From 03096a8951b8205afef5a16fc2a0908d7756cee2 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:00:14 +0800 Subject: [PATCH] feat: user permission --- backend/src/backend/auth.py | 20 ++- backend/src/backend/dependencies.py | 38 +++- backend/src/backend/platform.py | 261 +++++++++++++++++++++++++++- 3 files changed, 313 insertions(+), 6 deletions(-) diff --git a/backend/src/backend/auth.py b/backend/src/backend/auth.py index bd13764..f0e1518 100644 --- a/backend/src/backend/auth.py +++ b/backend/src/backend/auth.py @@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from backend.dependencies import database_session +from backend.dependencies import database_session, load_user_permissions from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token from common.auth.membership import resolve_is_system_admin from common.auth.passwords import verify_password @@ -69,6 +69,7 @@ def _user_payload( role_code: str | None = None, *, is_system_admin: bool = False, + permissions: list[str] | None = None, ) -> dict[str, Any]: return { "user_id": user.user_id, @@ -78,6 +79,7 @@ def _user_payload( "status": user.status, "role_code": role_code, "is_system_admin": is_system_admin, + "permissions": list(permissions) if permissions is not None else [], } @@ -177,11 +179,17 @@ async def login( _set_session_cookie(request, response, token) is_system_admin = await resolve_is_system_admin(session, user) + permissions = await load_user_permissions(session, user) return { "request_id": new_ulid(), "data": { - "user": _user_payload(user, user_role_code, is_system_admin=is_system_admin), + "user": _user_payload( + user, + user_role_code, + is_system_admin=is_system_admin, + permissions=permissions, + ), "workspaces": workspaces, "default_workspace_id": default_workspace_id, }, @@ -256,11 +264,17 @@ async def me( user_role_code = rows[0][1].role_code is_system_admin = await resolve_is_system_admin(session, user) + permissions = await load_user_permissions(session, user) return { "request_id": new_ulid(), "data": { - "user": _user_payload(user, user_role_code, is_system_admin=is_system_admin), + "user": _user_payload( + user, + user_role_code, + is_system_admin=is_system_admin, + permissions=permissions, + ), "workspaces": workspaces, "default_workspace_id": default_workspace_id, }, diff --git a/backend/src/backend/dependencies.py b/backend/src/backend/dependencies.py index ea79f91..1865367 100644 --- a/backend/src/backend/dependencies.py +++ b/backend/src/backend/dependencies.py @@ -41,7 +41,7 @@ from common.auth.membership import ( resolve_is_system_admin, ) from common.db import session_scope -from common.db.models import Roles, Users, Workspaces +from common.db.models import Permissions, RolePermissions, Roles, Users, Workspaces from common.ids import new_ulid @@ -174,3 +174,39 @@ async def _load_admin_role(session: AsyncSession) -> Roles | None: return await session.scalar( select(Roles).where(Roles.role_code == "admin") ) + + +async def load_user_permissions( + session: AsyncSession, user: Users +) -> list[str]: + """Return the user's effective platform permission_codes. + + Resolves ``Users.platform_role_id`` → ``RolePermissions`` → + ``Permissions.permission_code``, filtered by ``is_deleted = 0`` on + both sides. Returns an empty list when the user has no platform + role assigned (e.g. brand-new account before role assignment). + + This is the single source of truth for the menu permissions + consumed by ``GET /api/v1/auth/me``. Endpoint-level authorization + keeps using ``system_admin_context`` (which keys off + ``role_code == 'admin'``); permissions are a frontend-display + concern only. + """ + if user.platform_role_id is None: + return [] + rows = ( + await session.execute( + select(Permissions.permission_code) + .join( + RolePermissions, + RolePermissions.permission_id == Permissions.permission_id, + ) + .where( + RolePermissions.role_id == user.platform_role_id, + RolePermissions.is_deleted == 0, + Permissions.is_deleted == 0, + ) + .order_by(Permissions.permission_code) + ) + ).all() + return [row[0] for row in rows] diff --git a/backend/src/backend/platform.py b/backend/src/backend/platform.py index 41a090b..9597c4c 100644 --- a/backend/src/backend/platform.py +++ b/backend/src/backend/platform.py @@ -53,12 +53,19 @@ from typing import Any, Literal from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import func, or_, select, update +from sqlalchemy import func, insert, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from backend.dependencies import current_user, database_session from common.auth.passwords import hash_password -from common.db.models import Roles, Users, WorkspaceMembers, Workspaces +from common.db.models import ( + Permissions, + RolePermissions, + Roles, + Users, + WorkspaceMembers, + Workspaces, +) from common.ids import new_ulid @@ -133,6 +140,20 @@ class PlatformEmployeeUpdate(BaseModel): role_code: Literal["admin", "developer"] | None = None +class RolePermissionsPatch(BaseModel): + """Replace a platform role's permission set wholesale. + + Empty list is allowed (revokes all permissions) for non-`admin` + roles. The PATCH endpoint rejects emptying an `admin` role of its + system.* permissions; see ``patch_role_permissions`` for the + load-bearing guard order. + """ + + model_config = ConfigDict(extra="forbid") + + permission_codes: list[str] = Field(default_factory=list, max_length=64) + + # --------------------------------------------------------------------------- # System-admin context dependency # --------------------------------------------------------------------------- @@ -867,6 +888,242 @@ async def remove_member( ) +# --------------------------------------------------------------------------- +# Platform role permission management +# --------------------------------------------------------------------------- +# +# Menu permissions for the platform admin UI. The auth gate +# (system_admin_context) still keys off role_code == "admin"; these +# endpoints only control the menu items the frontend renders, not +# which API calls a user may make. See migrations/ +# versions/e5f6a7b8c9d0_seed_role_permissions_and_fix_scope.py for +# the seed values. + + +async def _load_platform_role_by_code( + session: AsyncSession, role_code: str +) -> Roles: + """Load a platform-scoped role by code; 404 if missing or not platform-scope.""" + role = await session.scalar( + select(Roles).where( + Roles.role_code == role_code, Roles.is_deleted == 0, + ) + ) + if role is None or role.role_scope != "platform": + raise HTTPException( + status.HTTP_404_NOT_FOUND, f"platform 角色 {role_code} 不存在", + ) + return role + + +async def _load_role_permission_codes( + session: AsyncSession, role_id: str +) -> list[str]: + """Return the active permission_codes for a role, ordered by code.""" + rows = ( + await session.execute( + select(Permissions.permission_code) + .join( + RolePermissions, + RolePermissions.permission_id == Permissions.permission_id, + ) + .where( + RolePermissions.role_id == role_id, + RolePermissions.is_deleted == 0, + Permissions.is_deleted == 0, + ) + .order_by(Permissions.permission_code) + ) + ).all() + return [row[0] for row in rows] + + +def _role_payload(role: Roles, permission_codes: list[str]) -> dict[str, Any]: + return { + "role_id": role.role_id, + "role_code": role.role_code, + "role_name": role.role_name, + "is_builtin": bool(role.is_builtin), + "permission_codes": permission_codes, + } + + +@router.get("/roles") +async def list_platform_roles( + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """List every platform-scoped role with its current permission_codes.""" + roles = ( + ( + await session.scalars( + select(Roles) + .where(Roles.role_scope == "platform", Roles.is_deleted == 0) + .order_by(Roles.role_code) + ) + ).all() + ) + payload = [] + for role in roles: + codes = await _load_role_permission_codes(session, role.role_id) + payload.append(_role_payload(role, codes)) + return _envelope( + context.request_id, payload, {"count": len(payload)}, + ) + + +@router.get("/roles/{role_code}/permissions") +async def get_role_permissions( + role_code: str, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Return one platform role's permission_codes.""" + role = await _load_platform_role_by_code(session, role_code) + codes = await _load_role_permission_codes(session, role.role_id) + return _envelope( + context.request_id, _role_payload(role, codes), + ) + + +@router.patch("/roles/{role_code}/permissions") +async def patch_role_permissions( + role_code: str, + payload: RolePermissionsPatch, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Replace a platform role's permission set wholesale. + + Guard order is load-bearing (mirrors ``update_platform_employee``): + + 1. Load the role. Reject 404 if it is missing or not + platform-scoped. + 2. Self-protection: when the caller is modifying the role they + themselves hold via ``platform_role_id``, evaluate the + post-patch permission set against the last-admin rule below. + Putting this check before the last-admin count keeps the + test surface stable (see CLAUDE.md line 78). + 3. Last-admin guard (admin role only): the patched + permission_codes MUST still include both ``system.view`` and + ``system.manage``. Otherwise every active admin loses the + entry point to this very endpoint and the platform locks + itself out. Reject with 409. + 4. Validate every code resolves to a non-deleted ``Permissions`` + row; unknown codes → 422. + 5. Write: soft-delete existing ``RolePermissions`` rows for this + role, then bulk_insert the new set. Repeat-with-same-payload + is idempotent (inserts after the soft-delete). + """ + role = await _load_platform_role_by_code(session, role_code) + + is_self = context.user.platform_role_id == role.role_id + new_codes = list(dict.fromkeys(payload.permission_codes)) + + if role.role_code == "admin": + keeps_admin_entry = ( + "system.view" in new_codes and "system.manage" in new_codes + ) + if not keeps_admin_entry: + raise HTTPException( + status.HTTP_409_CONFLICT, + "admin 角色必须保留 system.view 与 system.manage 权限", + ) + # Last-admin safety: if removing any current system.* permission + # would leave zero active admins able to reach this endpoint, + # reject. In practice the "keeps_admin_entry" check above + # already covers this for the admin role; the redundant guard + # is kept for clarity and as a safety net if a future schema + # change adds new admin-only permissions. + if is_self: + remaining = await _count_active_system_admins( + session, exclude_user_id=context.user.user_id, + ) + if remaining == 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + "platform 必须保留至少一个 active 系统管理员", + ) + + # 4. Validate every requested permission_code exists and is live. + if new_codes: + rows = ( + await session.execute( + select(Permissions.permission_code).where( + Permissions.permission_code.in_(new_codes), + Permissions.is_deleted == 0, + ) + ) + ).all() + found = {row[0] for row in rows} + missing = [code for code in new_codes if code not in found] + if missing: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + f"未知的 permission_code: {missing}", + ) + + # 5. Write: diff-based soft-delete + insert. + # The (role_id, permission_id) PRIMARY KEY still occupies the slot + # of soft-deleted rows, so a "delete-all then insert-all" approach + # would IntegrityError on any code that was already linked. + # Instead: only soft-delete codes NOT in the new set, only INSERT + # codes NOT already active. Repeat-with-same-payload is a no-op. + now = datetime.datetime.utcnow() + current_codes = set( + await _load_role_permission_codes(session, role.role_id) + ) + new_set = set(new_codes) + + codes_to_drop = current_codes - new_set + codes_to_add = new_set - current_codes + + if codes_to_drop: + # Resolve to permission_ids then soft-delete by id pair. + drop_ids = ( + await session.execute( + select(Permissions.permission_id).where( + Permissions.permission_code.in_(codes_to_drop), + Permissions.is_deleted == 0, + ) + ) + ).all() + drop_id_values = [row[0] for row in drop_ids] + await session.execute( + update(RolePermissions) + .where( + RolePermissions.role_id == role.role_id, + RolePermissions.permission_id.in_(drop_id_values), + RolePermissions.is_deleted == 0, + ) + .values(is_deleted=1, deleted_at=now) + ) + + if codes_to_add: + add_ids = ( + await session.execute( + select(Permissions.permission_id).where( + Permissions.permission_code.in_(codes_to_add), + Permissions.is_deleted == 0, + ) + ) + ).all() + if add_ids: + await session.execute( + insert(RolePermissions), + [ + {"role_id": role.role_id, "permission_id": pid} + for pid, in add_ids + ], + ) + + await session.flush() + final_codes = await _load_role_permission_codes(session, role.role_id) + return _envelope( + context.request_id, _role_payload(role, final_codes), + ) + + __all__ = [ "router", "SystemAdminContext",