Files
smart-org/apps/api/app/infrastructure/repositories/identity.py
T
mjalmoudy94 8eb631aedf ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده
- 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 تمیز.
2026-10-13 21:58:41 +03:30

338 lines
12 KiB
Python

"""Repositories for Identity & Access.
Each repository maps between the ORM rows and the pure domain dataclasses, so
the domain layer never sees SQLAlchemy.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.identity.models import (
Invite,
Membership,
MembershipStatus,
Organization,
PlatformRole,
Role,
User,
)
from app.infrastructure.db.models import (
InviteRow,
MembershipRow,
OrganizationRow,
UserRow,
)
def _aware(value: datetime) -> datetime:
"""Re-attach UTC to a timestamp read from the database.
SQLite drops ``tzinfo`` on round-trip while Postgres keeps it, so comparing
a freshly computed aware timestamp (``Invite.is_expired`` against
``utcnow()``) against one read back raises ``TypeError`` on one backend and
works on the other. Normalising at the read boundary keeps every consumer —
domain rules and API output alike — in a single frame.
"""
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value
def _user_to_domain(row: UserRow) -> User:
return User(
id=row.id,
email=row.email,
password_hash=row.password_hash,
name=row.name,
avatar_url=row.avatar_url,
platform_role=PlatformRole(row.platform_role),
is_active=row.is_active,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _org_to_domain(row: OrganizationRow) -> Organization:
return Organization(
id=row.id, name=row.name, slug=row.slug,
owner_user_id=row.owner_user_id, created_at=row.created_at,
)
def _membership_to_domain(row: MembershipRow) -> Membership:
return Membership(
id=row.id,
organization_id=row.organization_id,
user_id=row.user_id,
role=Role(row.role),
status=MembershipStatus(row.status),
created_at=row.created_at,
)
def _invite_to_domain(row: InviteRow) -> Invite:
return Invite(
id=row.id,
organization_id=row.organization_id,
email=row.email,
role=Role(row.role),
token_hash=row.token_hash,
invited_by_user_id=row.invited_by_user_id,
expires_at=_aware(row.expires_at),
accepted_at=_aware(row.accepted_at) if row.accepted_at else None,
created_at=row.created_at,
)
class UserRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, user_id: str) -> User | None:
row = await self.session.get(UserRow, user_id)
return _user_to_domain(row) if row else None
async def get_by_email(self, email: str) -> User | None:
stmt = select(UserRow).where(func.lower(UserRow.email) == email.strip().lower())
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _user_to_domain(row) if row else None
async def create(self, user: User) -> User:
row = UserRow(
id=user.id,
email=user.normalized_email(),
password_hash=user.password_hash,
name=user.name,
avatar_url=user.avatar_url,
platform_role=user.platform_role.value,
is_active=user.is_active,
created_at=user.created_at,
updated_at=user.updated_at,
)
self.session.add(row)
await self.session.flush()
return _user_to_domain(row)
async def update_password(self, user_id: str, password_hash: str) -> None:
row = await self.session.get(UserRow, user_id)
if row:
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())
class OrganizationRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, org_id: str) -> Organization | None:
row = await self.session.get(OrganizationRow, org_id)
return _org_to_domain(row) if row else None
async def get_by_slug(self, slug: str) -> Organization | None:
stmt = select(OrganizationRow).where(OrganizationRow.slug == slug)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _org_to_domain(row) if row else None
async def list_all_slugs(self) -> set[str]:
rows = (await self.session.execute(select(OrganizationRow.slug))).scalars().all()
return set(rows)
async def create(self, org: Organization) -> Organization:
row = OrganizationRow(
id=org.id, name=org.name, slug=org.slug,
owner_user_id=org.owner_user_id, created_at=org.created_at,
)
self.session.add(row)
await self.session.flush()
return _org_to_domain(row)
async def list_for_user(self, user_id: str) -> list[tuple[Organization, Role]]:
stmt = (
select(OrganizationRow, MembershipRow.role)
.join(MembershipRow, MembershipRow.organization_id == OrganizationRow.id)
.where(MembershipRow.user_id == user_id, MembershipRow.status == "active")
.order_by(OrganizationRow.created_at)
)
rows = (await self.session.execute(stmt)).all()
return [(_org_to_domain(o), Role(r)) for o, r in rows]
async def list_all(self) -> list[Organization]:
"""Every organization. Platform-owner console only — never expose this
through a tenant-scoped endpoint."""
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:
await self.session.delete(row)
await self.session.flush()
async def count(self) -> int:
return int(
(await self.session.execute(select(func.count(OrganizationRow.id)))).scalar_one()
)
class MembershipRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, org_id: str, user_id: str) -> Membership | None:
stmt = select(MembershipRow).where(
MembershipRow.organization_id == org_id, MembershipRow.user_id == user_id
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _membership_to_domain(row) if row else None
async def get_by_id(self, membership_id: str) -> Membership | None:
row = await self.session.get(MembershipRow, membership_id)
return _membership_to_domain(row) if row else None
async def list_for_org(self, org_id: str) -> list[Membership]:
stmt = (
select(MembershipRow)
.where(MembershipRow.organization_id == org_id)
.order_by(MembershipRow.created_at)
)
return [_membership_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def add(self, membership: Membership) -> Membership:
row = MembershipRow(
id=membership.id,
organization_id=membership.organization_id,
user_id=membership.user_id,
role=membership.role.value,
status=membership.status.value,
created_at=membership.created_at,
)
self.session.add(row)
await self.session.flush()
return _membership_to_domain(row)
async def set_role(self, membership_id: str, role: Role) -> None:
row = await self.session.get(MembershipRow, membership_id)
if row:
row.role = role.value
await self.session.flush()
async def set_status(self, membership_id: str, status: MembershipStatus) -> None:
row = await self.session.get(MembershipRow, membership_id)
if row:
row.status = status.value
await self.session.flush()
async def remove(self, membership_id: str) -> None:
row = await self.session.get(MembershipRow, membership_id)
if row:
await self.session.delete(row)
await self.session.flush()
async def count_owners(self, org_id: str) -> int:
stmt = select(func.count(MembershipRow.id)).where(
MembershipRow.organization_id == org_id,
MembershipRow.role == Role.OWNER.value,
MembershipRow.status == MembershipStatus.ACTIVE.value,
)
return int((await self.session.execute(stmt)).scalar_one())
class InviteRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_by_token_hash(self, token_hash: str) -> Invite | None:
stmt = select(InviteRow).where(InviteRow.token_hash == token_hash)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _invite_to_domain(row) if row else None
async def list_pending(self, org_id: str) -> list[Invite]:
stmt = (
select(InviteRow)
.where(InviteRow.organization_id == org_id, InviteRow.accepted_at.is_(None))
.order_by(InviteRow.created_at.desc())
)
return [_invite_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(self, invite: Invite) -> Invite:
row = InviteRow(
id=invite.id,
organization_id=invite.organization_id,
email=invite.email.strip().lower(),
role=invite.role.value,
token_hash=invite.token_hash,
invited_by_user_id=invite.invited_by_user_id,
expires_at=invite.expires_at,
accepted_at=invite.accepted_at,
created_at=invite.created_at,
)
self.session.add(row)
await self.session.flush()
return _invite_to_domain(row)
async def mark_accepted(self, invite_id: str, when: datetime) -> None:
row = await self.session.get(InviteRow, invite_id)
if row:
row.accepted_at = when
await self.session.flush()