ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمانهای مدیریتشده
- 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:
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user