ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمانهای مدیریتشده
- 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:
@@ -0,0 +1,519 @@
|
||||
"""The platform hierarchy: one owner, owner-created managers, invited tenants.
|
||||
|
||||
This is the security core of the identity model, so every rule gets both sides
|
||||
tested — the allow and the deny:
|
||||
|
||||
1. **Exactly one owner.** The database refuses a second owner row outright;
|
||||
the API refuses to mint or demote one. The seat moves only through a
|
||||
transactional transfer that demotes the previous owner in the same step.
|
||||
2. **Only the owner and managers create organizations.** A plain user may not.
|
||||
3. **Only the owner creates managers.** No manager can mint another manager,
|
||||
and no manager can enter the admin console.
|
||||
4. **A departing manager's organizations move to the owner** — never orphaned,
|
||||
never deleted with their data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.domain.identity.models import Role
|
||||
from app.infrastructure.db.models import MembershipRow, OrganizationRow, UserRow
|
||||
from tests.conftest import auth_header
|
||||
|
||||
|
||||
async def _create_org(client: AsyncClient, token: str, name: str) -> dict:
|
||||
response = await client.post(
|
||||
"/api/organizations", json={"name": name}, headers=auth_header(token)
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, email: str, password: str) -> str:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"email": email, "password": password}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
async def _owner_creates_user(
|
||||
client: AsyncClient,
|
||||
owner_token: str,
|
||||
email: str,
|
||||
*,
|
||||
platform_role: str = "manager",
|
||||
name: str = "",
|
||||
) -> dict:
|
||||
response = await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": email, "name": name, "platform_role": platform_role},
|
||||
headers=auth_header(owner_token),
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exactly one owner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_only_one_platform_owner_may_exist(
|
||||
client: AsyncClient, platform_owner_token: str, session_factory
|
||||
):
|
||||
"""The invariant is enforced by the database, not by habit.
|
||||
|
||||
Even a direct INSERT — the kind a future code path or a migration would
|
||||
perform — cannot produce a second owner row.
|
||||
"""
|
||||
from sqlalchemy.exc import IntegrityError as _IntegrityError
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(
|
||||
UserRow(
|
||||
id="second-owner-row",
|
||||
email="second-owner@example.com",
|
||||
password_hash="x",
|
||||
platform_role="owner",
|
||||
)
|
||||
)
|
||||
with pytest.raises(_IntegrityError):
|
||||
await session.flush()
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def test_owner_cannot_be_created_through_the_api(
|
||||
client: AsyncClient, platform_owner_token: str
|
||||
):
|
||||
response = await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": "wannabe@example.com", "platform_role": "owner"},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "مالکیت" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
async def test_owner_account_is_immutable_from_the_api(
|
||||
client: AsyncClient, platform_owner_token: str
|
||||
):
|
||||
"""No API path may demote, deactivate or delete the single owner seat."""
|
||||
me = await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
|
||||
owner_id = me.json()["user"]["id"]
|
||||
|
||||
demote = await client.patch(
|
||||
f"/api/admin/users/{owner_id}",
|
||||
json={"platform_role": "manager"},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert demote.status_code == 403
|
||||
|
||||
deactivate = await client.patch(
|
||||
f"/api/admin/users/{owner_id}",
|
||||
json={"is_active": False},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert deactivate.status_code == 403
|
||||
|
||||
delete = await client.delete(
|
||||
f"/api/admin/users/{owner_id}", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
assert delete.status_code == 403
|
||||
|
||||
|
||||
async def test_transfer_ownership_moves_the_seat_and_keeps_exactly_one(
|
||||
client: AsyncClient, platform_owner_token: str, session_factory
|
||||
):
|
||||
"""The safety valve for a lost owner account — transactional by design."""
|
||||
manager = await _owner_creates_user(
|
||||
client, platform_owner_token, "successor@example.com", name="جانشین"
|
||||
)
|
||||
successor_token = await _login(
|
||||
client, "successor@example.com", manager["temp_password"]
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
"/api/admin/transfer-ownership",
|
||||
json={"user_id": manager["user"]["id"]},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["platform_role"] == "owner"
|
||||
|
||||
async with session_factory() as session:
|
||||
owners = (
|
||||
(await session.execute(select(UserRow).where(UserRow.platform_role == "owner")))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert [o.email for o in owners] == ["successor@example.com"]
|
||||
|
||||
# The previous owner is demoted to manager in the same step — a manager,
|
||||
# not a powerless user, so they keep the ability to run organizations.
|
||||
previous = await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
|
||||
assert previous.json()["user"]["platform_role"] == "manager"
|
||||
|
||||
# And the successor really is the owner: the console opens for them.
|
||||
console = await client.get("/api/admin/users", headers=auth_header(successor_token))
|
||||
assert console.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Organization creation is gated by platform role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_plain_user_cannot_create_an_organization(
|
||||
client: AsyncClient, platform_owner_token: str, plain_user_token: str
|
||||
):
|
||||
response = await client.post(
|
||||
"/api/organizations",
|
||||
json={"name": "سازمان ممنوعه"},
|
||||
headers=auth_header(plain_user_token),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "دعوت" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
async def test_manager_can_create_an_organization(client: AsyncClient, manager_token: str):
|
||||
org = await _create_org(client, manager_token, "سازمان مدیر")
|
||||
assert org["owner_user_id"] == (
|
||||
await client.get("/api/auth/me", headers=auth_header(manager_token))
|
||||
).json()["user"]["id"]
|
||||
|
||||
|
||||
async def test_owner_can_create_an_organization(client: AsyncClient, platform_owner_token: str):
|
||||
org = await _create_org(client, platform_owner_token, "سازمان مالک")
|
||||
assert org["name"] == "سازمان مالک"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Only the owner creates managers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_manager_cannot_create_accounts(client: AsyncClient, manager_token: str):
|
||||
response = await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": "sneaky@example.com", "platform_role": "manager"},
|
||||
headers=auth_header(manager_token),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_plain_user_cannot_create_accounts(client: AsyncClient, plain_user_token: str):
|
||||
response = await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": "sneaky2@example.com", "platform_role": "manager"},
|
||||
headers=auth_header(plain_user_token),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_manager_cannot_reach_the_admin_console(client: AsyncClient, manager_token: str):
|
||||
for path in ("/api/admin/users", "/api/admin/models", "/api/admin/organizations"):
|
||||
response = await client.get(path, headers=auth_header(manager_token))
|
||||
assert response.status_code == 403, path
|
||||
|
||||
|
||||
async def test_owner_creates_a_manager_whose_temp_password_works(
|
||||
client: AsyncClient, platform_owner_token: str
|
||||
):
|
||||
created = await _owner_creates_user(
|
||||
client, platform_owner_token, "manager1@example.com", name="مدیر یک"
|
||||
)
|
||||
assert created["user"]["platform_role"] == "manager"
|
||||
assert created["user"]["is_manager"] is True
|
||||
assert created["user"]["owned_organizations"] == 0
|
||||
temp = created["temp_password"]
|
||||
assert temp
|
||||
|
||||
login = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "manager1@example.com", "password": temp},
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
assert login.json()["user"]["platform_role"] == "manager"
|
||||
|
||||
|
||||
async def test_duplicate_email_is_rejected(client: AsyncClient, platform_owner_token: str):
|
||||
await _owner_creates_user(client, platform_owner_token, "twin@example.com")
|
||||
response = await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": "twin@example.com", "platform_role": "manager"},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
async def test_owner_can_create_plain_users(client: AsyncClient, platform_owner_token: str):
|
||||
created = await _owner_creates_user(
|
||||
client,
|
||||
platform_owner_token,
|
||||
"plain@example.com",
|
||||
platform_role="user",
|
||||
name="کاربر عادی",
|
||||
)
|
||||
assert created["user"]["platform_role"] == "user"
|
||||
assert created["user"]["is_manager"] is False
|
||||
|
||||
|
||||
async def test_owner_resets_a_forgotten_temp_password(
|
||||
client: AsyncClient, platform_owner_token: str
|
||||
):
|
||||
created = await _owner_creates_user(
|
||||
client, platform_owner_token, "forgetful@example.com", name="فراموشکار"
|
||||
)
|
||||
first = created["temp_password"]
|
||||
|
||||
reset = await client.post(
|
||||
f"/api/admin/users/{created['user']['id']}/reset-password",
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert reset.status_code == 200, reset.text
|
||||
second = reset.json()["temp_password"]
|
||||
assert second and second != first
|
||||
|
||||
stale = await client.post(
|
||||
"/api/auth/login", json={"email": "forgetful@example.com", "password": first}
|
||||
)
|
||||
assert stale.status_code == 401
|
||||
fresh = await client.post(
|
||||
"/api/auth/login", json={"email": "forgetful@example.com", "password": second}
|
||||
)
|
||||
assert fresh.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A departing manager's organizations move to the owner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_demoting_a_manager_transfers_organizations_to_the_owner(
|
||||
client: AsyncClient, platform_owner_token: str, session_factory
|
||||
):
|
||||
manager = await _owner_creates_user(
|
||||
client, platform_owner_token, "leaving@example.com", name="خداحافظ"
|
||||
)
|
||||
manager_token = await _login(
|
||||
client, "leaving@example.com", manager["temp_password"]
|
||||
)
|
||||
org = await _create_org(client, manager_token, "سازمان خداحافظ")
|
||||
|
||||
demote = await client.patch(
|
||||
f"/api/admin/users/{manager['user']['id']}",
|
||||
json={"platform_role": "user"},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert demote.status_code == 200, demote.text
|
||||
assert demote.json()["platform_role"] == "user"
|
||||
assert demote.json()["owned_organizations"] == 0
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(OrganizationRow, org["id"])
|
||||
assert row is not None
|
||||
owner_id = (
|
||||
await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
|
||||
).json()["user"]["id"]
|
||||
assert row.owner_user_id == owner_id
|
||||
|
||||
memberships = (
|
||||
(
|
||||
await session.execute(
|
||||
select(MembershipRow).where(MembershipRow.organization_id == org["id"])
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
by_user = {m.user_id: m for m in memberships}
|
||||
# The new owner can actually see the tenant...
|
||||
assert by_user[owner_id].role == Role.OWNER.value
|
||||
# ...and the demoted manager keeps working access without ownership.
|
||||
assert by_user[manager["user"]["id"]].role == Role.ADMIN.value
|
||||
|
||||
|
||||
async def test_deleting_a_manager_transfers_organizations_and_memberships(
|
||||
client: AsyncClient, platform_owner_token: str, session_factory
|
||||
):
|
||||
manager = await _owner_creates_user(
|
||||
client, platform_owner_token, "doomed@example.com", name="حذفشونده"
|
||||
)
|
||||
manager_token = await _login(client, "doomed@example.com", manager["temp_password"])
|
||||
org = await _create_org(client, manager_token, "سازمان حذفشونده")
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/admin/users/{manager['user']['id']}", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["deleted_user_id"] == manager["user"]["id"]
|
||||
assert [t["organization_id"] for t in body["transferred_organizations"]] == [org["id"]]
|
||||
|
||||
async with session_factory() as session:
|
||||
assert await session.get(UserRow, manager["user"]["id"]) is None
|
||||
row = await session.get(OrganizationRow, org["id"])
|
||||
assert row is not None, "the organization must survive its owner's deletion"
|
||||
owner_id = (
|
||||
await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
|
||||
).json()["user"]["id"]
|
||||
assert row.owner_user_id == owner_id
|
||||
stale = (
|
||||
await session.execute(
|
||||
select(MembershipRow).where(
|
||||
MembershipRow.user_id == manager["user"]["id"]
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert stale == [], "the deleted account's memberships must be gone"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Managers create and join organizations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_manager_can_join_another_managers_organization_by_invite(
|
||||
client: AsyncClient, platform_owner_token: str
|
||||
):
|
||||
"""The invited manager keeps their own organization and gains a second."""
|
||||
first = await _owner_creates_user(
|
||||
client, platform_owner_token, "first@example.com", name="مدیر یکم"
|
||||
)
|
||||
second = await _owner_creates_user(
|
||||
client, platform_owner_token, "second@example.com", name="مدیر دوم"
|
||||
)
|
||||
first_token = await _login(client, "first@example.com", first["temp_password"])
|
||||
second_token = await _login(client, "second@example.com", second["temp_password"])
|
||||
|
||||
own_org = await _create_org(client, first_token, "سازمان مدیر یکم")
|
||||
# The second manager has a tenant of their own before being invited.
|
||||
second_own_org = await _create_org(client, second_token, "سازمان مدیر دوم")
|
||||
|
||||
invite = await client.post(
|
||||
f"/api/organizations/{own_org['id']}/invites",
|
||||
json={"email": "second@example.com", "role": "admin"},
|
||||
headers=auth_header(first_token),
|
||||
)
|
||||
assert invite.status_code == 201, invite.text
|
||||
|
||||
accepted = await client.post(
|
||||
"/api/invites/accept",
|
||||
json={"token": invite.json()["invite_token"]},
|
||||
headers=auth_header(second_token),
|
||||
)
|
||||
assert accepted.status_code == 200, accepted.text
|
||||
|
||||
me = await client.get("/api/auth/me", headers=auth_header(second_token))
|
||||
roles = {o["id"]: o["role"] for o in me.json()["organizations"]}
|
||||
# Joined the other manager's organization with the invited role...
|
||||
assert roles[own_org["id"]] == "admin"
|
||||
# ...and still owns their own, untouched.
|
||||
assert roles[second_own_org["id"]] == "owner"
|
||||
assert len(me.json()["organizations"]) == 2
|
||||
|
||||
|
||||
async def test_promoting_a_plain_user_to_manager_is_possible(
|
||||
client: AsyncClient, platform_owner_token: str
|
||||
):
|
||||
created = await _owner_creates_user(
|
||||
client, platform_owner_token, "promoted@example.com", platform_role="user"
|
||||
)
|
||||
promoted = await client.patch(
|
||||
f"/api/admin/users/{created['user']['id']}",
|
||||
json={"platform_role": "manager"},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert promoted.status_code == 200, promoted.text
|
||||
assert promoted.json()["platform_role"] == "manager"
|
||||
|
||||
# The new tier takes effect immediately: organizations are now allowed.
|
||||
promoted_token = await _login(
|
||||
client, "promoted@example.com", created["temp_password"]
|
||||
)
|
||||
org = await _create_org(client, promoted_token, "سازمان ارتقایافته")
|
||||
assert org["name"] == "سازمان ارتقایافته"
|
||||
|
||||
|
||||
async def test_deactivating_an_account_blocks_login(
|
||||
client: AsyncClient, platform_owner_token: str
|
||||
):
|
||||
created = await _owner_creates_user(
|
||||
client, platform_owner_token, "frozen@example.com", name="مسدود"
|
||||
)
|
||||
response = await client.patch(
|
||||
f"/api/admin/users/{created['user']['id']}",
|
||||
json={"is_active": False},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["is_active"] is False
|
||||
|
||||
login = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "frozen@example.com", "password": created["temp_password"]},
|
||||
)
|
||||
assert login.status_code == 401
|
||||
|
||||
|
||||
async def test_user_list_reports_roles_and_owned_organization_counts(
|
||||
client: AsyncClient, platform_owner_token: str
|
||||
):
|
||||
manager = await _owner_creates_user(
|
||||
client, platform_owner_token, "listed@example.com", name="فهرست"
|
||||
)
|
||||
manager_token = await _login(client, "listed@example.com", manager["temp_password"])
|
||||
await _create_org(client, manager_token, "سازمان فهرست")
|
||||
|
||||
listing = await client.get("/api/admin/users", headers=auth_header(platform_owner_token))
|
||||
assert listing.status_code == 200
|
||||
rows = {u["email"]: u for u in listing.json()}
|
||||
assert rows["listed@example.com"]["platform_role"] == "manager"
|
||||
assert rows["listed@example.com"]["owned_organizations"] == 1
|
||||
me = await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
|
||||
owner_email = me.json()["user"]["email"]
|
||||
assert rows[owner_email]["platform_role"] == "owner"
|
||||
assert rows[owner_email]["is_platform_owner"] is True
|
||||
|
||||
|
||||
async def test_admin_user_endpoints_require_authentication(client: AsyncClient):
|
||||
assert (await client.get("/api/admin/users")).status_code == 401
|
||||
assert (
|
||||
await client.post("/api/admin/users", json={"email": "x@example.com"})
|
||||
).status_code == 401
|
||||
|
||||
|
||||
async def test_every_seat_move_is_audited(
|
||||
client: AsyncClient, platform_owner_token: str, session_factory
|
||||
):
|
||||
"""The transfer and the deletion are the two irreversible moves; both must
|
||||
leave a trail that names the actor and the moved organizations."""
|
||||
from app.domain.audit.models import AuditAction
|
||||
|
||||
manager = await _owner_creates_user(
|
||||
client, platform_owner_token, "audited@example.com", name="ممیزی"
|
||||
)
|
||||
manager_token = await _login(client, "audited@example.com", manager["temp_password"])
|
||||
org = await _create_org(client, manager_token, "سازمان ممیزی")
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/admin/users/{manager['user']['id']}", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
from app.infrastructure.repositories.audit import AuditRepository
|
||||
|
||||
async with session_factory() as session:
|
||||
events = await AuditRepository(session).list_platform(limit=100)
|
||||
actions = {e.action: e for e in events}
|
||||
assert AuditAction.USER_CREATE in actions
|
||||
assert AuditAction.USER_DELETE in actions
|
||||
delete_event = actions[AuditAction.USER_DELETE]
|
||||
assert delete_event.metadata_json["transferred_organizations"] == [org["id"]]
|
||||
assert delete_event.actor_user_id == (
|
||||
await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
|
||||
).json()["user"]["id"]
|
||||
Reference in New Issue
Block a user