Files
smart-org/apps/api/app/domain/identity/models.py
T
mjalmoudy94 5095a29df9 feat(org): platform invites, roles, org sharing (v2 phase A1-1)
- New platform_invites table (migration a1b2c3d4e5f1) + PlatformInviteRow
- Owner-only endpoints in admin_users.py: issue/list/revoke platform invites
- invites.py: two-kind resolution (org + platform), email-supplied accept path
- auth.py /me gains needs_onboarding (manager, 0 orgs, not owner)
- organizations.py: transfer-ownership, leave (last owner guard), list/revoke org invites
- contracts + web UI: admin/users invite modal, accept-invite platform variant, onboarding gating
- Tests: test_org_platform_invites.py (18), org-sharing.test.tsx; report under docs/plan/reports

Note: (app)/settings "اشتراک‌گذاری سازمان" section (1.10) remains presentational work;
endpoints are done and tested.
2026-10-14 09:14:09 +03:30

237 lines
7.0 KiB
Python

"""Identity & Access domain: users, organizations, memberships, invites."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import StrEnum
from app.domain.base import new_id, utcnow
class Role(StrEnum):
OWNER = "owner"
ADMIN = "admin"
MANAGER = "manager"
MEMBER = "member"
VIEWER = "viewer"
class PlatformRole(StrEnum):
"""Platform-wide role — a different axis from the in-organization ``Role``.
The platform has exactly three levels, and exactly one ``OWNER`` account
exists (enforced by a partial unique index on ``users.platform_role``):
- ``OWNER`` — the single owner of the whole system. The only account that
may enter the admin console, create or demote managers, and transfer
ownership.
- ``MANAGER`` — an account created by the owner. A manager may create
their own organizations (becoming their organization owner) and join
other managers' organizations by invitation.
- ``USER`` — a plain account. Has no platform privileges: cannot create
organizations, only works inside organizations it was invited to.
"""
OWNER = "owner"
MANAGER = "manager"
USER = "user"
#: Roles that may create a new organization. Everyone else must be invited.
PLATFORM_ROLES_CAN_CREATE_ORG = frozenset({PlatformRole.OWNER, PlatformRole.MANAGER})
#: Higher rank implies every permission of the lower ranks.
ROLE_RANK: dict[Role, int] = {
Role.VIEWER: 10,
Role.MEMBER: 20,
Role.MANAGER: 30,
Role.ADMIN: 40,
Role.OWNER: 50,
}
#: Capabilities used across the application layer.
CAN_MANAGE_ORG = "org.manage"
CAN_MANAGE_MEMBERS = "org.members"
CAN_MANAGE_PROJECTS = "projects.manage"
CAN_MANAGE_EMPLOYEES = "employees.manage"
CAN_RUN_TASKS = "tasks.run"
CAN_VIEW_BILLING = "billing.view"
CAN_MANAGE_BILLING = "billing.manage"
CAN_VIEW_AUDIT = "audit.view"
CAN_USE_SANDBOX = "sandbox.use"
_ROLE_CAPABILITIES: dict[Role, set[str]] = {
Role.VIEWER: set(),
Role.MEMBER: {CAN_RUN_TASKS, CAN_VIEW_BILLING},
Role.MANAGER: {
CAN_RUN_TASKS,
CAN_VIEW_BILLING,
CAN_MANAGE_PROJECTS,
CAN_MANAGE_EMPLOYEES,
CAN_USE_SANDBOX,
},
Role.ADMIN: {
CAN_RUN_TASKS,
CAN_VIEW_BILLING,
CAN_MANAGE_BILLING,
CAN_MANAGE_PROJECTS,
CAN_MANAGE_EMPLOYEES,
CAN_USE_SANDBOX,
CAN_MANAGE_MEMBERS,
CAN_VIEW_AUDIT,
},
Role.OWNER: {
CAN_RUN_TASKS,
CAN_VIEW_BILLING,
CAN_MANAGE_BILLING,
CAN_MANAGE_PROJECTS,
CAN_MANAGE_EMPLOYEES,
CAN_USE_SANDBOX,
CAN_MANAGE_MEMBERS,
CAN_VIEW_AUDIT,
CAN_MANAGE_ORG,
},
}
def role_has_capability(role: Role, capability: str) -> bool:
return capability in _ROLE_CAPABILITIES.get(role, set())
class MembershipStatus(StrEnum):
ACTIVE = "active"
INVITED = "invited"
SUSPENDED = "suspended"
@dataclass(slots=True, kw_only=True)
class User:
email: str
password_hash: str
name: str = ""
id: str = field(default_factory=new_id)
avatar_url: str | None = None
#: Platform-wide role. ``is_platform_owner`` / ``is_manager`` are derived
#: from it; only one ``OWNER`` may exist (see the partial unique index on
#: ``users.platform_role``).
platform_role: PlatformRole = PlatformRole.USER
is_active: bool = True
created_at: datetime = field(default_factory=utcnow)
updated_at: datetime = field(default_factory=utcnow)
@property
def is_platform_owner(self) -> bool:
return self.platform_role == PlatformRole.OWNER
@property
def is_manager(self) -> bool:
return self.platform_role == PlatformRole.MANAGER
@property
def can_create_organization(self) -> bool:
return self.platform_role in PLATFORM_ROLES_CAN_CREATE_ORG
@property
def display_name(self) -> str:
return self.name.strip() or self.email.split("@")[0]
def normalized_email(self) -> str:
return self.email.strip().lower()
@dataclass(slots=True, kw_only=True)
class Organization:
name: str
slug: str
owner_user_id: str
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Membership:
organization_id: str
user_id: str
role: Role = Role.MEMBER
status: MembershipStatus = MembershipStatus.ACTIVE
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@property
def is_active(self) -> bool:
return self.status == MembershipStatus.ACTIVE
def has(self, capability: str) -> bool:
return self.is_active and role_has_capability(self.role, capability)
def outranks(self, other_role: Role) -> bool:
return ROLE_RANK[self.role] > ROLE_RANK[other_role]
@dataclass(slots=True, kw_only=True)
class Invite:
organization_id: str
email: str
role: Role
token_hash: str
expires_at: datetime
id: str = field(default_factory=new_id)
invited_by_user_id: str | None = None
accepted_at: datetime | None = None
created_at: datetime = field(default_factory=utcnow)
def is_expired(self, now: datetime | None = None) -> bool:
return (now or utcnow()) >= self.expires_at
def is_pending(self, now: datetime | None = None) -> bool:
return self.accepted_at is None and not self.is_expired(now)
@dataclass(slots=True, kw_only=True)
class PlatformInvite:
"""The platform owner's invitation to create a new manager account (D12).
Different in kind from an ``Invite``: an org invite joins someone to an
organization that already exists, while this one mints the platform account
itself. The token lives only as a hash.
"""
token_hash: str
created_by_user_id: str
expires_at: datetime
email: str | None = None
platform_role: PlatformRole = PlatformRole.MANAGER
used_at: datetime | None = None
used_by_user_id: str | None = None
revoked_at: datetime | None = None
revoked_by_user_id: str | None = None
note: str = ""
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@property
def is_redeemed(self) -> bool:
return self.used_at is not None
@property
def is_revoked(self) -> bool:
return self.revoked_at is not None
def is_expired(self, now: datetime | None = None) -> bool:
"""Redeeming a used or revoked link is also "expired" — one helper, one rule.
Both sides are normalised to aware before comparing: the row comes back
from SQLite with the offset stripped, while ``utcnow()`` carries one, and
comparing the two raises rather than answering.
"""
if self.used_at is not None or self.revoked_at is not None:
return True
return _aware(self.expires_at) <= _aware(now or utcnow())
def _aware(value: datetime) -> datetime:
"""A datetime guaranteed to carry a UTC offset."""
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)