feat: user permission

This commit is contained in:
tao.chen
2026-08-07 13:00:14 +08:00
parent a891f1f649
commit 03096a8951
3 changed files with 313 additions and 6 deletions
+17 -3
View File
@@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.jwt import JwtError, issue_jwt, verify_jwt_token
from common.auth.membership import resolve_is_system_admin from common.auth.membership import resolve_is_system_admin
from common.auth.passwords import verify_password from common.auth.passwords import verify_password
@@ -69,6 +69,7 @@ def _user_payload(
role_code: str | None = None, role_code: str | None = None,
*, *,
is_system_admin: bool = False, is_system_admin: bool = False,
permissions: list[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
return { return {
"user_id": user.user_id, "user_id": user.user_id,
@@ -78,6 +79,7 @@ def _user_payload(
"status": user.status, "status": user.status,
"role_code": role_code, "role_code": role_code,
"is_system_admin": is_system_admin, "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) _set_session_cookie(request, response, token)
is_system_admin = await resolve_is_system_admin(session, user) is_system_admin = await resolve_is_system_admin(session, user)
permissions = await load_user_permissions(session, user)
return { return {
"request_id": new_ulid(), "request_id": new_ulid(),
"data": { "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, "workspaces": workspaces,
"default_workspace_id": default_workspace_id, "default_workspace_id": default_workspace_id,
}, },
@@ -256,11 +264,17 @@ async def me(
user_role_code = rows[0][1].role_code user_role_code = rows[0][1].role_code
is_system_admin = await resolve_is_system_admin(session, user) is_system_admin = await resolve_is_system_admin(session, user)
permissions = await load_user_permissions(session, user)
return { return {
"request_id": new_ulid(), "request_id": new_ulid(),
"data": { "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, "workspaces": workspaces,
"default_workspace_id": default_workspace_id, "default_workspace_id": default_workspace_id,
}, },
+37 -1
View File
@@ -41,7 +41,7 @@ from common.auth.membership import (
resolve_is_system_admin, resolve_is_system_admin,
) )
from common.db import session_scope 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 from common.ids import new_ulid
@@ -174,3 +174,39 @@ async def _load_admin_role(session: AsyncSession) -> Roles | None:
return await session.scalar( return await session.scalar(
select(Roles).where(Roles.role_code == "admin") 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]
+259 -2
View File
@@ -53,12 +53,19 @@ from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field 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 sqlalchemy.ext.asyncio import AsyncSession
from backend.dependencies import current_user, database_session from backend.dependencies import current_user, database_session
from common.auth.passwords import hash_password 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 from common.ids import new_ulid
@@ -133,6 +140,20 @@ class PlatformEmployeeUpdate(BaseModel):
role_code: Literal["admin", "developer"] | None = None 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 # 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__ = [ __all__ = [
"router", "router",
"SystemAdminContext", "SystemAdminContext",