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

- 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
+2 -2
View File
@@ -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
+14 -6
View File
@@ -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