ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمانهای مدیریتشده
- 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:
@@ -21,6 +21,7 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -43,13 +44,29 @@ def _created() -> Mapped[datetime]:
|
||||
|
||||
class UserRow(Base):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
# Exactly one platform owner may exist — ever. A partial unique index
|
||||
# makes that a *database* invariant, not just an application habit: a
|
||||
# second INSERT with platform_role='owner' fails even if some future
|
||||
# code path forgets to check. Both dialects support partial indexes.
|
||||
Index(
|
||||
"uq_single_platform_owner",
|
||||
"platform_role",
|
||||
unique=True,
|
||||
sqlite_where=text("platform_role = 'owner'"),
|
||||
postgresql_where=text("platform_role = 'owner'"),
|
||||
),
|
||||
Index("ix_user_platform_role", "platform_role"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(160), default="", nullable=False)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
is_platform_owner: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
#: Platform-wide role: ``owner`` | ``manager`` | ``user`` (see
|
||||
#: ``PlatformRole``). Exactly one row may hold ``owner``.
|
||||
platform_role: Mapped[str] = mapped_column(String(16), default="user", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.domain.identity.models import (
|
||||
Membership,
|
||||
MembershipStatus,
|
||||
Organization,
|
||||
PlatformRole,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
@@ -48,7 +49,7 @@ def _user_to_domain(row: UserRow) -> User:
|
||||
password_hash=row.password_hash,
|
||||
name=row.name,
|
||||
avatar_url=row.avatar_url,
|
||||
is_platform_owner=row.is_platform_owner,
|
||||
platform_role=PlatformRole(row.platform_role),
|
||||
is_active=row.is_active,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
@@ -107,7 +108,7 @@ class UserRepository:
|
||||
password_hash=user.password_hash,
|
||||
name=user.name,
|
||||
avatar_url=user.avatar_url,
|
||||
is_platform_owner=user.is_platform_owner,
|
||||
platform_role=user.platform_role.value,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
@@ -122,6 +123,41 @@ class UserRepository:
|
||||
row.password_hash = password_hash
|
||||
await self.session.flush()
|
||||
|
||||
async def set_platform_role(self, user_id: str, role: PlatformRole) -> None:
|
||||
row = await self.session.get(UserRow, user_id)
|
||||
if row:
|
||||
row.platform_role = role.value
|
||||
await self.session.flush()
|
||||
|
||||
async def set_active(self, user_id: str, active: bool) -> None:
|
||||
row = await self.session.get(UserRow, user_id)
|
||||
if row:
|
||||
row.is_active = active
|
||||
await self.session.flush()
|
||||
|
||||
async def delete(self, user_id: str) -> None:
|
||||
"""Remove the account. Memberships cascade via the FK."""
|
||||
row = await self.session.get(UserRow, user_id)
|
||||
if row:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
async def get_platform_owner(self) -> User | None:
|
||||
"""The single platform owner. ``None`` on a system that never had one."""
|
||||
stmt = select(UserRow).where(
|
||||
UserRow.platform_role == PlatformRole.OWNER.value
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalars().first()
|
||||
return _user_to_domain(row) if row else None
|
||||
|
||||
async def list_all(self) -> list[User]:
|
||||
"""Every account. Platform-owner console only — never expose this
|
||||
through a tenant-scoped endpoint."""
|
||||
stmt = select(UserRow).order_by(UserRow.created_at)
|
||||
return [
|
||||
_user_to_domain(r) for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def count(self) -> int:
|
||||
return int((await self.session.execute(select(func.count(UserRow.id)))).scalar_one())
|
||||
|
||||
@@ -168,6 +204,24 @@ class OrganizationRepository:
|
||||
stmt = select(OrganizationRow).order_by(OrganizationRow.created_at)
|
||||
return [_org_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_owned_by(self, user_id: str) -> list[Organization]:
|
||||
"""Organizations whose ``owner_user_id`` is this user — the set that
|
||||
must move when a manager is demoted or deleted."""
|
||||
stmt = (
|
||||
select(OrganizationRow)
|
||||
.where(OrganizationRow.owner_user_id == user_id)
|
||||
.order_by(OrganizationRow.created_at)
|
||||
)
|
||||
return [
|
||||
_org_to_domain(r) for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def reassign_owner(self, org_id: str, new_owner_user_id: str) -> None:
|
||||
row = await self.session.get(OrganizationRow, org_id)
|
||||
if row:
|
||||
row.owner_user_id = new_owner_user_id
|
||||
await self.session.flush()
|
||||
|
||||
async def delete(self, org_id: str) -> None:
|
||||
row = await self.session.get(OrganizationRow, org_id)
|
||||
if row:
|
||||
|
||||
Reference in New Issue
Block a user