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