ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده

- 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:
mjalmoudy94
2026-10-13 21:58:41 +03:30
parent 6937b88ca7
commit 8eb631aedf
41 changed files with 2952 additions and 454 deletions
+107 -34
View File
@@ -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