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.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,
},
+37 -1
View File
@@ -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]
+259 -2
View File
@@ -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",