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

- 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
+52 -20
View File
@@ -25,13 +25,14 @@ from sqlalchemy.pool import NullPool
import app.infrastructure.db.models as _orm_models # noqa: F401 - registers metadata
from app.application.ports.realtime import RealtimeEvent, set_publisher
from app.core.config import settings
from app.core.database import Base, get_session
from app.core.database import Base, enable_sqlite_foreign_keys, get_session
from app.core.security import create_access_token
from app.domain.base import new_id
from app.infrastructure.db.models import UserRow
from app.interfaces.realtime.authz import authorize_channel
from app.interfaces.realtime.bus import get_bus
from app.interfaces.realtime.manager import Connection, ConnectionManager
# The internal "full" app holds every HTTP handler; the public app serves only
# /health + /api/ws. The socket and the RPC surface both live on the full app.
from app.interfaces.realtime.rpc_app import get_rpc_app
@@ -85,7 +86,7 @@ async def _user_with_org(session: AsyncSession, *, platform_owner: bool = False)
id=user_id,
email=f"{user_id}@example.com",
password_hash="x",
is_platform_owner=platform_owner,
platform_role=("owner" if platform_owner else "user"),
)
)
await session.flush()
@@ -277,6 +278,7 @@ class _NullPublisher:
async def _create_schema(engine) -> None:
enable_sqlite_foreign_keys(engine)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -299,6 +301,9 @@ def ws_client(monkeypatch):
monkeypatch.setattr("app.main.engine", engine)
monkeypatch.setattr("app.interfaces.realtime.router.SessionFactory", factory)
monkeypatch.setattr(settings, "realtime_transport", "local")
# Published for ``_create_account`` below: signup is closed, so the socket
# tests that need an identity mint it directly against this database.
_ws_factory_holder["factory"] = factory
async def _override_session():
async with factory() as session:
@@ -335,11 +340,45 @@ def _frames_until(websocket, wanted_type: str, *, max_frames: int = 20) -> dict:
raise AssertionError(f"no {wanted_type} event arrived")
def test_socket_allows_anonymous_then_restricts_actions(ws_client):
"""No token opens an anonymous socket, but only login/signup may be called.
#: Set by the ``ws_client`` fixture; read by ``_create_account``.
_ws_factory_holder: dict[str, async_sessionmaker] = {}
A visitor who has not logged in can reach ``/api/auth/login`` and
``/api/auth/signup`` over RPC; every other RPC and any subscribe is refused.
def _create_account(email: str, *, name: str = "", platform_role: str = "manager") -> str:
"""Create an account directly in the throwaway database; return its token.
Signup is closed — accounts come from the platform owner or from an
invite — so a socket test that needs an identity writes the row the way
those flows would and signs a token for it.
"""
from app.core.security import create_access_token, hash_password
from app.domain.identity.models import PlatformRole, User
from app.infrastructure.repositories.identity import UserRepository
factory = _ws_factory_holder["factory"]
async def _create():
async with factory() as session:
user = await UserRepository(session).create(
User(
email=email,
password_hash=hash_password("password123"),
name=name,
platform_role=PlatformRole(platform_role),
)
)
await session.commit()
return user
user = asyncio.run(_create())
return create_access_token(user.id)
def test_socket_allows_anonymous_then_restricts_actions(ws_client):
"""No token opens an anonymous socket, but only the bootstrap RPCs run.
A visitor who has not logged in can reach ``/api/auth/login`` and redeem an
invite over RPC; every other RPC and any subscribe is refused.
"""
with ws_client.websocket_connect("/api/ws") as socket:
hello = socket.receive_json()
@@ -347,7 +386,9 @@ def test_socket_allows_anonymous_then_restricts_actions(ws_client):
assert hello["user_id"] is None
# A protected RPC is refused for an anonymous connection.
socket.send_json({"action": "rpc", "id": "1", "method": "GET", "path": "/api/organizations"})
socket.send_json(
{"action": "rpc", "id": "1", "method": "GET", "path": "/api/organizations"}
)
result = socket.receive_json()
assert result["type"] == "rpc_result"
assert result["status"] == 401
@@ -378,13 +419,9 @@ def test_anonymous_may_call_login_over_rpc(ws_client):
def test_task_creation_is_pushed_live(ws_client):
# Sign up, create an organization — the tenant the socket subscribes to.
signup = ws_client.post(
"/api/auth/signup",
json={"email": "live@example.com", "password": "password123", "name": "زنده"},
)
assert signup.status_code == 201, signup.text
token = signup.json()["access_token"]
# Create a manager account and its organization — the tenant the socket
# subscribes to. (Signup is closed; this is the owner-created path.)
token = _create_account("live@example.com", name="زنده")
org = ws_client.post(
"/api/organizations",
json={"name": "سازمان زنده"},
@@ -425,12 +462,7 @@ def test_second_user_cannot_read_another_org(ws_client):
tokens = []
org_ids = []
for index in range(2):
signup = ws_client.post(
"/api/auth/signup",
json={"email": f"user{index}@example.com", "password": "password123"},
)
assert signup.status_code == 201, signup.text
token = signup.json()["access_token"]
token = _create_account(f"user{index}@example.com")
org = ws_client.post(
"/api/organizations",
json={"name": f"org {index}"},