feat: user permission
This commit is contained in:
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user