ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمانهای مدیریتشده
- 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
|
||||
)
|
||||
@@ -7,7 +7,13 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -17,6 +23,25 @@ class Base(DeclarativeBase):
|
||||
"""Declarative base for every ORM table."""
|
||||
|
||||
|
||||
def enable_sqlite_foreign_keys(engine: AsyncEngine) -> None:
|
||||
"""Turn on ``PRAGMA foreign_keys`` for every connection of a SQLite engine.
|
||||
|
||||
SQLite defaults to ``foreign_keys=OFF``, which silently turns every
|
||||
``ondelete="CASCADE"`` in the schema into a no-op — a dev database then
|
||||
keeps orphaned children (memberships of a deleted user, documents of a
|
||||
deleted source) that production Postgres would have removed. One pragma
|
||||
per connection keeps the two backends behaving the same.
|
||||
"""
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _set_pragma(dbapi_connection, _connection_record) -> None: # noqa: ANN001
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def _engine_kwargs() -> dict:
|
||||
url = settings.database_url
|
||||
if url.startswith("sqlite"):
|
||||
@@ -32,6 +57,9 @@ def _engine_kwargs() -> dict:
|
||||
|
||||
engine = create_async_engine(settings.database_url, **_engine_kwargs())
|
||||
|
||||
if settings.database_url.startswith("sqlite"):
|
||||
enable_sqlite_foreign_keys(engine)
|
||||
|
||||
SessionFactory = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
|
||||
@@ -15,13 +15,31 @@ class AuditAction:
|
||||
LOGIN = "auth.login"
|
||||
LOGOUT = "auth.logout"
|
||||
SIGNUP = "auth.signup"
|
||||
#: A user changed their own password (or an owner-forced temp password was
|
||||
#: replaced). Auditable because it is the one action that can lock the
|
||||
#: legitimate owner out.
|
||||
PASSWORD_CHANGE = "auth.password_change"
|
||||
ORG_CREATE = "organization.create"
|
||||
ORG_DELETE = "organization.delete"
|
||||
#: An organization changed hands because its owning manager was demoted or
|
||||
#: deleted — the platform owner takes over. Never automatic for any other
|
||||
#: reason.
|
||||
ORG_OWNER_TRANSFER = "organization.owner_transfer"
|
||||
MEMBER_INVITE = "member.invite"
|
||||
#: An invited user redeemed their token and joined the organization.
|
||||
MEMBER_JOIN = "member.join"
|
||||
MEMBER_ROLE_CHANGE = "member.role_change"
|
||||
MEMBER_REMOVE = "member.remove"
|
||||
#: The platform owner created an account (manager or plain user).
|
||||
USER_CREATE = "user.create"
|
||||
#: A user's platform role changed (user <-> manager, or manager -> user).
|
||||
USER_PLATFORM_ROLE_CHANGE = "user.platform_role_change"
|
||||
#: An account was activated or deactivated.
|
||||
USER_ACTIVE_CHANGE = "user.active_change"
|
||||
#: An account was deleted by the platform owner.
|
||||
USER_DELETE = "user.delete"
|
||||
#: The single platform-owner seat moved to another account.
|
||||
OWNERSHIP_TRANSFER = "platform.ownership_transfer"
|
||||
EMPLOYEE_CREATE = "employee.create"
|
||||
EMPLOYEE_UPDATE = "employee.update"
|
||||
REPO_CREATE = "repo.create"
|
||||
|
||||
@@ -17,6 +17,31 @@ class Role(StrEnum):
|
||||
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,
|
||||
@@ -88,11 +113,26 @@ class User:
|
||||
name: str = ""
|
||||
id: str = field(default_factory=new_id)
|
||||
avatar_url: str | None = None
|
||||
is_platform_owner: bool = False
|
||||
#: 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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
"""Platform owner console: the account lifecycle (managers and users).
|
||||
|
||||
Everything in this router is owner-only, guarded at the *router* level like the
|
||||
rest of the console — an endpoint added later is protected without its author
|
||||
having to remember. The rules encoded here:
|
||||
|
||||
- The owner creates every manager account directly, with a temporary password
|
||||
shown exactly once. There is no public signup.
|
||||
- Platform role changes are limited to ``user`` <-> ``manager``; the owner seat
|
||||
moves only through ``POST /admin/transfer-ownership``.
|
||||
- The owner account itself cannot be demoted, deactivated or deleted here.
|
||||
- When a manager is demoted or deleted, the organizations they own move to the
|
||||
platform owner (see ``application/services/platform_users.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.platform_users import (
|
||||
transfer_owned_organizations,
|
||||
)
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import ConflictError, NotFoundError, PermissionDeniedError, ValidationError
|
||||
from app.core.security import hash_password
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import PlatformRole, User
|
||||
from app.infrastructure.repositories.identity import (
|
||||
OrganizationRepository,
|
||||
UserRepository,
|
||||
)
|
||||
from app.interfaces.http.deps import client_meta, require_platform_owner
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin",
|
||||
tags=["admin"],
|
||||
# Deny-by-default for the whole console — see ``routers/admin.py`` for why
|
||||
# this is a router-level dependency and not per-endpoint checks.
|
||||
dependencies=[Depends(require_platform_owner)],
|
||||
)
|
||||
|
||||
#: Length of the one-time password the owner hands to a new account. Twelve
|
||||
#: URL-safe characters is ~71 bits — brute-force proof at any login attempt
|
||||
#: rate this API permits, and short enough to read over a chat message.
|
||||
TEMP_PASSWORD_BYTES = 9
|
||||
|
||||
|
||||
def _temp_password() -> str:
|
||||
return secrets.token_urlsafe(TEMP_PASSWORD_BYTES)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AdminUserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
platform_role: str
|
||||
is_manager: bool
|
||||
is_platform_owner: bool
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
#: How many organizations this account owns — the number that decides what
|
||||
#: happens to them if the account is demoted or deleted.
|
||||
owned_organizations: int
|
||||
|
||||
|
||||
class AdminUserCreatedOut(BaseModel):
|
||||
user: AdminUserOut
|
||||
#: Shown exactly once, at creation. Never stored in plaintext and never
|
||||
#: retrievable afterwards — a reset issues a new one.
|
||||
temp_password: str
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
email: EmailStr
|
||||
name: str = Field(default="", max_length=160)
|
||||
#: ``owner`` is refused: the seat moves only via transfer-ownership.
|
||||
platform_role: PlatformRole = PlatformRole.USER
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
#: ``owner`` is refused here for the same reason.
|
||||
platform_role: PlatformRole | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class TransferOwnershipRequest(BaseModel):
|
||||
#: Must be an existing, active, non-owner account.
|
||||
user_id: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class OrganizationTransferOut(BaseModel):
|
||||
organization_id: str
|
||||
organization_name: str
|
||||
|
||||
|
||||
class DeleteUserResult(BaseModel):
|
||||
deleted_user_id: str
|
||||
transferred_organizations: list[OrganizationTransferOut]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_owner(session: AsyncSession) -> User:
|
||||
owner = await UserRepository(session).get_platform_owner()
|
||||
if owner is None: # pragma: no cover - seed guarantees one exists
|
||||
raise NotFoundError("مالک سامانه یافت نشد.")
|
||||
return owner
|
||||
|
||||
|
||||
async def _load_target(session: AsyncSession, user_id: str) -> User:
|
||||
target = await UserRepository(session).get(user_id)
|
||||
if target is None:
|
||||
raise NotFoundError("کاربر یافت نشد.")
|
||||
return target
|
||||
|
||||
|
||||
def _assert_mutable_target(target: User) -> None:
|
||||
"""The single owner seat is not mutable through this router."""
|
||||
if target.is_platform_owner:
|
||||
raise PermissionDeniedError(
|
||||
"حساب مالک سامانه از این مسیر قابل تغییر نیست؛ "
|
||||
"برای جابهجایی مالکیت از «انتقال مالکیت سامانه» استفاده کنید."
|
||||
)
|
||||
|
||||
|
||||
async def _owned_org_count(session: AsyncSession, user_id: str) -> int:
|
||||
return len(await OrganizationRepository(session).list_owned_by(user_id))
|
||||
|
||||
|
||||
async def _to_out(session: AsyncSession, user: User) -> AdminUserOut:
|
||||
return AdminUserOut(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.display_name,
|
||||
platform_role=user.platform_role.value,
|
||||
is_manager=user.is_manager,
|
||||
is_platform_owner=user.is_platform_owner,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
owned_organizations=await _owned_org_count(session, user.id),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserOut])
|
||||
async def list_users(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AdminUserOut]:
|
||||
users = await UserRepository(session).list_all()
|
||||
return [await _to_out(session, user) for user in users]
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserCreatedOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
payload: CreateUserRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AdminUserCreatedOut:
|
||||
"""Create a manager or plain account. The temp password is returned once.
|
||||
|
||||
Every account in the system is created here or through an invite link; the
|
||||
owner is the only one who can mint a ``manager`` (the tier that may create
|
||||
organizations). Creating a second ``owner`` is refused by the schema — the
|
||||
seat moves through the transfer-ownership endpoint only.
|
||||
"""
|
||||
if payload.platform_role == PlatformRole.OWNER:
|
||||
raise ValidationError(
|
||||
"مالک سامانه از این مسیر ساخته نمیشود؛ برای جابهجایی مالکیت "
|
||||
"از «انتقال مالکیت سامانه» استفاده کنید."
|
||||
)
|
||||
users = UserRepository(session)
|
||||
if await users.get_by_email(payload.email) is not None:
|
||||
raise ConflictError("این ایمیل قبلاً ثبت شده است.")
|
||||
|
||||
temp_password = _temp_password()
|
||||
user = await users.create(
|
||||
User(
|
||||
email=payload.email,
|
||||
password_hash=hash_password(temp_password),
|
||||
name=payload.name.strip(),
|
||||
platform_role=payload.platform_role,
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.USER_CREATE,
|
||||
actor_user_id=owner.id,
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
metadata={"email": user.email, "platform_role": user.platform_role.value},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return AdminUserCreatedOut(user=await _to_out(session, user), temp_password=temp_password)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=AdminUserOut)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
payload: UpdateUserRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AdminUserOut:
|
||||
"""Change a platform role (user <-> manager) or activate/deactivate.
|
||||
|
||||
Demoting a manager to a plain user hands every organization they own to the
|
||||
platform owner first — a demoted manager with a tenant still under their
|
||||
name would quietly keep the capacity to create tenants, which is what the
|
||||
demotion was meant to revoke.
|
||||
"""
|
||||
target = await _load_target(session, user_id)
|
||||
_assert_mutable_target(target)
|
||||
users = UserRepository(session)
|
||||
|
||||
transferred: list = []
|
||||
if payload.platform_role is not None and payload.platform_role != target.platform_role:
|
||||
if payload.platform_role == PlatformRole.OWNER:
|
||||
raise ValidationError(
|
||||
"ارتقا به مالک سامانه فقط از مسیر «انتقال مالکیت سامانه» ممکن است."
|
||||
)
|
||||
if target.is_manager and payload.platform_role == PlatformRole.USER:
|
||||
transferred = await transfer_owned_organizations(
|
||||
session, from_user_id=target.id, to_user=owner
|
||||
)
|
||||
await users.set_platform_role(target.id, payload.platform_role)
|
||||
target.platform_role = payload.platform_role
|
||||
|
||||
if payload.is_active is not None and payload.is_active != target.is_active:
|
||||
await users.set_active(target.id, payload.is_active)
|
||||
target.is_active = payload.is_active
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
if payload.platform_role is not None:
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.USER_PLATFORM_ROLE_CHANGE,
|
||||
actor_user_id=owner.id,
|
||||
target_type="user",
|
||||
target_id=target.id,
|
||||
metadata={
|
||||
"platform_role": payload.platform_role.value,
|
||||
"transferred_organizations": [t.organization_id for t in transferred],
|
||||
},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
if payload.is_active is not None:
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.USER_ACTIVE_CHANGE,
|
||||
actor_user_id=owner.id,
|
||||
target_type="user",
|
||||
target_id=target.id,
|
||||
metadata={"is_active": payload.is_active},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return await _to_out(session, target)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", response_model=DeleteUserResult)
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DeleteUserResult:
|
||||
"""Delete an account, moving its owned organizations to the platform owner.
|
||||
|
||||
Memberships cascade with the user row, so the deleted account disappears
|
||||
from every organization it was a member of — the organizations it *owned*
|
||||
survive under the platform owner instead of being orphaned or deleted with
|
||||
their data.
|
||||
"""
|
||||
target = await _load_target(session, user_id)
|
||||
_assert_mutable_target(target)
|
||||
|
||||
transferred = await transfer_owned_organizations(
|
||||
session, from_user_id=target.id, to_user=owner
|
||||
)
|
||||
await UserRepository(session).delete(target.id)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.USER_DELETE,
|
||||
actor_user_id=owner.id,
|
||||
target_type="user",
|
||||
target_id=target.id,
|
||||
metadata={
|
||||
"email": target.email,
|
||||
"transferred_organizations": [t.organization_id for t in transferred],
|
||||
},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return DeleteUserResult(
|
||||
deleted_user_id=target.id,
|
||||
transferred_organizations=[
|
||||
OrganizationTransferOut(
|
||||
organization_id=t.organization_id, organization_name=t.organization_name
|
||||
)
|
||||
for t in transferred
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/reset-password", response_model=AdminUserCreatedOut)
|
||||
async def reset_user_password(
|
||||
user_id: str,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AdminUserCreatedOut:
|
||||
"""Issue a new temp password for an account. Shown once, never stored raw."""
|
||||
target = await _load_target(session, user_id)
|
||||
|
||||
temp_password = _temp_password()
|
||||
await UserRepository(session).update_password(
|
||||
target.id, hash_password(temp_password)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.PASSWORD_CHANGE,
|
||||
actor_user_id=owner.id,
|
||||
target_type="user",
|
||||
target_id=target.id,
|
||||
metadata={"via": "owner_reset"},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return AdminUserCreatedOut(user=await _to_out(session, target), temp_password=temp_password)
|
||||
|
||||
|
||||
@router.post("/transfer-ownership", response_model=AdminUserOut)
|
||||
async def transfer_ownership(
|
||||
payload: TransferOwnershipRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AdminUserOut:
|
||||
"""Move the single owner seat to another active account.
|
||||
|
||||
The requester is demoted to ``manager`` in the same step, so the invariant
|
||||
"exactly one owner" holds throughout — there is no window in which two
|
||||
accounts own the platform, and no window in which none does. This is the
|
||||
safety valve for a lost owner account; everything else about the owner
|
||||
seat is immutable.
|
||||
"""
|
||||
successor = await _load_target(session, payload.user_id)
|
||||
if successor.is_platform_owner:
|
||||
raise ConflictError("این کاربر همین حالا مالک سامانه است.")
|
||||
if not successor.is_active:
|
||||
raise ValidationError("حساب غیرفعال را نمیتوان مالک سامانه کرد.")
|
||||
|
||||
users = UserRepository(session)
|
||||
# The requester keeps every organization they own; only the platform seat
|
||||
# moves. The successor is usually a manager, and their organizations stay
|
||||
# with them.
|
||||
#
|
||||
# Order matters: demote the current owner *before* promoting the successor,
|
||||
# so the partial unique index on platform_role='owner' never sees two rows
|
||||
# at once. Both writes land in the same transaction.
|
||||
await users.set_platform_role(owner.id, PlatformRole.MANAGER)
|
||||
await users.set_platform_role(successor.id, PlatformRole.OWNER)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.OWNERSHIP_TRANSFER,
|
||||
actor_user_id=owner.id,
|
||||
target_type="user",
|
||||
target_id=successor.id,
|
||||
metadata={"from_user_id": owner.id, "to_user_id": successor.id},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
updated = await users.get(successor.id)
|
||||
assert updated is not None
|
||||
return await _to_out(session, updated)
|
||||
@@ -1,4 +1,10 @@
|
||||
"""Authentication endpoints."""
|
||||
"""Authentication endpoints.
|
||||
|
||||
There is deliberately **no public signup**: accounts are created either by the
|
||||
platform owner (``POST /admin/users`` — managers and plain users) or by
|
||||
redeeming an organization invite (``POST /invites/accept`` with a password).
|
||||
Login, the profile view and self-service password change live here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,8 +14,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import AuthenticationError, ConflictError
|
||||
from app.core.security import create_access_token, hash_password, verify_password
|
||||
from app.core.errors import AuthenticationError, ValidationError
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import User
|
||||
from app.infrastructure.repositories.identity import (
|
||||
@@ -22,12 +32,6 @@ from app.interfaces.http.deps import client_meta, get_current_user
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class SignupRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
name: str = Field(default="", max_length=160)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
@@ -40,9 +44,19 @@ class UserOut(BaseModel):
|
||||
email: str
|
||||
name: str
|
||||
avatar_url: str | None = None
|
||||
#: Platform-wide role — ``owner`` | ``manager`` | ``user``. Exactly one
|
||||
#: account is ``owner``; only ``owner`` and ``manager`` may create
|
||||
#: organizations.
|
||||
platform_role: str = "user"
|
||||
is_manager: bool = False
|
||||
is_platform_owner: bool = False
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(min_length=1, max_length=128)
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class OrganizationSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
@@ -67,40 +81,12 @@ def _to_user_out(user: User) -> UserOut:
|
||||
email=user.email,
|
||||
name=user.display_name,
|
||||
avatar_url=user.avatar_url,
|
||||
platform_role=user.platform_role.value,
|
||||
is_manager=user.is_manager,
|
||||
is_platform_owner=user.is_platform_owner,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/signup", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def signup(
|
||||
payload: SignupRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TokenResponse:
|
||||
users = UserRepository(session)
|
||||
if await users.get_by_email(payload.email) is not None:
|
||||
raise ConflictError("این ایمیل قبلاً ثبت شده است.")
|
||||
|
||||
user = await users.create(
|
||||
User(
|
||||
email=payload.email,
|
||||
password_hash=hash_password(payload.password),
|
||||
name=payload.name.strip(),
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.SIGNUP,
|
||||
actor_user_id=user.id,
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user))
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
@@ -146,6 +132,38 @@ async def logout(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def change_password(
|
||||
payload: ChangePasswordRequest,
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""Replace the caller's own password.
|
||||
|
||||
This is how a temp password handed out by the platform owner (or by an
|
||||
invite) becomes a private one — without it, every account created by
|
||||
someone else would live on a shared secret forever.
|
||||
"""
|
||||
users = UserRepository(session)
|
||||
if not verify_password(payload.current_password, user.password_hash):
|
||||
raise AuthenticationError("رمز عبور فعلی نادرست است.")
|
||||
if payload.new_password == payload.current_password:
|
||||
raise ValidationError("رمز عبور جدید باید با رمز فعلی متفاوت باشد.")
|
||||
await users.update_password(user.id, hash_password(payload.new_password))
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.PASSWORD_CHANGE,
|
||||
actor_user_id=user.id,
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(
|
||||
user: User = Depends(get_current_user),
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
Deliberately *not* nested under ``/organizations/{org_id}``: the person
|
||||
accepting an invite is by definition not yet a member, so ``get_org_context``
|
||||
would (correctly) refuse them. Here the invite token itself is the
|
||||
authorization. It is stored only as a hash, matched to the signed-in user's
|
||||
email, and single-use — see ``_load_pending_invite``.
|
||||
authorization. It is stored only as a hash and is single-use.
|
||||
|
||||
Because signup is closed (accounts come from the owner or from an invite), the
|
||||
token doubles as an account-creation capability: a brand-new invitee redeems
|
||||
it **with a password**, which creates their account bound to the invited email
|
||||
and joins the organization in one step. The token can never reset the password
|
||||
of an *existing* account — those must authenticate first, which keeps a leaked
|
||||
link from taking over an account that already exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,7 +18,7 @@ from __future__ import annotations
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
@@ -23,21 +29,23 @@ from app.core.errors import (
|
||||
PermissionDeniedError,
|
||||
ValidationError,
|
||||
)
|
||||
from app.core.security import hash_token
|
||||
from app.core.security import hash_password, hash_token
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import (
|
||||
Invite,
|
||||
Membership,
|
||||
MembershipStatus,
|
||||
Organization,
|
||||
PlatformRole,
|
||||
User,
|
||||
)
|
||||
from app.infrastructure.repositories.identity import (
|
||||
InviteRepository,
|
||||
MembershipRepository,
|
||||
OrganizationRepository,
|
||||
UserRepository,
|
||||
)
|
||||
from app.interfaces.http.deps import client_meta, get_current_user
|
||||
from app.interfaces.http.deps import client_meta, get_optional_user
|
||||
|
||||
router = APIRouter(prefix="/invites", tags=["invites"])
|
||||
|
||||
@@ -51,10 +59,18 @@ class InvitePreview(BaseModel):
|
||||
#: True when the caller is already a member, so the UI can say "open it"
|
||||
#: instead of "join".
|
||||
already_member: bool
|
||||
#: True when an account exists for the invited email. The invitee then logs
|
||||
#: in first; anonymous redemption with a password is only for brand-new
|
||||
#: accounts (a token must never be able to reset an existing password).
|
||||
has_account: bool
|
||||
|
||||
|
||||
class AcceptInviteRequest(BaseModel):
|
||||
token: str
|
||||
#: Required only when redeeming anonymously — it becomes the password of
|
||||
#: the account the invite creates. Ignored for a signed-in user, who has
|
||||
#: already proven ownership of the email.
|
||||
password: str | None = Field(default=None, min_length=8, max_length=128)
|
||||
|
||||
|
||||
class AcceptInviteResult(BaseModel):
|
||||
@@ -64,22 +80,26 @@ class AcceptInviteResult(BaseModel):
|
||||
|
||||
|
||||
async def _load_pending_invite(
|
||||
token: str, user: User, session: AsyncSession
|
||||
token: str, user: User | None, session: AsyncSession
|
||||
) -> tuple[Invite, Organization]:
|
||||
"""Resolve a raw token into a redeemable invite, or raise.
|
||||
|
||||
Every rejection is a distinct, user-actionable error rather than a generic
|
||||
"invalid link": the three real failure modes (already used, expired, wrong
|
||||
"invalid link": the real failure modes (already used, expired, wrong
|
||||
account) each need a different response from the person holding the link.
|
||||
|
||||
The email binding is only enforced when a user is signed in: an anonymous
|
||||
holder of the token sees the preview but must create an account for exactly
|
||||
the invited email, which the accept path checks.
|
||||
"""
|
||||
invite = await InviteRepository(session).get_by_token_hash(hash_token(token))
|
||||
if invite is None:
|
||||
raise NotFoundError("این لینک دعوت معتبر نیست.")
|
||||
if invite.accepted_at is not None:
|
||||
raise ConflictError("این دعوتنامه قبلاً پذیرفته شده است.")
|
||||
raise ConflictError("این دعوتنامه قبلاً پذیرافته شده است.")
|
||||
if invite.is_expired():
|
||||
raise ValidationError("این دعوتنامه منقضی شده است؛ از مدیر بخواهید دوباره دعوت کند.")
|
||||
if invite.email.strip().lower() != user.email.strip().lower():
|
||||
if user is not None and invite.email.strip().lower() != user.email.strip().lower():
|
||||
raise PermissionDeniedError(
|
||||
"این دعوتنامه برای ایمیل دیگری صادر شده است؛ با همان ایمیل وارد شوید.",
|
||||
details={"invite_email": invite.email},
|
||||
@@ -94,19 +114,30 @@ async def _load_pending_invite(
|
||||
@router.get("/{token}", response_model=InvitePreview)
|
||||
async def preview_invite(
|
||||
token: str,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User | None = Depends(get_optional_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvitePreview:
|
||||
"""Show the invitee what they are about to join, without joining yet."""
|
||||
"""Show the invitee what they are about to join, without joining yet.
|
||||
|
||||
Works without a session on purpose: a brand-new invitee must be able to
|
||||
see which organization invited them (and whether they already have an
|
||||
account) *before* deciding to sign in or create one.
|
||||
"""
|
||||
invite, organization = await _load_pending_invite(token, user, session)
|
||||
existing = await MembershipRepository(session).get(organization.id, user.id)
|
||||
existing_user = await UserRepository(session).get_by_email(invite.email)
|
||||
membership = (
|
||||
await MembershipRepository(session).get(organization.id, user.id)
|
||||
if user is not None
|
||||
else None
|
||||
)
|
||||
return InvitePreview(
|
||||
organization_id=organization.id,
|
||||
organization_name=organization.name,
|
||||
email=invite.email,
|
||||
role=invite.role.value,
|
||||
expires_at=invite.expires_at,
|
||||
already_member=existing is not None,
|
||||
already_member=membership is not None,
|
||||
has_account=existing_user is not None,
|
||||
)
|
||||
|
||||
|
||||
@@ -114,17 +145,46 @@ async def preview_invite(
|
||||
async def accept_invite(
|
||||
payload: AcceptInviteRequest,
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User | None = Depends(get_optional_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AcceptInviteResult:
|
||||
"""Redeem an invite: create the membership and burn the token.
|
||||
"""Redeem an invite: create the account if needed, join, and burn the token.
|
||||
|
||||
Idempotent by construction — accepting twice is a no-op the second time, so
|
||||
a double-click or a retried request cannot create two memberships.
|
||||
Two redemption paths, one outcome:
|
||||
|
||||
- **Signed in** — the account already exists and has proven ownership of
|
||||
the invited email; no password is needed.
|
||||
- **Anonymous** — the invite itself is the capability: a *new* account is
|
||||
created for the invited email with the supplied password and joins the
|
||||
organization. If an account for that email already exists, the caller
|
||||
must authenticate first — a link can never become a password reset.
|
||||
|
||||
Idempotent by construction — accepting twice is a no-op the second time,
|
||||
so a double-click or a retried request cannot create two memberships.
|
||||
"""
|
||||
invite, organization = await _load_pending_invite(payload.token, user, session)
|
||||
|
||||
users = UserRepository(session)
|
||||
memberships = MembershipRepository(session)
|
||||
|
||||
created_account = False
|
||||
if user is None:
|
||||
existing = await users.get_by_email(invite.email)
|
||||
if existing is not None:
|
||||
raise PermissionDeniedError(
|
||||
"برای این ایمیل قبلاً حساب ساخته شده است؛ وارد شوید و سپس دعوت را بپذیرید."
|
||||
)
|
||||
if not payload.password:
|
||||
raise ValidationError("برای ساخت حساب جدید، تعیین رمز عبور الزامی است.")
|
||||
user = await users.create(
|
||||
User(
|
||||
email=invite.email,
|
||||
password_hash=hash_password(payload.password),
|
||||
name="",
|
||||
platform_role=PlatformRole.USER,
|
||||
)
|
||||
)
|
||||
created_account = True
|
||||
|
||||
if await memberships.get(organization.id, user.id) is None:
|
||||
await memberships.add(
|
||||
Membership(
|
||||
@@ -146,6 +206,20 @@ async def accept_invite(
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
if created_account:
|
||||
# Account creation is audited separately from the join: one is a
|
||||
# platform identity event, the other a tenant event.
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.SIGNUP,
|
||||
actor_user_id=user.id,
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
metadata={"via": "invite", "organization_id": organization.id},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
# Burn the token regardless: whether the membership was just created or
|
||||
# already existed, this invite must not be redeemable a second time.
|
||||
|
||||
@@ -114,6 +114,15 @@ async def create_organization(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OrganizationWithRole:
|
||||
# The platform has exactly one owner and the managers the owner creates.
|
||||
# Everyone else works *inside* organizations they were invited to — if
|
||||
# plain users could create organizations, the owner's control over the
|
||||
# tenant map would mean nothing.
|
||||
if not user.can_create_organization:
|
||||
raise PermissionDeniedError(
|
||||
"ساخت سازمان فقط برای مالک سامانه و مدیران امکانپذیر است. "
|
||||
"برای عضویت در یک سازمان از لینک دعوت استفاده کنید."
|
||||
)
|
||||
orgs = OrganizationRepository(session)
|
||||
base = slugify(payload.name)
|
||||
slug = ensure_unique_slug(base, await orgs.list_all_slugs())
|
||||
|
||||
@@ -49,8 +49,8 @@ ChannelAuthorizer = Callable[["Connection", str], Awaitable[bool]]
|
||||
@dataclass(slots=True, eq=False)
|
||||
class Connection:
|
||||
"""One browser socket. ``user_id`` is ``None`` for an anonymous (pre-login)
|
||||
connection: it may only call the bootstrap RPCs (login/signup) and may not
|
||||
subscribe to live channels."""
|
||||
connection: it may only call the bootstrap RPCs (login and invite
|
||||
redemption) and may not subscribe to live channels."""
|
||||
|
||||
user_id: str | None
|
||||
is_platform_owner: bool
|
||||
|
||||
@@ -25,7 +25,7 @@ share the ``type`` discriminator; client->server frames use ``action``::
|
||||
server -> {"type": "error", "message": "…"}
|
||||
|
||||
A connection may be **anonymous** (no token): that is the bootstrap state that
|
||||
lets a brand-new visitor call ``/api/auth/login`` and ``/api/auth/signup`` over
|
||||
lets a brand-new visitor call ``/api/auth/login`` and redeem an invite over
|
||||
RPC. Every other RPC and every ``subscribe`` requires an authenticated
|
||||
connection; the token presented at connect time is re-injected as a bearer
|
||||
header when the RPC is dispatched into the internal app, so the handlers' own
|
||||
@@ -67,9 +67,17 @@ def realtime_manager() -> ConnectionManager:
|
||||
return _manager
|
||||
|
||||
|
||||
#: Paths an anonymous (not-yet-logged-in) connection is allowed to call. These are
|
||||
#: the only two calls a visitor can make; everything else needs a session.
|
||||
_ANONYMOUS_RPC_PATHS = frozenset({"/api/auth/login", "/api/auth/signup"})
|
||||
#: What an anonymous (not-yet-logged-in) connection may call: login — the only
|
||||
#: way in now that signup is closed — plus the invite redemption endpoints,
|
||||
#: which authorize themselves with the invite token. Previewing an invite and
|
||||
#: creating the account it names must both be possible before the visitor has
|
||||
#: a session, so a fresh invitee is never stuck at the login screen.
|
||||
_ANONYMOUS_RPC_PATHS = frozenset({"/api/auth/login"})
|
||||
_ANONYMOUS_RPC_PREFIXES = ("/api/invites/",)
|
||||
|
||||
|
||||
def _is_anonymous_rpc_allowed(path: str) -> bool:
|
||||
return path in _ANONYMOUS_RPC_PATHS or path.startswith(_ANONYMOUS_RPC_PREFIXES)
|
||||
|
||||
#: One in-process HTTP client that dispatches RPCs into the internal app. Built
|
||||
#: lazily so importing this module never pulls the app into existence.
|
||||
@@ -216,8 +224,8 @@ async def _handle_rpc(connection: Connection, message: dict) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
# Gate unauthenticated callers to login/signup only.
|
||||
if connection.user_id is None and path not in _ANONYMOUS_RPC_PATHS:
|
||||
# Gate unauthenticated callers to the bootstrap RPCs only.
|
||||
if connection.user_id is None and not _is_anonymous_rpc_allowed(path):
|
||||
_send_rpc_result(
|
||||
connection,
|
||||
req_id,
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.interfaces.http.errors import register_exception_handlers
|
||||
from app.interfaces.http.request_context import RequestContextMiddleware
|
||||
from app.interfaces.http.routers import (
|
||||
admin,
|
||||
admin_users,
|
||||
agy,
|
||||
auth,
|
||||
billing,
|
||||
@@ -68,6 +69,7 @@ def build_rpc_app() -> FastAPI:
|
||||
application.include_router(knowledge.router, prefix="/api")
|
||||
application.include_router(sandbox.router, prefix="/api")
|
||||
application.include_router(admin.router, prefix="/api")
|
||||
application.include_router(admin_users.router, prefix="/api")
|
||||
application.include_router(agy.router, prefix="/api")
|
||||
application.include_router(realtime_router, prefix="/api")
|
||||
return application
|
||||
|
||||
+107
-34
@@ -1,7 +1,7 @@
|
||||
"""Idempotent development seed.
|
||||
|
||||
Run with ``pnpm db:seed``. Creates a platform owner, a demo user, a sample
|
||||
organization that both of them own, the system agent templates, routing
|
||||
Run with ``pnpm db:seed``. Creates the platform owner, a demo *manager* with a
|
||||
sample organization that manager owns, the system agent templates, routing
|
||||
policies, rate cards and the agy (Google Antigravity) provider with its model
|
||||
catalog. agy is the only model provider the platform talks to; accounts are
|
||||
added from the admin console.
|
||||
@@ -11,6 +11,16 @@ Safe to run repeatedly: every step checks for existing rows first.
|
||||
The two accounts it prints at the end are meant to be usable immediately —
|
||||
``tests/test_seed.py`` asserts exactly that, because a seed whose credentials the
|
||||
login endpoint rejects is worse than no seed at all.
|
||||
|
||||
Platform hierarchy this seed models (there is deliberately no public signup):
|
||||
|
||||
- exactly one ``owner`` — the account that owns the whole system and is the
|
||||
only one allowed into the admin console;
|
||||
- the demo account is a ``manager`` — the tier that may create organizations
|
||||
and join other managers' organizations by invitation;
|
||||
- the platform owner is *not* auto-joined to any organization: the product
|
||||
rule is that the owner observes tenants from the console, rather than
|
||||
silently sitting inside their data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,7 +28,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.core.database import Base, SessionFactory, engine
|
||||
from app.core.security import hash_password
|
||||
@@ -32,7 +42,14 @@ from app.domain.ai_control.models import (
|
||||
RoutingMode,
|
||||
RoutingPolicy,
|
||||
)
|
||||
from app.domain.identity.models import Membership, MembershipStatus, Organization, Role, User
|
||||
from app.domain.identity.models import (
|
||||
Membership,
|
||||
MembershipStatus,
|
||||
Organization,
|
||||
PlatformRole,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from app.domain.sandbox.models import SandboxProviderKind, SandboxTemplate
|
||||
from app.domain.workforce.models import AgentTemplate, RoleKey
|
||||
from app.infrastructure.db.models import (
|
||||
@@ -45,6 +62,7 @@ from app.infrastructure.db.models import (
|
||||
SandboxTemplateRow,
|
||||
UserRow,
|
||||
)
|
||||
from app.infrastructure.repositories.identity import UserRepository
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
|
||||
logger = logging.getLogger("agy.seed")
|
||||
@@ -61,7 +79,6 @@ DEMO_PASSWORD = "demo12345"
|
||||
#: the seed to be idempotent — see ``_ensure_org``.
|
||||
DEMO_ORG_NAME = "سازمان نمونه"
|
||||
DEMO_ORG_SLUG = "sample-org"
|
||||
|
||||
#: The agy (Google Antigravity) model catalog. ``internal_name`` is the model id
|
||||
#: the Antigravity backend expects; the display name is admin-only. The 4th
|
||||
#: element is the reasoning effort (empty = provider default) — the console can
|
||||
@@ -185,36 +202,66 @@ SANDBOX_TEMPLATES: list[tuple[str, str, str, float, int, int]] = [
|
||||
|
||||
|
||||
async def _ensure_user(
|
||||
session, *, email: str, password: str, name: str, platform_owner: bool
|
||||
session, *, email: str, password: str, name: str, platform_role: PlatformRole
|
||||
) -> User:
|
||||
"""Get or create one seeded account, converging its platform role.
|
||||
|
||||
The reconciliation matters for installs seeded *before* the single-owner
|
||||
model: the migration already mapped the old boolean onto
|
||||
``platform_role``, and this pass fixes everything the migration could not
|
||||
know (for example which of several legacy owners the seed intended).
|
||||
Exactly one row may hold ``owner`` — the partial unique index enforces it —
|
||||
so promoting this account first demotes any other owner to ``manager``.
|
||||
"""
|
||||
existing = (
|
||||
await session.execute(select(UserRow).where(UserRow.email == email))
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
if existing is not None:
|
||||
if existing.platform_role != platform_role.value:
|
||||
if platform_role == PlatformRole.OWNER:
|
||||
# Demote the current seat holder before taking it, so the
|
||||
# unique index never sees two owners.
|
||||
await session.execute(
|
||||
update(UserRow)
|
||||
.where(
|
||||
UserRow.platform_role == PlatformRole.OWNER.value,
|
||||
UserRow.id != existing.id,
|
||||
)
|
||||
.values(platform_role=PlatformRole.MANAGER.value)
|
||||
)
|
||||
existing.platform_role = platform_role.value
|
||||
await session.flush()
|
||||
return User(
|
||||
id=existing.id, email=existing.email, password_hash=existing.password_hash,
|
||||
name=existing.name, is_platform_owner=existing.is_platform_owner,
|
||||
id=existing.id,
|
||||
email=existing.email,
|
||||
password_hash=existing.password_hash,
|
||||
name=existing.name,
|
||||
platform_role=PlatformRole(existing.platform_role),
|
||||
)
|
||||
from app.infrastructure.repositories.identity import UserRepository
|
||||
|
||||
user = await UserRepository(session).create(
|
||||
User(
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
name=name,
|
||||
is_platform_owner=platform_owner,
|
||||
platform_role=platform_role,
|
||||
)
|
||||
)
|
||||
logger.info("created user %s", email)
|
||||
logger.info("created user %s (%s)", email, platform_role.value)
|
||||
return user
|
||||
|
||||
|
||||
async def _ensure_org(session, *, members: list[User]) -> Organization:
|
||||
"""Get or create the sample organization, with every given user in it.
|
||||
async def _ensure_org(session, *, owner: User) -> Organization:
|
||||
"""Get or create the sample organization, owned by ``owner``.
|
||||
|
||||
Idempotent in all three parts: the organization, its memberships and its
|
||||
credit wallet. A developer who runs ``pnpm db:seed`` twice must end up with
|
||||
exactly one organization and no duplicate rows.
|
||||
Idempotent in all three parts: the organization, the owner's membership and
|
||||
the credit wallet. A developer who runs ``pnpm db:seed`` twice must end up
|
||||
with exactly one organization and no duplicate rows.
|
||||
|
||||
The platform owner is deliberately *not* a member — see the module
|
||||
docstring. Any membership a platform owner picked up in an older database
|
||||
(the seed used to join both accounts) is removed here so the model stays
|
||||
honest after a re-seed.
|
||||
"""
|
||||
from app.infrastructure.repositories.identity import (
|
||||
MembershipRepository,
|
||||
@@ -232,22 +279,46 @@ async def _ensure_org(session, *, members: list[User]) -> Organization:
|
||||
org = await orgs.get_by_slug(DEMO_ORG_SLUG)
|
||||
if org is None:
|
||||
org = await orgs.create(
|
||||
Organization(name=DEMO_ORG_NAME, slug=DEMO_ORG_SLUG, owner_user_id=members[0].id)
|
||||
Organization(name=DEMO_ORG_NAME, slug=DEMO_ORG_SLUG, owner_user_id=owner.id)
|
||||
)
|
||||
logger.info("created organization %s", org.slug)
|
||||
elif org.owner_user_id != owner.id:
|
||||
await orgs.reassign_owner(org.id, owner.id)
|
||||
org = await orgs.get(org.id)
|
||||
assert org is not None
|
||||
|
||||
# Every listed user becomes an owner of the sample org. Without this the
|
||||
# demo account can log in but belongs to no organization, so every screen
|
||||
# The org owner is always an active OWNER member. Without this the demo
|
||||
# account can log in but belongs to no organization, so every screen
|
||||
# renders empty and the account looks broken.
|
||||
for user in members:
|
||||
if await memberships.get(org.id, user.id) is None:
|
||||
await memberships.add(
|
||||
Membership(
|
||||
organization_id=org.id, user_id=user.id,
|
||||
role=Role.OWNER, status=MembershipStatus.ACTIVE,
|
||||
)
|
||||
membership = await memberships.get(org.id, owner.id)
|
||||
if membership is None:
|
||||
await memberships.add(
|
||||
Membership(
|
||||
organization_id=org.id, user_id=owner.id,
|
||||
role=Role.OWNER, status=MembershipStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
logger.info("added %s to %s as owner", owner.email, org.slug)
|
||||
elif membership.role != Role.OWNER or not membership.is_active:
|
||||
await memberships.set_role(membership.id, Role.OWNER)
|
||||
await memberships.set_status(membership.id, MembershipStatus.ACTIVE)
|
||||
|
||||
# Platform owners observe tenants from the console; they do not sit inside
|
||||
# them. Remove any such membership left by an older seed.
|
||||
platform_owners = (
|
||||
await session.execute(
|
||||
select(UserRow).where(UserRow.platform_role == PlatformRole.OWNER.value)
|
||||
)
|
||||
).scalars().all()
|
||||
for platform_owner in platform_owners:
|
||||
stale = await memberships.get(org.id, platform_owner.id)
|
||||
if stale is not None:
|
||||
await memberships.remove(stale.id)
|
||||
logger.info(
|
||||
"removed platform owner %s from %s (owner stays out of tenants)",
|
||||
platform_owner.email,
|
||||
org.slug,
|
||||
)
|
||||
logger.info("added %s to %s as owner", user.email, org.slug)
|
||||
|
||||
wallet = (
|
||||
await session.execute(
|
||||
@@ -264,17 +335,19 @@ async def seed() -> None:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with SessionFactory() as session:
|
||||
owner = await _ensure_user(
|
||||
await _ensure_user(
|
||||
session, email=PLATFORM_OWNER_EMAIL, password=PLATFORM_OWNER_PASSWORD,
|
||||
name="مالک سامانه", platform_owner=True,
|
||||
name="مالک سامانه", platform_role=PlatformRole.OWNER,
|
||||
)
|
||||
# The demo account is a *manager*: the tier the owner creates that can
|
||||
# build its own organizations. It owns the sample organization, so
|
||||
# logging in with it lands on a populated dashboard rather than an
|
||||
# empty one.
|
||||
demo = await _ensure_user(
|
||||
session, email=DEMO_EMAIL, password=DEMO_PASSWORD,
|
||||
name="کاربر نمونه", platform_owner=False,
|
||||
name="کاربر نمونه", platform_role=PlatformRole.MANAGER,
|
||||
)
|
||||
# Both accounts join the sample organization, so a developer who logs in
|
||||
# as either one lands on a populated dashboard rather than an empty one.
|
||||
await _ensure_org(session, members=[owner, demo])
|
||||
await _ensure_org(session, owner=demo)
|
||||
|
||||
# The agy catalog (Google Antigravity). agy is the only model provider,
|
||||
# so the provider row and its models are created on a first run and
|
||||
|
||||
Reference in New Issue
Block a user