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

- 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
@@ -0,0 +1,86 @@
"""Platform user administration — the owner-side account lifecycle.
Only the platform owner may call into this module; the HTTP layer enforces that
with ``require_platform_owner``. It owns three rules that are easy to get wrong
and expensive to get wrong:
1. **Exactly one ``OWNER`` exists.** Creating a second owner is refused;
moving the seat is the single transactional :func:`transfer_ownership`,
which demotes the previous owner in the same step.
2. **The owner account is immutable from here** — it cannot be demoted,
deactivated or deleted through the API. An owner who lost access is fixed
by the other owner-seat holder or at the database, never by a quiet API
call.
3. **A departing manager's organizations move to the platform owner.** Both
``organizations.owner_user_id`` *and* the membership table are updated: an
organization whose owner has no membership is invisible to its own owner,
and a leftover ``owner`` membership would contradict the new
``owner_user_id``.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.identity.models import (
Membership,
MembershipStatus,
Role,
User,
)
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
)
@dataclass(slots=True)
class OrganizationTransfer:
"""One organization that changed hands, for the audit trail."""
organization_id: str
organization_name: str
async def transfer_owned_organizations(
session: AsyncSession, *, from_user_id: str, to_user: User
) -> list[OrganizationTransfer]:
"""Move every organization owned by ``from_user_id`` to ``to_user``.
The departing user keeps an ``admin`` membership where they had ``owner``:
they built the tenant, and silently losing all access would be a bigger
surprise than keeping working access without ownership.
"""
orgs = OrganizationRepository(session)
memberships = MembershipRepository(session)
transferred: list[OrganizationTransfer] = []
for org in await orgs.list_owned_by(from_user_id):
await orgs.reassign_owner(org.id, to_user.id)
if await memberships.get(org.id, to_user.id) is None:
await memberships.add(
Membership(
organization_id=org.id,
user_id=to_user.id,
role=Role.OWNER,
status=MembershipStatus.ACTIVE,
)
)
previous = await memberships.get(org.id, from_user_id)
if previous is not None and previous.role == Role.OWNER:
await memberships.set_role(previous.id, Role.ADMIN)
transferred.append(
OrganizationTransfer(organization_id=org.id, organization_name=org.name)
)
return transferred
async def demote_manager_accounts_organizations(
session: AsyncSession, *, manager: User, owner: User
) -> list[OrganizationTransfer]:
"""Convenience wrapper used when a manager leaves the manager tier."""
return await transfer_owned_organizations(
session, from_user_id=manager.id, to_user=owner
)