ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمانهای مدیریتشده
- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین میکند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد. - ثبتنام عمومی حذف شد (POST /auth/signup). حسابها فقط با POST /admin/users (فقط مالک، با رمز موقت یکبارمصرف) یا با پذیرش لینک دعوت ساخته میشوند. در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوتشده رمز تعیین میکند؛ اگر وجود داشته باشد لینک هرگز جای reset رمز را نمیگیرد. - ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو میشود. - تنزل یا حذف مدیر، سازمانهایش را به مالک سامانه منتقل میکند (مالکیت + عضویت، با audit). حساب مالک از API تغییرناپذیر است؛ جابهجایی صندلی فقط با POST /admin/transfer-ownership و در یک تراکنش انجام میشود. - افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی. - فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup، گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ. - PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان رفتار کند (پیشتر حذف کاربر، عضویتهایش را یتیم میگذاشت). - تست: ۳۰۰ تست بکاند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست فرانتاند؛ ruff، mypy، tsc، eslint و build تمیز.
This commit is contained in:
+107
-34
@@ -1,7 +1,7 @@
|
||||
"""Idempotent development seed.
|
||||
|
||||
Run with ``pnpm db:seed``. Creates a platform owner, a demo user, a sample
|
||||
organization that both of them own, the system agent templates, routing
|
||||
Run with ``pnpm db:seed``. Creates the platform owner, a demo *manager* with a
|
||||
sample organization that manager owns, the system agent templates, routing
|
||||
policies, rate cards and the agy (Google Antigravity) provider with its model
|
||||
catalog. agy is the only model provider the platform talks to; accounts are
|
||||
added from the admin console.
|
||||
@@ -11,6 +11,16 @@ Safe to run repeatedly: every step checks for existing rows first.
|
||||
The two accounts it prints at the end are meant to be usable immediately —
|
||||
``tests/test_seed.py`` asserts exactly that, because a seed whose credentials the
|
||||
login endpoint rejects is worse than no seed at all.
|
||||
|
||||
Platform hierarchy this seed models (there is deliberately no public signup):
|
||||
|
||||
- exactly one ``owner`` — the account that owns the whole system and is the
|
||||
only one allowed into the admin console;
|
||||
- the demo account is a ``manager`` — the tier that may create organizations
|
||||
and join other managers' organizations by invitation;
|
||||
- the platform owner is *not* auto-joined to any organization: the product
|
||||
rule is that the owner observes tenants from the console, rather than
|
||||
silently sitting inside their data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,7 +28,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.core.database import Base, SessionFactory, engine
|
||||
from app.core.security import hash_password
|
||||
@@ -32,7 +42,14 @@ from app.domain.ai_control.models import (
|
||||
RoutingMode,
|
||||
RoutingPolicy,
|
||||
)
|
||||
from app.domain.identity.models import Membership, MembershipStatus, Organization, Role, User
|
||||
from app.domain.identity.models import (
|
||||
Membership,
|
||||
MembershipStatus,
|
||||
Organization,
|
||||
PlatformRole,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from app.domain.sandbox.models import SandboxProviderKind, SandboxTemplate
|
||||
from app.domain.workforce.models import AgentTemplate, RoleKey
|
||||
from app.infrastructure.db.models import (
|
||||
@@ -45,6 +62,7 @@ from app.infrastructure.db.models import (
|
||||
SandboxTemplateRow,
|
||||
UserRow,
|
||||
)
|
||||
from app.infrastructure.repositories.identity import UserRepository
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
|
||||
logger = logging.getLogger("agy.seed")
|
||||
@@ -61,7 +79,6 @@ DEMO_PASSWORD = "demo12345"
|
||||
#: the seed to be idempotent — see ``_ensure_org``.
|
||||
DEMO_ORG_NAME = "سازمان نمونه"
|
||||
DEMO_ORG_SLUG = "sample-org"
|
||||
|
||||
#: The agy (Google Antigravity) model catalog. ``internal_name`` is the model id
|
||||
#: the Antigravity backend expects; the display name is admin-only. The 4th
|
||||
#: element is the reasoning effort (empty = provider default) — the console can
|
||||
@@ -185,36 +202,66 @@ SANDBOX_TEMPLATES: list[tuple[str, str, str, float, int, int]] = [
|
||||
|
||||
|
||||
async def _ensure_user(
|
||||
session, *, email: str, password: str, name: str, platform_owner: bool
|
||||
session, *, email: str, password: str, name: str, platform_role: PlatformRole
|
||||
) -> User:
|
||||
"""Get or create one seeded account, converging its platform role.
|
||||
|
||||
The reconciliation matters for installs seeded *before* the single-owner
|
||||
model: the migration already mapped the old boolean onto
|
||||
``platform_role``, and this pass fixes everything the migration could not
|
||||
know (for example which of several legacy owners the seed intended).
|
||||
Exactly one row may hold ``owner`` — the partial unique index enforces it —
|
||||
so promoting this account first demotes any other owner to ``manager``.
|
||||
"""
|
||||
existing = (
|
||||
await session.execute(select(UserRow).where(UserRow.email == email))
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
if existing is not None:
|
||||
if existing.platform_role != platform_role.value:
|
||||
if platform_role == PlatformRole.OWNER:
|
||||
# Demote the current seat holder before taking it, so the
|
||||
# unique index never sees two owners.
|
||||
await session.execute(
|
||||
update(UserRow)
|
||||
.where(
|
||||
UserRow.platform_role == PlatformRole.OWNER.value,
|
||||
UserRow.id != existing.id,
|
||||
)
|
||||
.values(platform_role=PlatformRole.MANAGER.value)
|
||||
)
|
||||
existing.platform_role = platform_role.value
|
||||
await session.flush()
|
||||
return User(
|
||||
id=existing.id, email=existing.email, password_hash=existing.password_hash,
|
||||
name=existing.name, is_platform_owner=existing.is_platform_owner,
|
||||
id=existing.id,
|
||||
email=existing.email,
|
||||
password_hash=existing.password_hash,
|
||||
name=existing.name,
|
||||
platform_role=PlatformRole(existing.platform_role),
|
||||
)
|
||||
from app.infrastructure.repositories.identity import UserRepository
|
||||
|
||||
user = await UserRepository(session).create(
|
||||
User(
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
name=name,
|
||||
is_platform_owner=platform_owner,
|
||||
platform_role=platform_role,
|
||||
)
|
||||
)
|
||||
logger.info("created user %s", email)
|
||||
logger.info("created user %s (%s)", email, platform_role.value)
|
||||
return user
|
||||
|
||||
|
||||
async def _ensure_org(session, *, members: list[User]) -> Organization:
|
||||
"""Get or create the sample organization, with every given user in it.
|
||||
async def _ensure_org(session, *, owner: User) -> Organization:
|
||||
"""Get or create the sample organization, owned by ``owner``.
|
||||
|
||||
Idempotent in all three parts: the organization, its memberships and its
|
||||
credit wallet. A developer who runs ``pnpm db:seed`` twice must end up with
|
||||
exactly one organization and no duplicate rows.
|
||||
Idempotent in all three parts: the organization, the owner's membership and
|
||||
the credit wallet. A developer who runs ``pnpm db:seed`` twice must end up
|
||||
with exactly one organization and no duplicate rows.
|
||||
|
||||
The platform owner is deliberately *not* a member — see the module
|
||||
docstring. Any membership a platform owner picked up in an older database
|
||||
(the seed used to join both accounts) is removed here so the model stays
|
||||
honest after a re-seed.
|
||||
"""
|
||||
from app.infrastructure.repositories.identity import (
|
||||
MembershipRepository,
|
||||
@@ -232,22 +279,46 @@ async def _ensure_org(session, *, members: list[User]) -> Organization:
|
||||
org = await orgs.get_by_slug(DEMO_ORG_SLUG)
|
||||
if org is None:
|
||||
org = await orgs.create(
|
||||
Organization(name=DEMO_ORG_NAME, slug=DEMO_ORG_SLUG, owner_user_id=members[0].id)
|
||||
Organization(name=DEMO_ORG_NAME, slug=DEMO_ORG_SLUG, owner_user_id=owner.id)
|
||||
)
|
||||
logger.info("created organization %s", org.slug)
|
||||
elif org.owner_user_id != owner.id:
|
||||
await orgs.reassign_owner(org.id, owner.id)
|
||||
org = await orgs.get(org.id)
|
||||
assert org is not None
|
||||
|
||||
# Every listed user becomes an owner of the sample org. Without this the
|
||||
# demo account can log in but belongs to no organization, so every screen
|
||||
# The org owner is always an active OWNER member. Without this the demo
|
||||
# account can log in but belongs to no organization, so every screen
|
||||
# renders empty and the account looks broken.
|
||||
for user in members:
|
||||
if await memberships.get(org.id, user.id) is None:
|
||||
await memberships.add(
|
||||
Membership(
|
||||
organization_id=org.id, user_id=user.id,
|
||||
role=Role.OWNER, status=MembershipStatus.ACTIVE,
|
||||
)
|
||||
membership = await memberships.get(org.id, owner.id)
|
||||
if membership is None:
|
||||
await memberships.add(
|
||||
Membership(
|
||||
organization_id=org.id, user_id=owner.id,
|
||||
role=Role.OWNER, status=MembershipStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
logger.info("added %s to %s as owner", owner.email, org.slug)
|
||||
elif membership.role != Role.OWNER or not membership.is_active:
|
||||
await memberships.set_role(membership.id, Role.OWNER)
|
||||
await memberships.set_status(membership.id, MembershipStatus.ACTIVE)
|
||||
|
||||
# Platform owners observe tenants from the console; they do not sit inside
|
||||
# them. Remove any such membership left by an older seed.
|
||||
platform_owners = (
|
||||
await session.execute(
|
||||
select(UserRow).where(UserRow.platform_role == PlatformRole.OWNER.value)
|
||||
)
|
||||
).scalars().all()
|
||||
for platform_owner in platform_owners:
|
||||
stale = await memberships.get(org.id, platform_owner.id)
|
||||
if stale is not None:
|
||||
await memberships.remove(stale.id)
|
||||
logger.info(
|
||||
"removed platform owner %s from %s (owner stays out of tenants)",
|
||||
platform_owner.email,
|
||||
org.slug,
|
||||
)
|
||||
logger.info("added %s to %s as owner", user.email, org.slug)
|
||||
|
||||
wallet = (
|
||||
await session.execute(
|
||||
@@ -264,17 +335,19 @@ async def seed() -> None:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with SessionFactory() as session:
|
||||
owner = await _ensure_user(
|
||||
await _ensure_user(
|
||||
session, email=PLATFORM_OWNER_EMAIL, password=PLATFORM_OWNER_PASSWORD,
|
||||
name="مالک سامانه", platform_owner=True,
|
||||
name="مالک سامانه", platform_role=PlatformRole.OWNER,
|
||||
)
|
||||
# The demo account is a *manager*: the tier the owner creates that can
|
||||
# build its own organizations. It owns the sample organization, so
|
||||
# logging in with it lands on a populated dashboard rather than an
|
||||
# empty one.
|
||||
demo = await _ensure_user(
|
||||
session, email=DEMO_EMAIL, password=DEMO_PASSWORD,
|
||||
name="کاربر نمونه", platform_owner=False,
|
||||
name="کاربر نمونه", platform_role=PlatformRole.MANAGER,
|
||||
)
|
||||
# Both accounts join the sample organization, so a developer who logs in
|
||||
# as either one lands on a populated dashboard rather than an empty one.
|
||||
await _ensure_org(session, members=[owner, demo])
|
||||
await _ensure_org(session, owner=demo)
|
||||
|
||||
# The agy catalog (Google Antigravity). agy is the only model provider,
|
||||
# so the provider row and its models are created on a first run and
|
||||
|
||||
Reference in New Issue
Block a user