ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمانهای مدیریتشده
- 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user