ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده

- 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:
mjalmoudy94
2026-10-13 21:58:41 +03:30
parent 6937b88ca7
commit 8eb631aedf
41 changed files with 2952 additions and 454 deletions
@@ -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)
+57 -39
View File
@@ -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),
+92 -18
View File
@@ -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())