diff --git a/CLAUDE.md b/CLAUDE.md index 00fa22b..d106ebb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,4 +123,16 @@ Lessons from splitting `frontend/app/features/schedules/state/schedulesStore.ts` ### Frontend coupling -- **`UserManagementPage.tsx` and `api.ts` still call `/api/v1/admin/employees`.** Don't migrate them in the same change as a `/api/v1/platform/employees` addition — the contract surface is intentionally duplicated. \ No newline at end of file +- **`UserManagementPage.tsx` and `api.ts` still call `/api/v1/admin/employees`.** Don't migrate them in the same change as a `/api/v1/platform/employees` addition — the contract surface is intentionally duplicated. + +### File size: 500 lines hard cap + +- **每个文件最多 500 行** — 超过时主动拆分,不要等 review 才动。 +- 常见拆分维度: + - **后端 — 按资源 / 端点分组**:例如 `api/platform.py` (1600 行 / 20 endpoint) → `api/platform/{__init__,_deps,employees,roles,workspaces}.py`。原文件改为 thin shim,只 re-export 公共符号,保持 `from backend.api.platform import router` 等既有 import path 不变。 + - **后端 — 按层级**:参考 `schedule/` 已有的 `domain/` / `application/` / `infrastructure/` 分层。 + - **前端 — 按职责**:`types.ts` / `helpers.ts` / `slices/` / `useXStore.ts`(参考 `useSchedulesStore` 拆 8-slice 的纪律)。 + - **前端 — 页面 vs 路由 wrapper**:Page 组件持有 useState,route 文件只做 `` 重挂载,二者不要混在一起。 +- **拆分前先列调用面**(`grep "from "`),任何外部 import 路径必须仍然可用 — 用 re-export 或 shim 兜底,不要让调用方被迫改。 +- **拆分后**每个新文件 ≤ 500 行是硬约束,验证方式:`wc -l ` 或 CI 脚本。 +- 拆分本身是**纯结构调整**,endpoint 行为 / URL / 响应 schema 零变化 — 不要顺手"清理"。 \ No newline at end of file diff --git a/backend/src/backend/api/platform.py b/backend/src/backend/api/platform.py index f251985..dc480ff 100644 --- a/backend/src/backend/api/platform.py +++ b/backend/src/backend/api/platform.py @@ -1,1316 +1,8 @@ -"""系统级管理接口。 - -中文导读:本模块管理全平台的用户、工作区、成员关系和角色权限。它使用 -``system_admin_context`` 进行平台管理员校验,因此不要求请求者先加入某个具体 -工作区;普通工作区内的业务接口则使用 ``request_context``。 - -System-admin (platform-scope) endpoints for workspace & membership management. - -All routes under ``/api/v1/platform/*`` are gated by -:func:`system_admin_context`, which requires the requester to hold a -``Users.platform_role_id`` pointing to a ``Roles`` row whose -``role_code == 'admin'``. Unlike ``backend.api.dependencies.request_context``, -this dependency does NOT require an active workspace membership — system -admins can manage workspaces before/without being a member of any. - -Endpoints ---------- - -Workspace CRUD:: - - GET /workspaces — list non-deleted workspaces - POST /workspaces — create a new workspace - GET /workspaces/{workspace_id} — single workspace (incl. disabled) - PATCH /workspaces/{workspace_id} — update editable fields - DELETE /workspaces/{workspace_id} — soft delete (cascades memberships) - -Workspace membership CRUD:: - - GET /workspaces/{workspace_id}/members — list active members - POST /workspaces/{workspace_id}/members — add a member - PATCH /workspaces/{workspace_id}/members/{user_id} — update role/status - DELETE /workspaces/{workspace_id}/members/{user_id} — remove a member - -Platform employee roster:: - - GET /employees — list all non-deleted users - POST /employees — create a user without workspace membership - PATCH /employees/{user_id} — update a user's profile, status or platform role - DELETE /employees/{user_id} — soft delete a user (cascades to workspace memberships) - -Role menu-permission management:: - - GET /roles — list platform roles with their permission_codes - GET /roles/{role_code}/permissions — one role's permission_codes - PATCH /roles/{role_code}/permissions — replace a role's permission set (diff-based) - -Invariants ----------- - -* Every workspace must always retain at least one active ``admin`` member. - This is enforced on member PATCH/DELETE AND on - ``PATCH /employees/{user_id}`` demotions, because workspace role is - inherited from ``users.platform_role_id`` and demoting a platform - admin cascades to all of their active memberships. -* A system admin cannot remove their own workspace membership via - ``DELETE .../members/{self}``; the only escape is to delete the entire - workspace, which cascades membership soft-deletion. -* ``DELETE /workspaces/{id}`` is allowed from any non-disabled status and - sets ``status='disabled'`` + ``is_deleted=1`` + ``deleted_at`` on the - workspace and every one of its active memberships. -* The ``admin`` role must always keep ``system:view``. - menu permissions; non-admin roles may never hold ``system.*`` - permissions. Menu permissions gate frontend rendering only — API - authorization always keys off ``role_code == 'admin'``. -""" - -from __future__ import annotations - -import datetime -import re -from dataclasses import dataclass -from typing import Any, Literal - -from common.auth.passwords import hash_password -from common.db.models import ( - Permissions, - RolePermissions, - Roles, - Users, - WorkspaceMembers, - Workspaces, +"""Backward-compat shim — see backend.api.platform package.""" +from backend.api.platform import ( + SystemAdminContext, + router, + system_admin_context, ) -from common.ids import new_ulid -from fastapi import APIRouter, Depends, HTTPException, Request, status -from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import func, insert, or_, select, update -from sqlalchemy.ext.asyncio import AsyncSession -from backend.api.dependencies import current_user, database_session - -router = APIRouter(prefix="/api/v1/platform", tags=["platform"]) - - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -WORKSPACE_CODE_PATTERN = re.compile(r"^[a-z0-9-]{3,32}$") -LIST_PAGE_SIZE = 100 - -WORKSPACE_EDITABLE_STATUS = ("active", "archived") -MEMBER_ROLE_CODES = ("admin", "developer") -MEMBER_STATUS_VALUES = ("active", "disabled", "locked") - - -# --------------------------------------------------------------------------- -# Schemas -# --------------------------------------------------------------------------- - - -# 创建工作区时前端提交的请求体;禁止未声明字段。 -class WorkspaceCreate(BaseModel): - model_config = ConfigDict(extra="forbid") - - workspace_code: str = Field(min_length=3, max_length=32) - workspace_name: str = Field(min_length=1, max_length=150) - quota_bytes: int = Field(default=0, ge=0) - description: str | None = Field(default=None, max_length=1000) - - -# 编辑工作区时允许修改的字段;禁用操作必须走删除接口而不是直接传状态。 -class WorkspaceUpdate(BaseModel): - model_config = ConfigDict(extra="forbid") - - workspace_name: str | None = Field(default=None, min_length=1, max_length=150) - quota_bytes: int | None = Field(default=None, ge=0) - description: str | None = Field(default=None, max_length=1000) - # 'disabled' is rejected here on purpose — soft delete must go through DELETE. - status: Literal["active", "archived"] | None = None - - -# 将已存在用户加入工作区的请求体;角色继承用户的平台角色。 -class MemberCreate(BaseModel): - """Add a user to a workspace. Role is inherited from the user's - platform role (Users.platform_role_id) — not set here.""" - - model_config = ConfigDict(extra="forbid") - - user_id: str = Field(min_length=26, max_length=26) - - -# 更新成员在该工作区中的可用状态,不直接在这里修改平台角色。 -class MemberUpdate(BaseModel): - """Update a workspace membership's status. Role cannot be changed - via this endpoint — workspace role is always inherited from the - user's platform role. To change a member's role, PATCH - /platform/employees/{user_id} instead.""" - - model_config = ConfigDict(extra="forbid") - - member_status: Literal["active", "disabled", "locked"] | None = None - - -# 新建平台用户的请求体;创建用户不等同于把用户加入某个工作区。 -class PlatformEmployeeCreate(BaseModel): - model_config = ConfigDict(extra="forbid") - - username: str = Field(min_length=2, max_length=64) - display_name: str = Field(min_length=1, max_length=100) - email: str | None = Field(default=None, max_length=255) - password: str = Field(min_length=8, max_length=72) - role_code: Literal["admin", "developer"] | None = None - - -# 修改平台用户资料、状态或平台角色的请求体。 -class PlatformEmployeeUpdate(BaseModel): - model_config = ConfigDict(extra="forbid") - - display_name: str | None = Field(default=None, min_length=1, max_length=100) - email: str | None = Field(default=None, max_length=255) - status: Literal["active", "disabled", "locked"] | 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 -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class SystemAdminContext: - """通过系统管理员校验后的上下文,只包含当前用户和请求追踪 ID。""" - - """Resolved identity for a system-admin request. - - Carries the request id, the authenticated user row, and the resolved - ``Roles`` row the user holds via ``Users.platform_role_id``. By - construction the role's ``role_code`` is ``"admin"``. - """ - - request_id: str - user: Users - platform_role: Roles - - -async def system_admin_context( - request: Request, - session: AsyncSession = Depends(database_session), -) -> SystemAdminContext: - """验证当前用户是否为平台管理员,供 /api/v1/platform 下的路由依赖。""" - """Resolve the requester as a system admin. - - Steps: - 1. Reuse :func:`backend.api.dependencies.current_user` to validate the JWT - cookie and fetch the active ``Users`` row (raises 401 on failure). - 2. Require ``Users.platform_role_id`` to point to a row whose - ``role_code == 'admin'`` — anything else is 403. - """ - user = await current_user(request, session) - if user.platform_role_id is None: - raise HTTPException( - status.HTTP_403_FORBIDDEN, - "需要系统管理员权限", - ) - platform_role = await session.scalar( - select(Roles).where(Roles.role_id == user.platform_role_id) - ) - if platform_role is None or platform_role.role_code != "admin": - raise HTTPException( - status.HTTP_403_FORBIDDEN, - "需要系统管理员权限", - ) - request_id = request.headers.get("X-Request-ID") or new_ulid() - return SystemAdminContext( - request_id=request_id, - user=user, - platform_role=platform_role, - ) - - -async def _is_system_admin(session: AsyncSession, user: Users) -> bool: - """True if ``user`` holds the platform-scoped admin role. - - Mirrors the check inside :func:`system_admin_context` so member-listing - endpoints can admit workspace members *or* system admins without pulling - in the full :class:`SystemAdminContext` (which 403s non-admins outright). - """ - if user.platform_role_id is None: - return False - platform_role = await session.scalar( - select(Roles).where(Roles.role_id == user.platform_role_id) - ) - return platform_role is not None and platform_role.role_code == "admin" - - -# --------------------------------------------------------------------------- -# Payload helpers -# --------------------------------------------------------------------------- - - -def workspace_payload(workspace: Workspaces) -> dict[str, Any]: - return { - "workspace_id": workspace.workspace_id, - "workspace_code": workspace.workspace_code, - "workspace_name": workspace.workspace_name, - "active_root_uri": workspace.active_root_uri, - "quota_bytes": workspace.quota_bytes, - "status": workspace.status, - "description": workspace.description, - "created_by": workspace.created_by, - "created_at": workspace.created_at.isoformat(), - "updated_at": ( - workspace.updated_at.isoformat() if workspace.updated_at else None - ), - } - - -def member_payload( - user: Users, - role: Roles, - membership: WorkspaceMembers, -) -> dict[str, Any]: - return { - "user_id": user.user_id, - "username": user.username, - "display_name": user.display_name, - "email": user.email, - "user_status": user.status, - "role_code": role.role_code, - "role_name": role.role_name, - "member_status": membership.member_status, - "joined_at": membership.joined_at.isoformat(), - } - - -def platform_employee_payload( - user: Users, - role: Roles | None, -) -> dict[str, Any]: - return { - "user_id": user.user_id, - "username": user.username, - "display_name": user.display_name, - "email": user.email, - "status": user.status, - "role_code": role.role_code if role is not None else None, - "role_name": role.role_name if role is not None else None, - "created_at": user.created_at.isoformat(), - } - - -# --------------------------------------------------------------------------- -# Internal helpers -# --------------------------------------------------------------------------- - - -async def _load_workspace(session: AsyncSession, workspace_id: str) -> Workspaces: - workspace = await session.get(Workspaces, workspace_id) - if workspace is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "workspace 不存在") - return workspace - - -async def _load_role_by_code(session: AsyncSession, role_code: str) -> Roles: - role = await session.scalar(select(Roles).where(Roles.role_code == role_code)) - if role is None: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - f"角色 {role_code} 不存在", - ) - return role - - -async def _count_active_admins( - session: AsyncSession, - workspace_id: str, - exclude_user_id: str | None = None, -) -> int: - """Count active admin members of ``workspace_id``. - - Pass ``exclude_user_id`` when checking "would X be the last admin?" - before mutating X. - """ - admin_role = await _load_role_by_code(session, "admin") - stmt = ( - select(func.count()) - .select_from(WorkspaceMembers) - .where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.role_id == admin_role.role_id, - WorkspaceMembers.member_status == "active", - WorkspaceMembers.is_deleted == 0, - ) - ) - if exclude_user_id is not None: - stmt = stmt.where(WorkspaceMembers.user_id != exclude_user_id) - return int(await session.scalar(stmt) or 0) - - -async def _count_active_system_admins( - session: AsyncSession, - exclude_user_id: str | None = None, -) -> int: - """Count active system admins across the platform. - - Pass ``exclude_user_id`` when checking "would X be the last admin?" - before mutating X. - """ - admin_role = await _load_role_by_code(session, "admin") - stmt = ( - select(func.count()) - .select_from(Users) - .where( - Users.status == "active", - Users.is_deleted == 0, - Users.platform_role_id == admin_role.role_id, - ) - ) - if exclude_user_id is not None: - stmt = stmt.where(Users.user_id != exclude_user_id) - return int(await session.scalar(stmt) or 0) - - -def _envelope(request_id: str, data: Any, meta: dict[str, Any] | None = None) -> dict[str, Any]: - return { - "request_id": request_id, - "data": data, - "meta": meta or {}, - } - - -# --------------------------------------------------------------------------- -# Platform employee roster -# --------------------------------------------------------------------------- - - -# 列出整个平台的非删除用户;不局限于某一个工作区。 -@router.get("/employees") -async def list_platform_employees( - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """List every non-soft-deleted platform user.""" - rows = ( - await session.execute( - select(Users, Roles) - .outerjoin(Roles, Roles.role_id == Users.platform_role_id) - .where(Users.is_deleted == 0) - .order_by(Users.created_at, Users.user_id) - ) - ).all() - return _envelope( - context.request_id, - [platform_employee_payload(user, role) for user, role in rows], - {"count": len(rows)}, - ) - - -# 创建平台用户;后续可再通过成员接口把该用户加入工作区。 -@router.post("/employees", status_code=status.HTTP_201_CREATED) -async def create_platform_employee( - payload: PlatformEmployeeCreate, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Create a platform user without assigning workspace membership.""" - username = payload.username.strip() - display_name = payload.display_name.strip() - duplicate_conditions = [Users.username == username] - if payload.email: - duplicate_conditions.append(Users.email == payload.email.strip()) - duplicate = await session.scalar( - select(Users.user_id).where(or_(*duplicate_conditions)) - ) - if duplicate is not None: - raise HTTPException(status.HTTP_409_CONFLICT, "用户名或邮箱已存在") - - new_role: Roles | None = None - if payload.role_code is not None: - new_role = await _load_role_by_code(session, payload.role_code) - - user = Users( - user_id=new_ulid(), - username=username, - display_name=display_name, - email=payload.email.strip() if payload.email else None, - password_hash=hash_password(payload.password), - status="active", - platform_role_id=new_role.role_id if new_role is not None else None, - ) - session.add(user) - await session.flush() - await session.refresh(user) - return _envelope( - context.request_id, - platform_employee_payload(user, new_role), - ) - - -# 更新平台用户资料、账号状态或平台角色,同时保护最少管理员等约束。 -@router.patch("/employees/{user_id}") -async def update_platform_employee( - user_id: str, - payload: PlatformEmployeeUpdate, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Update a platform user's profile, status, or platform role. - - Changing ``role_code`` cascades: every active ``workspace_members`` - row of the user is rewritten to the new role (workspace role is - inherited from the platform role). Demoting admin → developer is - rejected with 409 when it would leave any workspace without an - active admin member, or the platform without an active system - admin. Self-demotion is always rejected. - """ - user = await session.get(Users, user_id) - if user is None or user.is_deleted != 0: - raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") - - is_self = user_id == context.user.user_id - if ( - is_self - and payload.status is not None - and payload.status != "active" - ): - raise HTTPException(status.HTTP_409_CONFLICT, "不能停用当前登录账号") - - current_role: Roles | None = None - if user.platform_role_id is not None: - current_role = await session.scalar( - select(Roles).where(Roles.role_id == user.platform_role_id) - ) - is_current_system_admin = ( - user.status == "active" - and current_role is not None - and current_role.role_code == "admin" - ) - - new_role: Roles | None = None - if payload.role_code is not None: - new_role = await _load_role_by_code(session, payload.role_code) - next_status = payload.status if payload.status is not None else user.status - - leaves_admin_pool = ( - is_current_system_admin - and ( - next_status != "active" - or (new_role is not None and new_role.role_code != "admin") - ) - ) - if leaves_admin_pool: - remaining = await _count_active_system_admins( - session, exclude_user_id=user_id, - ) - if remaining == 0: - raise HTTPException( - status.HTTP_409_CONFLICT, - "platform 必须保留至少一个 active 系统管理员", - ) - - # Workspace-level last-admin guard for the demote path. The role_code - # sync below rewrites ``workspace_members.role_id`` for every active - # membership of this user, so demoting admin → developer would - # silently strip workspace admin coverage anywhere this user is the - # sole active admin member. ``update_member`` / ``remove_member`` - # guard the same invariant via ``_count_active_admins``; this - # endpoint must too, now that it can change workspace roles. - demotes_admin = ( - is_current_system_admin - and new_role is not None - and new_role.role_code != "admin" - ) - if demotes_admin: - assert current_role is not None # implied by is_current_system_admin - admin_memberships = ( - await session.execute( - select(WorkspaceMembers.workspace_id) - .where( - WorkspaceMembers.user_id == user_id, - WorkspaceMembers.role_id == current_role.role_id, - WorkspaceMembers.member_status == "active", - WorkspaceMembers.is_deleted == 0, - ) - ) - ).all() - orphaned: list[str] = [] - for (ws_id,) in admin_memberships: - remaining_ws = await _count_active_admins( - session, ws_id, exclude_user_id=user_id, - ) - if remaining_ws == 0: - orphaned.append(ws_id) - if orphaned: - codes = ( - await session.execute( - select(Workspaces.workspace_code).where( - Workspaces.workspace_id.in_(orphaned) - ) - ) - ).all() - names = sorted(row[0] for row in codes) - raise HTTPException( - status.HTTP_409_CONFLICT, - f"以下 workspace 将失去唯一 active admin: {names};" - "请先在这些 workspace 中指定其他 admin,再降级该用户", - ) - - if is_self and new_role is not None and new_role.role_code != "admin": - raise HTTPException(status.HTTP_409_CONFLICT, "不能降级自身管理员角色") - - if payload.display_name is not None: - user.display_name = payload.display_name.strip() - if payload.email is not None: - user.email = payload.email.strip() or None - if payload.status is not None: - user.status = payload.status - if new_role is not None: - user.platform_role_id = new_role.role_id - # Workspace role is always inherited from the platform role - # (§7.5/§7.6 cannot change it). Keep workspace_members.role_id - # in sync so downstream reads — `/me` workspaces[].role_code, - # load_active_membership, §7.7 DELETE last-admin guard — - # see the up-to-date role. Without this sync, a user demoted - # from admin → developer would still appear as admin in every - # workspace they belong to until they leave and re-join. - await session.execute( - update(WorkspaceMembers) - .where( - WorkspaceMembers.user_id == user.user_id, - WorkspaceMembers.is_deleted == 0, - ) - .values(role_id=new_role.role_id) - ) - - await session.flush() - await session.refresh(user) - - response_role: Roles | None = None - if user.platform_role_id is not None: - response_role = await session.scalar( - select(Roles).where(Roles.role_id == user.platform_role_id) - ) - return _envelope( - context.request_id, platform_employee_payload(user, response_role), - ) - - -# 软删除平台用户,并级联标记其工作区成员关系为删除。 -@router.delete("/employees/{user_id}") -async def delete_platform_employee( - user_id: str, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Soft delete a platform user and cascade-soft-delete workspace memberships.""" - user = await session.get(Users, user_id) - if user is None or user.is_deleted != 0: - raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") - - if user_id == context.user.user_id: - raise HTTPException(status.HTTP_409_CONFLICT, "不能删除当前登录账号") - - if user.status == "active" and user.platform_role_id is not None: - current_admin_role = await session.scalar( - select(Roles).where(Roles.role_id == user.platform_role_id) - ) - if current_admin_role is not None and current_admin_role.role_code == "admin": - remaining = await _count_active_system_admins( - session, exclude_user_id=user_id, - ) - if remaining == 0: - raise HTTPException( - status.HTTP_409_CONFLICT, - "platform 必须保留至少一个 active 系统管理员", - ) - - now = datetime.datetime.utcnow() - user.status = "disabled" - user.is_deleted = 1 - user.deleted_at = now - await session.execute( - update(WorkspaceMembers) - .where( - WorkspaceMembers.user_id == user_id, - WorkspaceMembers.is_deleted == 0, - ) - .values(is_deleted=1, deleted_at=now) - ) - await session.flush() - return _envelope( - context.request_id, - {"user_id": user_id, "deleted": True}, - ) - - -# --------------------------------------------------------------------------- -# Workspace CRUD -# --------------------------------------------------------------------------- - - -# 列出平台中全部未删除工作区。 -@router.get("/workspaces") -async def list_workspaces( - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """List active/archived workspaces. Soft-deleted rows are filtered out. - - Silent ``pageSize=100`` cap — YAGNI on real pagination until needed. - """ - rows = ( - await session.execute( - select(Workspaces) - .where( - Workspaces.status != "disabled", - Workspaces.is_deleted == 0, - ) - .order_by(Workspaces.created_at, Workspaces.workspace_id) - .limit(LIST_PAGE_SIZE) - ) - ).scalars().all() - return _envelope( - context.request_id, - [workspace_payload(w) for w in rows], - {"count": len(rows), "page_size": LIST_PAGE_SIZE}, - ) - - -# 创建工作区,并将当前系统管理员初始化为该工作区管理员。 -@router.post("/workspaces", status_code=status.HTTP_201_CREATED) -async def create_workspace( - payload: WorkspaceCreate, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Create a workspace and auto-join the creator as an admin member.""" - if not WORKSPACE_CODE_PATTERN.fullmatch(payload.workspace_code): - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - "workspace_code 必须匹配 ^[a-z0-9-]{3,32}$", - ) - duplicate = await session.scalar( - select(Workspaces.workspace_id).where( - Workspaces.workspace_code == payload.workspace_code, - ) - ) - if duplicate is not None: - raise HTTPException(status.HTTP_409_CONFLICT, "workspace_code 已存在") - - admin_role = await _load_role_by_code(session, "admin") - workspace_id = new_ulid() - workspace = Workspaces( - workspace_id=workspace_id, - workspace_code=payload.workspace_code, - workspace_name=payload.workspace_name, - active_root_uri=f"s3://workspaces/{workspace_id}/", - quota_bytes=payload.quota_bytes, - status="active", - created_by=context.user.user_id, - description=payload.description, - ) - session.add(workspace) - session.add( - WorkspaceMembers( - workspace_id=workspace_id, - user_id=context.user.user_id, - role_id=admin_role.role_id, - member_status="active", - ) - ) - await session.flush() - await session.refresh(workspace) - return _envelope(context.request_id, workspace_payload(workspace)) - - -# 读取单个工作区详情,包含已归档或禁用状态。 -@router.get("/workspaces/{workspace_id}") -async def get_workspace( - workspace_id: str, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Fetch a single workspace — even soft-deleted ones are reachable.""" - workspace = await _load_workspace(session, workspace_id) - return _envelope(context.request_id, workspace_payload(workspace)) - - -# 更新工作区可编辑属性,例如名称、配额、描述和归档状态。 -@router.patch("/workspaces/{workspace_id}") -async def update_workspace( - workspace_id: str, - payload: WorkspaceUpdate, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Patch editable workspace fields. ``status='disabled'`` is rejected.""" - workspace = await _load_workspace(session, workspace_id) - if workspace.status == "disabled": - raise HTTPException( - status.HTTP_409_CONFLICT, - "workspace 已删除,无法修改", - ) - if payload.workspace_name is not None: - workspace.workspace_name = payload.workspace_name.strip() - if payload.quota_bytes is not None: - workspace.quota_bytes = payload.quota_bytes - if payload.description is not None: - workspace.description = payload.description - if payload.status is not None: - workspace.status = payload.status - await session.flush() - await session.refresh(workspace) - return _envelope(context.request_id, workspace_payload(workspace)) - - -# 软删除/禁用工作区,并级联处理其活动成员关系。 -@router.delete("/workspaces/{workspace_id}") -async def delete_workspace( - workspace_id: str, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Soft-delete a workspace and cascade-soft-delete its memberships. - - Allowed from any non-disabled status (active or archived). The - membership cascade is what lets system admins leave a workspace — - there is no per-member DELETE escape for self-removal. - """ - workspace = await _load_workspace(session, workspace_id) - if workspace.status == "disabled": - raise HTTPException( - status.HTTP_409_CONFLICT, - "workspace 已被删除", - ) - now = datetime.datetime.utcnow() - workspace.status = "disabled" - workspace.is_deleted = 1 - workspace.deleted_at = now - await session.execute( - update(WorkspaceMembers) - .where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.is_deleted == 0, - ) - .values(is_deleted=1, deleted_at=now) - ) - await session.flush() - await session.refresh(workspace) - return _envelope(context.request_id, workspace_payload(workspace)) - - -# --------------------------------------------------------------------------- -# Workspace membership CRUD -# --------------------------------------------------------------------------- - - -# 列出一个工作区的活动成员与成员状态。 -@router.get("/workspaces/{workspace_id}/members") -async def list_members( - workspace_id: str, - request: Request, - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """List active and historical (non-soft-deleted) members of a workspace. - - Accessible to system admins (any workspace) and to active members of the - workspace itself. The script explorer calls this to seed the per-owner - directory-tree groups for non-admin users; visibility filters on the - scripts/data-resources endpoints still keep each peer's private content - hidden, so this only exposes membership (names), not private files. - """ - user = await current_user(request, session) - is_system_admin = await _is_system_admin(session, user) - if not is_system_admin: - membership = await session.scalar( - select(WorkspaceMembers).where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.user_id == user.user_id, - WorkspaceMembers.is_deleted == 0, - WorkspaceMembers.member_status == "active", - ) - ) - if membership is None: - raise HTTPException( - status.HTTP_403_FORBIDDEN, - "需要系统管理员或该工作区成员权限", - ) - await _load_workspace(session, workspace_id) - rows = ( - await session.execute( - select(Users, Roles, WorkspaceMembers) - .join( - WorkspaceMembers, - WorkspaceMembers.user_id == Users.user_id, - ) - .join(Roles, Roles.role_id == WorkspaceMembers.role_id) - .where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.is_deleted == 0, - ) - .order_by(WorkspaceMembers.joined_at, Users.user_id) - .limit(LIST_PAGE_SIZE) - ) - ).all() - request_id = request.headers.get("X-Request-ID") or new_ulid() - return _envelope( - request_id, - [member_payload(u, r, m) for u, r, m in rows], - {"count": len(rows), "page_size": LIST_PAGE_SIZE}, - ) - - -# 将已有平台用户加入指定工作区。 -@router.post( - "/workspaces/{workspace_id}/members", - status_code=status.HTTP_201_CREATED, -) -async def add_member( - workspace_id: str, - payload: MemberCreate, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Add a user to a workspace. The new row starts with member_status='active'. - - The role is inherited from the target user's ``platform_role_id``; - the request body does NOT take a ``role_code``. To change a member's - role, PATCH ``/api/v1/platform/employees/{user_id}`` instead. - """ - await _load_workspace(session, workspace_id) - user = await session.get(Users, payload.user_id) - if user is None or user.is_deleted != 0: - raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") - if user.status != "active": - raise HTTPException( - status.HTTP_409_CONFLICT, - f"用户状态为 {user.status},无法加入 workspace", - ) - if user.platform_role_id is None: - raise HTTPException( - status.HTTP_409_CONFLICT, - "目标用户尚未分配平台角色,无法加入 workspace;" - "请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code", - ) - role = await session.scalar( - select(Roles).where( - Roles.role_id == user.platform_role_id, - Roles.is_deleted == 0, - ) - ) - if role is None: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - "用户的平台角色行不存在或已被删除", - ) - # ``WorkspaceMembers`` 的主键是 ``(workspace_id, user_id)`` 复合 PK, - # 而 ``remove_member`` / ``delete_platform_employee`` 都是软删除 (保留行, - # 仅置 ``is_deleted=1``). 因此这里必须按主键查整行,而不是只看活跃行: - # 否则软删行会被 active-duplicate 检查漏过,然后 INSERT 直接撞 PK. - existing = await session.scalar( - select(WorkspaceMembers).where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.user_id == payload.user_id, - ) - ) - if existing is not None: - if existing.is_deleted == 0: - raise HTTPException( - status.HTTP_409_CONFLICT, - "用户已是该 workspace 成员;workspace 角色继承自平台角色," - "要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code", - ) - # 复活软删除行. 保留 ``joined_at`` 作为历史记录;``role_id`` 重新继承 - # 当前用户的平台角色 (用户在中间可能改过 platform_role);清掉 - # ``deleted_at`` 标记本轮已不在软删状态. - existing.is_deleted = 0 - existing.deleted_at = None - existing.role_id = role.role_id - existing.member_status = "active" - await session.flush() - await session.refresh(existing) - return _envelope( - context.request_id, member_payload(user, role, existing), - ) - membership = WorkspaceMembers( - workspace_id=workspace_id, - user_id=payload.user_id, - role_id=role.role_id, - member_status="active", - ) - session.add(membership) - await session.flush() - await session.refresh(membership) - return _envelope(context.request_id, member_payload(user, role, membership)) - - -# 更新成员状态,例如禁用或锁定;同时保证工作区不会失去最后一个管理员。 -@router.patch("/workspaces/{workspace_id}/members/{user_id}") -async def update_member( - workspace_id: str, - user_id: str, - payload: MemberUpdate, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Update a workspace membership's status. Role is not editable here. - - Workspace role is always inherited from the user's platform role - (``Users.platform_role_id``). To change role, PATCH - ``/api/v1/platform/employees/{user_id}`` instead. - - Last-admin guard still applies to ``member_status`` changes: setting - the only active admin to ``disabled``/``locked`` would leave the - workspace without admin coverage. - """ - await _load_workspace(session, workspace_id) - row = ( - await session.execute( - select(Users, Roles, WorkspaceMembers) - .join( - WorkspaceMembers, - WorkspaceMembers.user_id == Users.user_id, - ) - .join(Roles, Roles.role_id == WorkspaceMembers.role_id) - .where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.user_id == user_id, - WorkspaceMembers.is_deleted == 0, - ) - ) - ).first() - if row is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在") - user, role, membership = row - - if payload.member_status is not None and payload.member_status != membership.member_status: - if ( - role.role_code == "admin" - and payload.member_status != "active" - ): - remaining = await _count_active_admins( - session, workspace_id, exclude_user_id=user_id, - ) - if remaining == 0: - raise HTTPException( - status.HTTP_409_CONFLICT, - "workspace 必须保留至少一个 admin", - ) - membership.member_status = payload.member_status - - await session.flush() - await session.refresh(membership) - return _envelope(context.request_id, member_payload(user, role, membership)) - - -# 移除某个工作区成员,并保护最后一名管理员及当前操作者的安全约束。 -@router.delete("/workspaces/{workspace_id}/members/{user_id}") -async def remove_member( - workspace_id: str, - user_id: str, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Soft-delete a workspace membership. - - System admins cannot remove themselves — the only escape is to delete - the entire workspace, which cascades membership soft-deletion. - """ - await _load_workspace(session, workspace_id) - if user_id == context.user.user_id: - raise HTTPException( - status.HTTP_403_FORBIDDEN, - "系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace", - ) - row = ( - await session.execute( - select(Roles, WorkspaceMembers) - .join(Roles, Roles.role_id == WorkspaceMembers.role_id) - .where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.user_id == user_id, - WorkspaceMembers.is_deleted == 0, - ) - ) - ).first() - if row is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在") - role, membership = row - if role.role_code == "admin" and membership.member_status == "active": - remaining = await _count_active_admins( - session, workspace_id, exclude_user_id=user_id, - ) - if remaining == 0: - raise HTTPException( - status.HTTP_409_CONFLICT, - "workspace 必须保留至少一个 admin", - ) - membership.is_deleted = 1 - membership.deleted_at = datetime.datetime.utcnow() - await session.flush() - return _envelope( - context.request_id, - {"workspace_id": workspace_id, "user_id": user_id, "removed": True}, - ) - - -# --------------------------------------------------------------------------- -# 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: - - 1. Load the role. Reject 404 if it is missing or not - platform-scoped. - 2. Admin role: the patched ``permission_codes`` MUST still include - ``system:view``. Otherwise every - active admin loses the menu entry to this very endpoint and - the platform locks itself out. Reject with 409. (No last-admin - count is needed here — menu permissions never gate API access; - ``system_admin_context`` keys off ``role_code == 'admin'``.) - 3. Non-admin role: ``system.*`` codes are rejected with 422 — - they would render a system-admin menu entry whose API calls - all 403. - 4. Validate every code resolves to a non-deleted ``Permissions`` - row; unknown codes → 422. - 5. Write: diff-based. Only soft-delete codes leaving the set, - only insert codes entering it. The ``(role_id, permission_id)`` - PRIMARY KEY still occupies soft-deleted rows, so a blanket - "delete-all then insert-all" would IntegrityError. - Repeat-with-same-payload is a no-op. - """ - role = await _load_platform_role_by_code(session, role_code) - - new_codes = list(dict.fromkeys(payload.permission_codes)) - - if role.role_code == "admin": - keeps_admin_entry = ( - "system:view" in new_codes - ) - if not keeps_admin_entry: - raise HTTPException( - status.HTTP_409_CONFLICT, - "admin 角色必须保留 system:view 权限", - ) - else: - # Menu permissions are a frontend-display signal only — backend - # authorization keeps keying off role_code == "admin". Letting a - # non-admin role hold system.* permissions would render the - # system-admin entry in the developer's UI while every - # /api/v1/platform/* call still returns 403. Reject with 422 so - # the failure is unambiguous about *what* the input violated. - leaked_system = [ - code for code in new_codes if code.startswith("system:") - ] - if leaked_system: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - f"非 admin 角色不能拥有 system.* 权限: {leaked_system}", - ) - - # 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__ = [ - "SystemAdminContext", - "router", - "system_admin_context", -] +__all__ = ["SystemAdminContext", "router", "system_admin_context"] diff --git a/backend/src/backend/api/platform/__init__.py b/backend/src/backend/api/platform/__init__.py new file mode 100644 index 0000000..f9c8ea2 --- /dev/null +++ b/backend/src/backend/api/platform/__init__.py @@ -0,0 +1,92 @@ +"""系统级管理接口。 + +中文导读:本模块管理全平台的用户、工作区、成员关系和角色权限。它使用 +``system_admin_context`` 进行平台管理员校验,因此不要求请求者先加入某个具体 +工作区;普通工作区内的业务接口则使用 ``request_context``。 + +System-admin (platform-scope) endpoints for workspace & membership management. + +All routes under ``/api/v1/platform/*`` are gated by +:func:`system_admin_context`, which requires the requester to hold a +``Users.platform_role_id`` pointing to a ``Roles`` row whose +``role_code == 'admin'``. Unlike ``backend.api.dependencies.request_context``, +this dependency does NOT require an active workspace membership — system +admins can manage workspaces before/without being a member of any. + +Endpoints +--------- + +Workspace CRUD:: + + GET /workspaces — list non-deleted workspaces + POST /workspaces — create a new workspace + GET /workspaces/{workspace_id} — single workspace (incl. disabled) + PATCH /workspaces/{workspace_id} — update editable fields + DELETE /workspaces/{workspace_id} — soft delete (cascades memberships) + +Workspace membership CRUD:: + + GET /workspaces/{workspace_id}/members — list active members + POST /workspaces/{workspace_id}/members — add a member + PATCH /workspaces/{workspace_id}/members/{user_id} — update role/status + DELETE /workspaces/{workspace_id}/members/{user_id} — remove a member + +Platform employee roster:: + + GET /employees — list all non-deleted users + POST /employees — create a user without workspace membership + PATCH /employees/{user_id} — update a user's profile, status or platform role + DELETE /employees/{user_id} — soft delete a user (cascades to workspace memberships) + +Role menu-permission management:: + + GET /roles — list platform roles with their permission_codes + GET /roles/{role_code}/permissions — one role's permission_codes + PATCH /roles/{role_code}/permissions — replace a role's permission set (diff-based) + +Invariants +---------- + +* Every workspace must always retain at least one active ``admin`` member. + This is enforced on member PATCH/DELETE AND on + ``PATCH /employees/{user_id}`` demotions, because workspace role is + inherited from ``users.platform_role_id`` and demoting a platform + admin cascades to all of their active memberships. +* A system admin cannot remove their own workspace membership via + ``DELETE .../members/{self}``; the only escape is to delete the entire + workspace, which cascades membership soft-deletion. +* ``DELETE /workspaces/{id}`` is allowed from any non-disabled status and + sets ``status='disabled'`` + ``is_deleted=1`` + ``deleted_at`` on the + workspace and every one of its active memberships. +* The ``admin`` role must always keep ``system:view``. + menu permissions; non-admin roles may never hold ``system.*`` + permissions. Menu permissions gate frontend rendering only — API + authorization always keys off ``role_code == 'admin'``. +""" + +from __future__ import annotations + +from fastapi import APIRouter + +from backend.api.platform._deps import ( + SystemAdminContext, + system_admin_context, +) +from backend.api.platform.employees import router as employees_router +from backend.api.platform.roles import router as roles_router +from backend.api.platform.workspaces import router as workspaces_router + +# 聚合 router,所有 endpoint 都挂在 /api/v1/platform 下。每个子 router 自带 +# prefix="/api/v1/platform",这里 include_router 不加 prefix,路径保持不变。 +router = APIRouter() +router.include_router(employees_router) +router.include_router(workspaces_router) +router.include_router(roles_router) + +# 保留 system_admin_context 的 re-export,供其他文件使用 +# (之前从 backend.api.platform 导出)。 +__all__ = [ + "SystemAdminContext", + "router", + "system_admin_context", +] diff --git a/backend/src/backend/api/platform/_deps.py b/backend/src/backend/api/platform/_deps.py new file mode 100644 index 0000000..5732e6e --- /dev/null +++ b/backend/src/backend/api/platform/_deps.py @@ -0,0 +1,148 @@ +"""Shared dependencies & helpers for the platform admin API. +Centralizes the ``system_admin_context`` gate plus cross-resource helpers so the +resource modules (employees / workspaces / roles) stay focused on their endpoints. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from common.db.models import Roles, Users, WorkspaceMembers +from common.ids import new_ulid +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.dependencies import current_user, database_session + +# --------------------------------------------------------------------------- +# System-admin context dependency +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SystemAdminContext: + """通过系统管理员校验后的上下文,只包含当前用户和请求追踪 ID。""" + + + request_id: str + user: Users + platform_role: Roles + + +async def system_admin_context( + request: Request, + session: AsyncSession = Depends(database_session), +) -> SystemAdminContext: + """验证当前用户是否为平台管理员,供 /api/v1/platform 下的路由依赖。""" + user = await current_user(request, session) + if user.platform_role_id is None: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "需要系统管理员权限", + ) + platform_role = await session.scalar( + select(Roles).where(Roles.role_id == user.platform_role_id) + ) + if platform_role is None or platform_role.role_code != "admin": + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "需要系统管理员权限", + ) + request_id = request.headers.get("X-Request-ID") or new_ulid() + return SystemAdminContext( + request_id=request_id, + user=user, + platform_role=platform_role, + ) + + +async def _is_system_admin(session: AsyncSession, user: Users) -> bool: + """True if ``user`` holds the platform-scoped admin role. + + Mirrors the check inside :func:`system_admin_context` so member-listing + endpoints can admit workspace members *or* system admins without pulling + in the full :class:`SystemAdminContext` (which 403s non-admins outright). + """ + if user.platform_role_id is None: + return False + platform_role = await session.scalar( + select(Roles).where(Roles.role_id == user.platform_role_id) + ) + return platform_role is not None and platform_role.role_code == "admin" + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +async def _load_role_by_code(session: AsyncSession, role_code: str) -> Roles: + role = await session.scalar(select(Roles).where(Roles.role_code == role_code)) + if role is None: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + f"角色 {role_code} 不存在", + ) + return role + + +async def _count_active_admins( + session: AsyncSession, + workspace_id: str, + exclude_user_id: str | None = None, +) -> int: + """Count active admin members of ``workspace_id``. + + Pass ``exclude_user_id`` when checking "would X be the last admin?" + before mutating X. + """ + admin_role = await _load_role_by_code(session, "admin") + stmt = ( + select(func.count()) + .select_from(WorkspaceMembers) + .where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.role_id == admin_role.role_id, + WorkspaceMembers.member_status == "active", + WorkspaceMembers.is_deleted == 0, + ) + ) + if exclude_user_id is not None: + stmt = stmt.where(WorkspaceMembers.user_id != exclude_user_id) + return int(await session.scalar(stmt) or 0) + + +async def _count_active_system_admins( + session: AsyncSession, + exclude_user_id: str | None = None, +) -> int: + """Count active system admins across the platform. + + Pass ``exclude_user_id`` when checking "would X be the last admin?" + before mutating X. + """ + admin_role = await _load_role_by_code(session, "admin") + stmt = ( + select(func.count()) + .select_from(Users) + .where( + Users.status == "active", + Users.is_deleted == 0, + Users.platform_role_id == admin_role.role_id, + ) + ) + if exclude_user_id is not None: + stmt = stmt.where(Users.user_id != exclude_user_id) + return int(await session.scalar(stmt) or 0) + + +def _envelope(request_id: str, data: Any, meta: dict[str, Any] | None = None) -> dict[str, Any]: + return { + "request_id": request_id, + "data": data, + "meta": meta or {}, + } + + diff --git a/backend/src/backend/api/platform/employees.py b/backend/src/backend/api/platform/employees.py new file mode 100644 index 0000000..7c14131 --- /dev/null +++ b/backend/src/backend/api/platform/employees.py @@ -0,0 +1,343 @@ +"""Platform employee roster endpoints. +CRUD for platform users (``/employees``). Role-code changes cascade to every active +``workspace_members`` row because workspace role is inherited from the platform role. +""" + +from __future__ import annotations + +import datetime +from typing import Any, Literal + +from common.auth.passwords import hash_password +from common.db.models import Roles, Users, WorkspaceMembers, Workspaces +from common.ids import new_ulid +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.dependencies import database_session +from backend.api.platform._deps import ( + SystemAdminContext, + _count_active_admins, + _count_active_system_admins, + _envelope, + _load_role_by_code, + system_admin_context, +) + +router = APIRouter(prefix="/api/v1/platform", tags=["platform"]) + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + + +# 新建平台用户的请求体;创建用户不等同于把用户加入某个工作区。 +class PlatformEmployeeCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + username: str = Field(min_length=2, max_length=64) + display_name: str = Field(min_length=1, max_length=100) + email: str | None = Field(default=None, max_length=255) + password: str = Field(min_length=8, max_length=72) + role_code: Literal["admin", "developer"] | None = None + + +# 修改平台用户资料、状态或平台角色的请求体。 +class PlatformEmployeeUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + display_name: str | None = Field(default=None, min_length=1, max_length=100) + email: str | None = Field(default=None, max_length=255) + status: Literal["active", "disabled", "locked"] | None = None + role_code: Literal["admin", "developer"] | None = None + + +# --------------------------------------------------------------------------- +# Payload helpers +# --------------------------------------------------------------------------- + + +def platform_employee_payload( + user: Users, + role: Roles | None, +) -> dict[str, Any]: + return { + "user_id": user.user_id, + "username": user.username, + "display_name": user.display_name, + "email": user.email, + "status": user.status, + "role_code": role.role_code if role is not None else None, + "role_name": role.role_name if role is not None else None, + "created_at": user.created_at.isoformat(), + } + + +# --------------------------------------------------------------------------- +# Platform employee roster +# --------------------------------------------------------------------------- + + +# 列出整个平台的非删除用户;不局限于某一个工作区。 +@router.get("/employees") +async def list_platform_employees( + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """List every non-soft-deleted platform user.""" + rows = ( + await session.execute( + select(Users, Roles) + .outerjoin(Roles, Roles.role_id == Users.platform_role_id) + .where(Users.is_deleted == 0) + .order_by(Users.created_at, Users.user_id) + ) + ).all() + return _envelope( + context.request_id, + [platform_employee_payload(user, role) for user, role in rows], + {"count": len(rows)}, + ) + + +# 创建平台用户;后续可再通过成员接口把该用户加入工作区。 +@router.post("/employees", status_code=status.HTTP_201_CREATED) +async def create_platform_employee( + payload: PlatformEmployeeCreate, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Create a platform user without assigning workspace membership.""" + username = payload.username.strip() + display_name = payload.display_name.strip() + duplicate_conditions = [Users.username == username] + if payload.email: + duplicate_conditions.append(Users.email == payload.email.strip()) + duplicate = await session.scalar( + select(Users.user_id).where(or_(*duplicate_conditions)) + ) + if duplicate is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "用户名或邮箱已存在") + + new_role: Roles | None = None + if payload.role_code is not None: + new_role = await _load_role_by_code(session, payload.role_code) + + user = Users( + user_id=new_ulid(), + username=username, + display_name=display_name, + email=payload.email.strip() if payload.email else None, + password_hash=hash_password(payload.password), + status="active", + platform_role_id=new_role.role_id if new_role is not None else None, + ) + session.add(user) + await session.flush() + await session.refresh(user) + return _envelope( + context.request_id, + platform_employee_payload(user, new_role), + ) + + +# 更新平台用户资料、账号状态或平台角色,同时保护最少管理员等约束。 +@router.patch("/employees/{user_id}") +async def update_platform_employee( + user_id: str, + payload: PlatformEmployeeUpdate, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Update a platform user's profile, status, or platform role. + + Changing ``role_code`` cascades: every active ``workspace_members`` + row of the user is rewritten to the new role (workspace role is + inherited from the platform role). Demoting admin → developer is + rejected with 409 when it would leave any workspace without an + active admin member, or the platform without an active system + admin. Self-demotion is always rejected. + """ + user = await session.get(Users, user_id) + if user is None or user.is_deleted != 0: + raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") + + is_self = user_id == context.user.user_id + if ( + is_self + and payload.status is not None + and payload.status != "active" + ): + raise HTTPException(status.HTTP_409_CONFLICT, "不能停用当前登录账号") + + current_role: Roles | None = None + if user.platform_role_id is not None: + current_role = await session.scalar( + select(Roles).where(Roles.role_id == user.platform_role_id) + ) + is_current_system_admin = ( + user.status == "active" + and current_role is not None + and current_role.role_code == "admin" + ) + + new_role: Roles | None = None + if payload.role_code is not None: + new_role = await _load_role_by_code(session, payload.role_code) + next_status = payload.status if payload.status is not None else user.status + + leaves_admin_pool = ( + is_current_system_admin + and ( + next_status != "active" + or (new_role is not None and new_role.role_code != "admin") + ) + ) + if leaves_admin_pool: + remaining = await _count_active_system_admins( + session, exclude_user_id=user_id, + ) + if remaining == 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + "platform 必须保留至少一个 active 系统管理员", + ) + + # Workspace-level last-admin guard for the demote path. The role_code + # sync below rewrites ``workspace_members.role_id`` for every active + # membership of this user, so demoting admin → developer would + # silently strip workspace admin coverage anywhere this user is the + # sole active admin member. ``update_member`` / ``remove_member`` + # guard the same invariant via ``_count_active_admins``; this + # endpoint must too, now that it can change workspace roles. + demotes_admin = ( + is_current_system_admin + and new_role is not None + and new_role.role_code != "admin" + ) + if demotes_admin: + assert current_role is not None # implied by is_current_system_admin + admin_memberships = ( + await session.execute( + select(WorkspaceMembers.workspace_id) + .where( + WorkspaceMembers.user_id == user_id, + WorkspaceMembers.role_id == current_role.role_id, + WorkspaceMembers.member_status == "active", + WorkspaceMembers.is_deleted == 0, + ) + ) + ).all() + orphaned: list[str] = [] + for (ws_id,) in admin_memberships: + remaining_ws = await _count_active_admins( + session, ws_id, exclude_user_id=user_id, + ) + if remaining_ws == 0: + orphaned.append(ws_id) + if orphaned: + codes = ( + await session.execute( + select(Workspaces.workspace_code).where( + Workspaces.workspace_id.in_(orphaned) + ) + ) + ).all() + names = sorted(row[0] for row in codes) + raise HTTPException( + status.HTTP_409_CONFLICT, + f"以下 workspace 将失去唯一 active admin: {names};" + "请先在这些 workspace 中指定其他 admin,再降级该用户", + ) + + if is_self and new_role is not None and new_role.role_code != "admin": + raise HTTPException(status.HTTP_409_CONFLICT, "不能降级自身管理员角色") + + if payload.display_name is not None: + user.display_name = payload.display_name.strip() + if payload.email is not None: + user.email = payload.email.strip() or None + if payload.status is not None: + user.status = payload.status + if new_role is not None: + user.platform_role_id = new_role.role_id + # Workspace role is always inherited from the platform role + # (§7.5/§7.6 cannot change it). Keep workspace_members.role_id + # in sync so downstream reads — `/me` workspaces[].role_code, + # load_active_membership, §7.7 DELETE last-admin guard — + # see the up-to-date role. Without this sync, a user demoted + # from admin → developer would still appear as admin in every + # workspace they belong to until they leave and re-join. + await session.execute( + update(WorkspaceMembers) + .where( + WorkspaceMembers.user_id == user.user_id, + WorkspaceMembers.is_deleted == 0, + ) + .values(role_id=new_role.role_id) + ) + + await session.flush() + await session.refresh(user) + + response_role: Roles | None = None + if user.platform_role_id is not None: + response_role = await session.scalar( + select(Roles).where(Roles.role_id == user.platform_role_id) + ) + return _envelope( + context.request_id, platform_employee_payload(user, response_role), + ) + + +# 软删除平台用户,并级联标记其工作区成员关系为删除。 +@router.delete("/employees/{user_id}") +async def delete_platform_employee( + user_id: str, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Soft delete a platform user and cascade-soft-delete workspace memberships.""" + user = await session.get(Users, user_id) + if user is None or user.is_deleted != 0: + raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") + + if user_id == context.user.user_id: + raise HTTPException(status.HTTP_409_CONFLICT, "不能删除当前登录账号") + + if user.status == "active" and user.platform_role_id is not None: + current_admin_role = await session.scalar( + select(Roles).where(Roles.role_id == user.platform_role_id) + ) + if current_admin_role is not None and current_admin_role.role_code == "admin": + remaining = await _count_active_system_admins( + session, exclude_user_id=user_id, + ) + if remaining == 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + "platform 必须保留至少一个 active 系统管理员", + ) + + now = datetime.datetime.utcnow() + user.status = "disabled" + user.is_deleted = 1 + user.deleted_at = now + await session.execute( + update(WorkspaceMembers) + .where( + WorkspaceMembers.user_id == user_id, + WorkspaceMembers.is_deleted == 0, + ) + .values(is_deleted=1, deleted_at=now) + ) + await session.flush() + return _envelope( + context.request_id, + {"user_id": user_id, "deleted": True}, + ) + + diff --git a/backend/src/backend/api/platform/roles.py b/backend/src/backend/api/platform/roles.py new file mode 100644 index 0000000..0d5379a --- /dev/null +++ b/backend/src/backend/api/platform/roles.py @@ -0,0 +1,499 @@ +"""Platform role & permission management endpoints. +Seven endpoints: role list/CRUD, per-role permission get/patch, and the permission +catalog; ``_apply_role_permission_set`` is shared by PATCH /roles/{code}/permissions and POST /roles. +""" + +from __future__ import annotations + +import datetime +import re +from typing import Any + +from common.db.models import ( + Permissions, + RolePermissions, + Roles, + Users, + WorkspaceMembers, +) +from common.ids import new_ulid +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, ConfigDict, Field, field_validator +from sqlalchemy import func, insert, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.dependencies import database_session +from backend.api.platform._deps import ( + SystemAdminContext, + _envelope, + system_admin_context, +) + +router = APIRouter(prefix="/api/v1/platform", tags=["platform"]) + +RESERVED_PLATFORM_ROLE_CODES = frozenset({"admin", "developer"}) +ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_-]{1,63}$") + +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) + +class RoleCreate(BaseModel): + """Create a platform role. + + ``role_code`` must not collide with the reserved built-in codes + (``admin`` / ``developer``); the validator rejects those with 422. + ``permission_codes`` is optional — an empty list means the role + starts with no menu permissions. + """ + + model_config = ConfigDict(extra="forbid") + + role_code: str = Field(min_length=2, max_length=64) + role_name: str = Field(min_length=1, max_length=100) + description: str | None = Field(default=None, max_length=500) + permission_codes: list[str] = Field(default_factory=list, max_length=64) + + @field_validator("role_code") + @classmethod + def _validate_role_code(cls, value: str) -> str: + if value in RESERVED_PLATFORM_ROLE_CODES: + raise ValueError(f"role_code {value!r} 是预留字") + if not ROLE_CODE_PATTERN.match(value): + raise ValueError("role_code 必须以小写字母开头,仅含小写字母/数字/下划线/连字符") + return value + +class RoleUpdate(BaseModel): + """Update a platform role's ``role_name`` / ``description`` only. + + ``role_code`` is intentionally absent — it is the URL key and is + backed by a unique index; ``extra="forbid"`` rejects any attempt to + send it. ``description`` accepts an explicit ``null`` (clear the + value); omitting it leaves it unchanged. + """ + + model_config = ConfigDict(extra="forbid") + + role_name: str | None = Field(default=None, min_length=1, max_length=100) + description: str | None = Field(default=None, max_length=500) + +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, + # 加法字段:POST/PATCH/GET 都自动带上 description,便于前端展示。 + "description": role.description, + } + +@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), + ) + +async def _apply_role_permission_set( + session: AsyncSession, role: Roles, codes: list[str] +) -> list[str]: + """Validate ``codes`` then replace the role's permission set wholesale. + + Shared by PATCH /roles/{role_code}/permissions and POST /roles so the + guard order is identical no matter the entry point: + + 1. Admin: must keep ``system:view`` → 409 (menu perms never gate + API access; auth keys off ``role_code == 'admin'``). + 2. Non-admin: ``system.*`` codes → 422. + 3. Unknown codes → 422. + 4. Diff-based soft-delete + insert — the ``(role_id, permission_id)`` + PK keeps soft-deleted rows, so delete-all/insert-all would + IntegrityError. Repeat-with-same-set is a no-op. + + Returns the role's final permission_codes (after flush). + """ + new_codes = list(dict.fromkeys(codes)) + + if role.role_code == "admin": + keeps_admin_entry = "system:view" in new_codes + if not keeps_admin_entry: + raise HTTPException( + status.HTTP_409_CONFLICT, + "admin 角色必须保留 system:view 权限", + ) + else: + # Menu permissions are a frontend-display signal only — backend + # authorization keeps keying off role_code == "admin". Letting a + # non-admin role hold system.* permissions would render the + # system-admin entry in the developer's UI while every + # /api/v1/platform/* call still returns 403. Reject with 422 so + # the failure is unambiguous about *what* the input violated. + leaked_system = [ + code for code in new_codes if code.startswith("system:") + ] + if leaked_system: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + f"非 admin 角色不能拥有 system.* 权限: {leaked_system}", + ) + + # 3. 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}", + ) + + # 4. 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() + return await _load_role_permission_codes(session, role.role_id) + +@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. + + The guard order and the diff-based write live in + :func:`_apply_role_permission_set`, shared with POST /roles so both + entry points validate identically. + """ + role = await _load_platform_role_by_code(session, role_code) + final_codes = await _apply_role_permission_set( + session, role, payload.permission_codes, + ) + return _envelope( + context.request_id, _role_payload(role, final_codes), + ) + +@router.post("/roles", status_code=status.HTTP_201_CREATED) +async def create_platform_role( + payload: RoleCreate, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Create a platform role. + + - ``role_code`` validated by the schema (reserved codes + pattern → + 422) and must not collide with ``uk_roles_code`` → 409 (incl. + soft-deleted rows that still occupy the index slot). + - Always ``role_scope='platform'``, ``is_builtin=0``. + - Optional ``permission_codes``: empty → no menu permissions; non-empty + applies the same guard order as PATCH via + :func:`_apply_role_permission_set`. + """ + role_code = payload.role_code + duplicate = await session.scalar( + select(Roles.role_id).where(Roles.role_code == role_code) + ) + if duplicate is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, f"role_code {role_code!r} 已存在", + ) + + role = Roles( + role_id=new_ulid(), + role_code=role_code, + role_name=payload.role_name.strip(), + role_scope="platform", + is_builtin=0, + description=payload.description, + ) + session.add(role) + await session.flush() + + final_codes: list[str] = [] + if payload.permission_codes: + final_codes = await _apply_role_permission_set( + session, role, payload.permission_codes, + ) + + await session.refresh(role) + return _envelope( + context.request_id, _role_payload(role, final_codes), + ) + +@router.patch("/roles/{role_code}") +async def update_platform_role( + role_code: str, + payload: RoleUpdate, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Update a platform role's ``role_name`` / ``description``. + + - ``role_code`` cannot change: it is the URL key (unique + ``uk_roles_code`` index) and the schema forbids sending it → 422. + - Built-in roles (``is_builtin=1``) keep ``role_name`` locked → 409; + ``description`` may still be edited. + - ``description`` accepts an explicit ``null`` (clear); omission + leaves it unchanged. + - ``updated_at`` refreshed by the DB's ``ON UPDATE + CURRENT_TIMESTAMP(3)`` server default. + """ + role = await _load_platform_role_by_code(session, role_code) + + if ( + role.is_builtin == 1 + and payload.role_name is not None + and payload.role_name != role.role_name + ): + raise HTTPException( + status.HTTP_409_CONFLICT, + "内置角色 role_name 不可改", + ) + + if payload.role_name is not None: + role.role_name = payload.role_name.strip() + # 显式传 null → 清空 description;字段省略 → 不改。 + # 用 model_fields_set 区分 "省略" 与 "显式 null",因为两者在 Pydantic + # 里都解析为 None。 + if "description" in payload.model_fields_set: + role.description = payload.description + + await session.flush() + await session.refresh(role) + codes = await _load_role_permission_codes(session, role.role_id) + return _envelope( + context.request_id, _role_payload(role, codes), + ) + +@router.delete("/roles/{role_code}") +async def delete_platform_role( + role_code: str, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Soft-delete a platform role. + + Guards: + - Built-in roles (``is_builtin=1``) → 409. + - Active ``users.platform_role_id`` reference → 409 (migrate users first). + - Active ``workspace_members.role_id`` reference → 409 (same ``Roles`` + row — a hard orphan would leave it dangling). + """ + role = await _load_platform_role_by_code(session, role_code) + + if role.is_builtin == 1: + raise HTTPException( + status.HTTP_409_CONFLICT, "内置角色不可删除", + ) + + user_refs = int( + await session.scalar( + select(func.count()) + .select_from(Users) + .where( + Users.platform_role_id == role.role_id, + Users.is_deleted == 0, + ) + ) + or 0 + ) + if user_refs > 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + f"仍有 {user_refs} 个用户引用此角色", + ) + + member_refs = int( + await session.scalar( + select(func.count()) + .select_from(WorkspaceMembers) + .where( + WorkspaceMembers.role_id == role.role_id, + WorkspaceMembers.is_deleted == 0, + ) + ) + or 0 + ) + if member_refs > 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + f"仍有 {member_refs} 个 workspace 成员引用此角色", + ) + + role.is_deleted = 1 + role.deleted_at = datetime.datetime.utcnow() + await session.flush() + return _envelope( + context.request_id, + {"role_code": role_code, "deleted": True}, + ) + +@router.get("/permissions") +async def list_platform_permissions( + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """List every active permission, ordered by module_code + permission_code. + + Returns ``[{permission_code, permission_name, module_code, + description}, ...]`` for the role-management UI. Includes + ``system:role:view`` which the admin role holds by default. + """ + rows = ( + await session.execute( + select( + Permissions.permission_code, + Permissions.permission_name, + Permissions.module_code, + Permissions.description, + ) + .where(Permissions.is_deleted == 0) + .order_by(Permissions.module_code, Permissions.permission_code) + ) + ).all() + payload = [ + { + "permission_code": code, + "permission_name": name, + "module_code": module, + "description": description, + } + for code, name, module, description in rows + ] + return _envelope( + context.request_id, payload, {"count": len(payload)}, + ) + diff --git a/backend/src/backend/api/platform/workspaces.py b/backend/src/backend/api/platform/workspaces.py new file mode 100644 index 0000000..5cda1d3 --- /dev/null +++ b/backend/src/backend/api/platform/workspaces.py @@ -0,0 +1,497 @@ +"""Workspace & membership CRUD endpoints. +Five workspace endpoints plus five membership endpoints, gated by +``system_admin_context`` (except ``GET .../members``, which also admits workspace +members). Last-admin guards and soft-delete cascades live here. +""" + +from __future__ import annotations + +import datetime +import re +from typing import Any, Literal + +from common.db.models import Roles, Users, WorkspaceMembers, Workspaces +from common.ids import new_ulid +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.dependencies import current_user, database_session +from backend.api.platform._deps import ( + SystemAdminContext, + _count_active_admins, + _envelope, + _is_system_admin, + _load_role_by_code, + system_admin_context, +) + +router = APIRouter(prefix="/api/v1/platform", tags=["platform"]) + +WORKSPACE_CODE_PATTERN = re.compile(r"^[a-z0-9-]{3,32}$") +LIST_PAGE_SIZE = 100 + +WORKSPACE_EDITABLE_STATUS = ("active", "archived") +MEMBER_ROLE_CODES = ("admin", "developer") +MEMBER_STATUS_VALUES = ("active", "disabled", "locked") + +# 创建工作区时前端提交的请求体;禁止未声明字段。 +class WorkspaceCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + workspace_code: str = Field(min_length=3, max_length=32) + workspace_name: str = Field(min_length=1, max_length=150) + quota_bytes: int = Field(default=0, ge=0) + description: str | None = Field(default=None, max_length=1000) + +# 编辑工作区时允许修改的字段;禁用操作必须走删除接口而不是直接传状态。 +class WorkspaceUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + workspace_name: str | None = Field(default=None, min_length=1, max_length=150) + quota_bytes: int | None = Field(default=None, ge=0) + description: str | None = Field(default=None, max_length=1000) + # 'disabled' is rejected here on purpose — soft delete must go through DELETE. + status: Literal["active", "archived"] | None = None + +class MemberCreate(BaseModel): + """Add a user to a workspace. Role is inherited from the user's + platform role (Users.platform_role_id) — not set here.""" + + model_config = ConfigDict(extra="forbid") + + user_id: str = Field(min_length=26, max_length=26) + +class MemberUpdate(BaseModel): + """Update a workspace membership's status. Role cannot be changed + via this endpoint — workspace role is always inherited from the + user's platform role. To change a member's role, PATCH + /platform/employees/{user_id} instead.""" + + model_config = ConfigDict(extra="forbid") + + member_status: Literal["active", "disabled", "locked"] | None = None + +def workspace_payload(workspace: Workspaces) -> dict[str, Any]: + return { + "workspace_id": workspace.workspace_id, + "workspace_code": workspace.workspace_code, + "workspace_name": workspace.workspace_name, + "active_root_uri": workspace.active_root_uri, + "quota_bytes": workspace.quota_bytes, + "status": workspace.status, + "description": workspace.description, + "created_by": workspace.created_by, + "created_at": workspace.created_at.isoformat(), + "updated_at": ( + workspace.updated_at.isoformat() if workspace.updated_at else None + ), + } + +def member_payload( + user: Users, + role: Roles, + membership: WorkspaceMembers, +) -> dict[str, Any]: + return { + "user_id": user.user_id, + "username": user.username, + "display_name": user.display_name, + "email": user.email, + "user_status": user.status, + "role_code": role.role_code, + "role_name": role.role_name, + "member_status": membership.member_status, + "joined_at": membership.joined_at.isoformat(), + } + +async def _load_workspace(session: AsyncSession, workspace_id: str) -> Workspaces: + workspace = await session.get(Workspaces, workspace_id) + if workspace is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "workspace 不存在") + return workspace + +@router.get("/workspaces") +async def list_workspaces( + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """List active/archived workspaces. Soft-deleted rows are filtered out. + + Silent ``pageSize=100`` cap — YAGNI on real pagination until needed. + """ + rows = ( + await session.execute( + select(Workspaces) + .where( + Workspaces.status != "disabled", + Workspaces.is_deleted == 0, + ) + .order_by(Workspaces.created_at, Workspaces.workspace_id) + .limit(LIST_PAGE_SIZE) + ) + ).scalars().all() + return _envelope( + context.request_id, + [workspace_payload(w) for w in rows], + {"count": len(rows), "page_size": LIST_PAGE_SIZE}, + ) + +@router.post("/workspaces", status_code=status.HTTP_201_CREATED) +async def create_workspace( + payload: WorkspaceCreate, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Create a workspace and auto-join the creator as an admin member.""" + if not WORKSPACE_CODE_PATTERN.fullmatch(payload.workspace_code): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "workspace_code 必须匹配 ^[a-z0-9-]{3,32}$", + ) + duplicate = await session.scalar( + select(Workspaces.workspace_id).where( + Workspaces.workspace_code == payload.workspace_code, + ) + ) + if duplicate is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "workspace_code 已存在") + + admin_role = await _load_role_by_code(session, "admin") + workspace_id = new_ulid() + workspace = Workspaces( + workspace_id=workspace_id, + workspace_code=payload.workspace_code, + workspace_name=payload.workspace_name, + active_root_uri=f"s3://workspaces/{workspace_id}/", + quota_bytes=payload.quota_bytes, + status="active", + created_by=context.user.user_id, + description=payload.description, + ) + session.add(workspace) + session.add( + WorkspaceMembers( + workspace_id=workspace_id, + user_id=context.user.user_id, + role_id=admin_role.role_id, + member_status="active", + ) + ) + await session.flush() + await session.refresh(workspace) + return _envelope(context.request_id, workspace_payload(workspace)) + +@router.get("/workspaces/{workspace_id}") +async def get_workspace( + workspace_id: str, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Fetch a single workspace — even soft-deleted ones are reachable.""" + workspace = await _load_workspace(session, workspace_id) + return _envelope(context.request_id, workspace_payload(workspace)) + +@router.patch("/workspaces/{workspace_id}") +async def update_workspace( + workspace_id: str, + payload: WorkspaceUpdate, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Patch editable workspace fields. ``status='disabled'`` is rejected.""" + workspace = await _load_workspace(session, workspace_id) + if workspace.status == "disabled": + raise HTTPException( + status.HTTP_409_CONFLICT, + "workspace 已删除,无法修改", + ) + if payload.workspace_name is not None: + workspace.workspace_name = payload.workspace_name.strip() + if payload.quota_bytes is not None: + workspace.quota_bytes = payload.quota_bytes + if payload.description is not None: + workspace.description = payload.description + if payload.status is not None: + workspace.status = payload.status + await session.flush() + await session.refresh(workspace) + return _envelope(context.request_id, workspace_payload(workspace)) + +@router.delete("/workspaces/{workspace_id}") +async def delete_workspace( + workspace_id: str, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Soft-delete a workspace and cascade-soft-delete its memberships. + + Allowed from any non-disabled status (active or archived). The + membership cascade is what lets system admins leave a workspace — + there is no per-member DELETE escape for self-removal. + """ + workspace = await _load_workspace(session, workspace_id) + if workspace.status == "disabled": + raise HTTPException( + status.HTTP_409_CONFLICT, + "workspace 已被删除", + ) + now = datetime.datetime.utcnow() + workspace.status = "disabled" + workspace.is_deleted = 1 + workspace.deleted_at = now + await session.execute( + update(WorkspaceMembers) + .where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.is_deleted == 0, + ) + .values(is_deleted=1, deleted_at=now) + ) + await session.flush() + await session.refresh(workspace) + return _envelope(context.request_id, workspace_payload(workspace)) + +@router.get("/workspaces/{workspace_id}/members") +async def list_members( + workspace_id: str, + request: Request, + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """List active and historical (non-soft-deleted) members of a workspace. + + Accessible to system admins (any workspace) and to active members of the + workspace itself. The script explorer calls this to seed the per-owner + directory-tree groups for non-admin users; visibility filters on the + scripts/data-resources endpoints still keep each peer's private content + hidden, so this only exposes membership (names), not private files. + """ + user = await current_user(request, session) + is_system_admin = await _is_system_admin(session, user) + if not is_system_admin: + membership = await session.scalar( + select(WorkspaceMembers).where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.user_id == user.user_id, + WorkspaceMembers.is_deleted == 0, + WorkspaceMembers.member_status == "active", + ) + ) + if membership is None: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "需要系统管理员或该工作区成员权限", + ) + await _load_workspace(session, workspace_id) + rows = ( + await session.execute( + select(Users, Roles, WorkspaceMembers) + .join( + WorkspaceMembers, + WorkspaceMembers.user_id == Users.user_id, + ) + .join(Roles, Roles.role_id == WorkspaceMembers.role_id) + .where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.is_deleted == 0, + ) + .order_by(WorkspaceMembers.joined_at, Users.user_id) + .limit(LIST_PAGE_SIZE) + ) + ).all() + request_id = request.headers.get("X-Request-ID") or new_ulid() + return _envelope( + request_id, + [member_payload(u, r, m) for u, r, m in rows], + {"count": len(rows), "page_size": LIST_PAGE_SIZE}, + ) + +@router.post( + "/workspaces/{workspace_id}/members", + status_code=status.HTTP_201_CREATED, +) +async def add_member( + workspace_id: str, + payload: MemberCreate, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Add a user to a workspace. The new row starts with member_status='active'. + + The role is inherited from the target user's ``platform_role_id``; + the request body does NOT take a ``role_code``. To change a member's + role, PATCH ``/api/v1/platform/employees/{user_id}`` instead. + """ + await _load_workspace(session, workspace_id) + user = await session.get(Users, payload.user_id) + if user is None or user.is_deleted != 0: + raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") + if user.status != "active": + raise HTTPException( + status.HTTP_409_CONFLICT, + f"用户状态为 {user.status},无法加入 workspace", + ) + if user.platform_role_id is None: + raise HTTPException( + status.HTTP_409_CONFLICT, + "目标用户尚未分配平台角色,无法加入 workspace;" + "请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code", + ) + role = await session.scalar( + select(Roles).where( + Roles.role_id == user.platform_role_id, + Roles.is_deleted == 0, + ) + ) + if role is None: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "用户的平台角色行不存在或已被删除", + ) + # ``WorkspaceMembers`` 的主键是 ``(workspace_id, user_id)`` 复合 PK, + # 而 ``remove_member`` / ``delete_platform_employee`` 都是软删除 (保留行, + # 仅置 ``is_deleted=1``). 因此这里必须按主键查整行,而不是只看活跃行: + # 否则软删行会被 active-duplicate 检查漏过,然后 INSERT 直接撞 PK. + existing = await session.scalar( + select(WorkspaceMembers).where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.user_id == payload.user_id, + ) + ) + if existing is not None: + if existing.is_deleted == 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + "用户已是该 workspace 成员;workspace 角色继承自平台角色," + "要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code", + ) + # 复活软删除行. 保留 ``joined_at`` 作为历史记录;``role_id`` 重新继承 + # 当前用户的平台角色 (用户在中间可能改过 platform_role);清掉 + # ``deleted_at`` 标记本轮已不在软删状态. + existing.is_deleted = 0 + existing.deleted_at = None + existing.role_id = role.role_id + existing.member_status = "active" + await session.flush() + await session.refresh(existing) + return _envelope( + context.request_id, member_payload(user, role, existing), + ) + membership = WorkspaceMembers( + workspace_id=workspace_id, + user_id=payload.user_id, + role_id=role.role_id, + member_status="active", + ) + session.add(membership) + await session.flush() + await session.refresh(membership) + return _envelope(context.request_id, member_payload(user, role, membership)) + +@router.patch("/workspaces/{workspace_id}/members/{user_id}") +async def update_member( + workspace_id: str, + user_id: str, + payload: MemberUpdate, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Update a workspace membership's status. Role is not editable here. + + Workspace role is always inherited from the user's platform role + (``Users.platform_role_id``). To change role, PATCH + ``/api/v1/platform/employees/{user_id}`` instead. + + Last-admin guard still applies to ``member_status`` changes: setting + the only active admin to ``disabled``/``locked`` would leave the + workspace without admin coverage. + """ + await _load_workspace(session, workspace_id) + row = ( + await session.execute( + select(Users, Roles, WorkspaceMembers) + .join( + WorkspaceMembers, + WorkspaceMembers.user_id == Users.user_id, + ) + .join(Roles, Roles.role_id == WorkspaceMembers.role_id) + .where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.user_id == user_id, + WorkspaceMembers.is_deleted == 0, + ) + ) + ).first() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在") + user, role, membership = row + + if payload.member_status is not None and payload.member_status != membership.member_status: + if ( + role.role_code == "admin" + and payload.member_status != "active" + ): + remaining = await _count_active_admins( + session, workspace_id, exclude_user_id=user_id, + ) + if remaining == 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + "workspace 必须保留至少一个 admin", + ) + membership.member_status = payload.member_status + + await session.flush() + await session.refresh(membership) + return _envelope(context.request_id, member_payload(user, role, membership)) + +@router.delete("/workspaces/{workspace_id}/members/{user_id}") +async def remove_member( + workspace_id: str, + user_id: str, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Soft-delete a workspace membership. + + System admins cannot remove themselves — the only escape is to delete + the entire workspace, which cascades membership soft-deletion. + """ + await _load_workspace(session, workspace_id) + if user_id == context.user.user_id: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace", + ) + row = ( + await session.execute( + select(Roles, WorkspaceMembers) + .join(Roles, Roles.role_id == WorkspaceMembers.role_id) + .where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.user_id == user_id, + WorkspaceMembers.is_deleted == 0, + ) + ) + ).first() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在") + role, membership = row + if role.role_code == "admin" and membership.member_status == "active": + remaining = await _count_active_admins( + session, workspace_id, exclude_user_id=user_id, + ) + if remaining == 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + "workspace 必须保留至少一个 admin", + ) + membership.is_deleted = 1 + membership.deleted_at = datetime.datetime.utcnow() + await session.flush() + return _envelope( + context.request_id, + {"workspace_id": workspace_id, "user_id": user_id, "removed": True}, + ) + diff --git a/migrations/versions/j4k5l6m7n8o9_add_system_role_view_permission.py b/migrations/versions/j4k5l6m7n8o9_add_system_role_view_permission.py new file mode 100644 index 0000000..cd094b3 --- /dev/null +++ b/migrations/versions/j4k5l6m7n8o9_add_system_role_view_permission.py @@ -0,0 +1,119 @@ +"""add system:role:view permission + grant it to admin + +Revision ID: j4k5l6m7n8o9 +Revises: e1f2a3b4c5d6 +Create Date: 2026-08-25 +""" + +import hashlib +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "j4k5l6m7n8o9" +down_revision: str | Sequence[str] | None = "e1f2a3b4c5d6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# ── seed constants ─────────────────────────────────────────────── +ADMIN_ROLE_ID = "0000000000000000000000000A" + +PERMISSION_CODE = "system:role:view" +PERMISSION_NAME = "查看角色管理" +MODULE_CODE = "system" +PERMISSION_DESCRIPTION = "查看角色管理与权限配置" + +CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +def _deterministic_permission_id(code: str) -> str: + """Stable 26-char ULID-shaped id derived from permission_code. + + Mirrors ``e1f2a3b4c5d6_rebuild_baseline._deterministic_permission_id`` + so the id for a given code matches what the baseline would derive. + """ + digest = hashlib.sha256( + f"model-platform-permission-v1:{code}".encode() + ).digest() + value = int.from_bytes(b"\x00" * 6 + digest[:10], byteorder="big") + encoded = ["0"] * 26 + for index in range(25, -1, -1): + encoded[index] = CROCKFORD_BASE32[value & 31] + value >>= 5 + return "".join(encoded) + +def upgrade() -> None: + """Add system:role:view permission and grant it to admin only.""" + permission_id = _deterministic_permission_id(PERMISSION_CODE) + + permissions_table = sa.table( + "permissions", + sa.column("permission_id", sa.CHAR(26)), + sa.column("permission_code", sa.String(128)), + sa.column("permission_name", sa.String(100)), + sa.column("module_code", sa.String(64)), + sa.column("description", sa.String(500)), + ) + op.bulk_insert( + permissions_table, + [ + { + "permission_id": permission_id, + "permission_code": PERMISSION_CODE, + "permission_name": PERMISSION_NAME, + "module_code": MODULE_CODE, + "description": PERMISSION_DESCRIPTION, + } + ], + ) + + # admin 持有全部 system:*(沿用基线约定);developer 不授予 system:*. + role_permissions_table = sa.table( + "role_permissions", + sa.column("role_id", sa.CHAR(26)), + sa.column("permission_id", sa.CHAR(26)), + ) + op.bulk_insert( + role_permissions_table, + [ + { + "role_id": ADMIN_ROLE_ID, + "permission_id": permission_id, + } + ], + ) + +def downgrade() -> None: + """Remove admin's system:role:view grant and soft-delete the permission.""" + permission_id = _deterministic_permission_id(PERMISSION_CODE) + + # 软删 role_permissions 关联行(与后端 soft-delete 约定一致). + role_permissions_table = sa.table( + "role_permissions", + sa.column("role_id", sa.CHAR(26)), + sa.column("permission_id", sa.CHAR(26)), + sa.column("is_deleted", sa.Integer), + sa.column("deleted_at", sa.DateTime), + ) + op.execute( + role_permissions_table.update() + .where(role_permissions_table.c.role_id == ADMIN_ROLE_ID) + .where(role_permissions_table.c.permission_id == permission_id) + .where(role_permissions_table.c.is_deleted == 0) + .values(is_deleted=1, deleted_at=sa.text("CURRENT_TIMESTAMP(3)")) + ) + + # 软删 permissions 行(基线表含 is_deleted,优先软删除). + permissions_table = sa.table( + "permissions", + sa.column("permission_id", sa.CHAR(26)), + sa.column("is_deleted", sa.Integer), + sa.column("deleted_at", sa.DateTime), + ) + op.execute( + permissions_table.update() + .where(permissions_table.c.permission_id == permission_id) + .where(permissions_table.c.is_deleted == 0) + .values(is_deleted=1, deleted_at=sa.text("CURRENT_TIMESTAMP(3)")) + )