diff --git a/API.md b/API.md index 9afa626..9ec2bfb 100644 --- a/API.md +++ b/API.md @@ -645,7 +645,7 @@ Base 前缀 `/api/v1/admin`。 | `DELETE` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 软删成员 | | `GET` | `/api/v1/platform/roles` | 列全部 `role_scope='platform'` 的角色及其 `permission_codes`(仅系统管理员) | | `GET` | `/api/v1/platform/roles/{role_code}/permissions` | 单个平台角色的 `permission_codes`(仅系统管理员) | -| `PATCH` | `/api/v1/platform/roles/{role_code}/permissions` | 整体替换角色权限集合;对 `admin` 角色保留 `system.view` + `system.manage` 的最后系统管理员保护(仅系统管理员) | +| `PATCH` | `/api/v1/platform/roles/{role_code}/permissions` | 整体替换角色权限集合;对 `admin` 角色强制保留 `system:view` 的最后系统管理员保护(仅系统管理员) | > **不变量**: > - 每个 workspace 必须始终保留至少一个 `admin` 角色的活跃成员;对最后 admin 做降级 / 停用 / 删除 → 409。 @@ -883,11 +883,8 @@ Base 前缀 `/api/v1/admin`。 "role_name": "管理员", "is_builtin": true, "permission_codes": [ - "dashboard.view", "experiment.all", "experiment.own", - "resource.personal", "resource.public.manage", - "resource.public.upload", "schedule.all", "schedule.own", - "script.build", "script.public.manage", "system.manage", - "system.view" + "dashboard:view", "schedule:view", "script:view", + "system:project:view", "system:user:view", "system:view" ] }, { @@ -896,8 +893,7 @@ Base 前缀 `/api/v1/admin`。 "role_name": "开发人员", "is_builtin": true, "permission_codes": [ - "dashboard.view", "experiment.own", "resource.personal", - "schedule.own", "script.build", "script.public.manage" + "dashboard:view", "schedule:view", "script:view" ] } ], @@ -923,11 +919,8 @@ Base 前缀 `/api/v1/admin`。 "role_name": "管理员", "is_builtin": true, "permission_codes": [ - "dashboard.view", "experiment.all", "experiment.own", - "resource.personal", "resource.public.manage", - "resource.public.upload", "schedule.all", "schedule.own", - "script.build", "script.public.manage", "system.manage", - "system.view" + "dashboard:view", "schedule:view", "script:view", + "system:project:view", "system:user:view", "system:view" ] }, "meta": {} @@ -948,8 +941,8 @@ Base 前缀 `/api/v1/admin`。 - **守卫顺序(load-bearing,不可调换)**: 1. 角色不存在 / `role_scope != 'platform'` → 404。 - 2. **`admin` 角色**:提交的 `permission_codes` 必须同时包含 `system.view` 与 `system.manage`,否则 → 409 "admin 角色必须保留 system.view 与 system.manage 权限"。 - 3. **非 `admin` 角色**:`permission_codes` 中**禁止**含 `system.*` 项 → 422 "非 admin 角色不能拥有 system.* 权限: [...]"。menu permission 只控前端展示,后端 `/api/v1/platform/*` 鉴权仍按 `role_code == "admin"`;若让 developer 拿到 `system.manage`,前端会渲染"系统管理"入口但所有 platform API 调用 403,UX 割裂。 + 2. **`admin` 角色**:提交的 `permission_codes` 必须包含 `system:view`,否则 → 409 "admin 角色必须保留 system:view 权限"。 + 3. **非 `admin` 角色**:`permission_codes` 中**禁止**含 `system:*` 项 → 422 "非 admin 角色不能拥有 system:* 权限: [...]"。menu permission 只控前端展示,后端 `/api/v1/platform/*` 鉴权仍按 `role_code == "admin"`;若让 developer 拿到 `system:view`,前端会渲染"系统管理"入口但所有 platform API 调用 403,UX 割裂。 4. 任意 `permission_code` 不在 `permissions` 表活跃行中 → 422 "未知的 permission_code: [...]"。 5. 写入策略:diff-based —— 只 soft-delete `current \ new` 的关联,只 INSERT `new \ current` 的关联。重复提交同 payload 是 no-op;包含原有 codes 的 patch 不会触发 `(role_id, permission_id)` 主键冲突。**严禁**先全量 soft-delete 再全量 INSERT(会 `IntegrityError`,因为软删行仍占主键 slot)。 - 写入后,响应 `data.permission_codes` 为本次写入后的活跃集合,与再次 `GET §7.13` 完全一致。 @@ -963,32 +956,26 @@ Base 前缀 `/api/v1/admin`。 "role_code": "admin", "role_name": "管理员", "is_builtin": true, - "permission_codes": ["dashboard.view", "system.manage", "system.view"] + "permission_codes": ["dashboard:view", "system:view"] }, "meta": {} } ``` -- **当前 seed 的 permission_code 全集**(来自迁移 `f6a7b8c9d0e1`,与 `migrations/data/migrate_system_json.py::PERMISSION_NAMES` 真值对齐,不要在客户端另造一份): +- **当前 seed 的 permission_code 全集**(由 baseline `e1f2a3b4c5d6_rebuild_baseline` 直接写入 MySQL,不要在客户端另造一份): | `permission_code` | `module_code` | admin | developer | |---|---|:-:|:-:| -| `dashboard.view` | dashboard | ✓ | ✓ | -| `script.build` | script | ✓ | ✓ | -| `script.public.manage` | script | ✓ | ✓ | -| `schedule.own` | schedule | ✓ | ✓ | -| `schedule.all` | schedule | ✓ | | -| `experiment.own` | experiment | ✓ | ✓ | -| `experiment.all` | experiment | ✓ | | -| `resource.personal` | resource | ✓ | ✓ | -| `resource.public.upload` | resource | ✓ | | -| `resource.public.manage` | resource | ✓ | | -| `system.view` | system | ✓ | | -| `system.manage` | system | ✓ | | +| `dashboard:view` | dashboard | ✓ | ✓ | +| `script:view` | script | ✓ | ✓ | +| `schedule:view` | schedule | ✓ | ✓ | +| `system:view` | system | ✓ | | +| `system:user:view` | system | ✓ | | +| `system:project:view` | system | ✓ | | > **与现有 7.x 端点的语义差异**(避免 reviewer 误读): > - 本端点不修改 `users.platform_role_id`,只调整 `role_permissions` 关联表。 -> - "清空 developer 的全部权限"是合法操作;只有 `admin` 受 `system.*` 强制约束。 +> - "清空 developer 的全部权限"是合法操作;只有 `admin` 受 `system:*` 强制约束。 > - `role_code` 不是 `permission_code`,前端不要用前者去判断菜单可见性。 --- diff --git a/backend/src/backend/api/platform.py b/backend/src/backend/api/platform.py index 7b865ec..f251985 100644 --- a/backend/src/backend/api/platform.py +++ b/backend/src/backend/api/platform.py @@ -58,7 +58,7 @@ Invariants * ``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`` + ``system.manage`` +* 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'``. @@ -1185,7 +1185,7 @@ async def patch_role_permissions( 1. Load the role. Reject 404 if it is missing or not platform-scoped. 2. Admin role: the patched ``permission_codes`` MUST still include - both ``system.view`` and ``system.manage``. Otherwise every + ``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; @@ -1207,12 +1207,12 @@ async def patch_role_permissions( if role.role_code == "admin": keeps_admin_entry = ( - "system.view" in new_codes and "system.manage" in new_codes + "system:view" in new_codes ) if not keeps_admin_entry: raise HTTPException( status.HTTP_409_CONFLICT, - "admin 角色必须保留 system.view 与 system.manage 权限", + "admin 角色必须保留 system:view 权限", ) else: # Menu permissions are a frontend-display signal only — backend @@ -1222,7 +1222,7 @@ async def patch_role_permissions( # /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.") + code for code in new_codes if code.startswith("system:") ] if leaked_system: raise HTTPException( diff --git a/frontend/app/context/AuthContext.tsx b/frontend/app/context/AuthContext.tsx index e4f95e9..f440891 100644 --- a/frontend/app/context/AuthContext.tsx +++ b/frontend/app/context/AuthContext.tsx @@ -20,6 +20,7 @@ export type AuthUser = { status: string; role_code: string | null; is_system_admin: boolean; + permissions: string[]; }; export type AuthWorkspace = { @@ -312,3 +313,8 @@ export function useApi(): WorkspaceBoundApi { deleteWorkspaceMember: (workspaceId, userId) => rawApi.deleteWorkspaceMember(workspaceId, userId), }), [workspaceId]); } + +export function usePermission(code: string): boolean { + const { user } = useAuth(); + return user?.permissions.includes(code) ?? false; +} diff --git a/frontend/app/features/admin/DashboardPage.tsx b/frontend/app/features/admin/DashboardPage.tsx index f9c4794..7ef7afe 100644 --- a/frontend/app/features/admin/DashboardPage.tsx +++ b/frontend/app/features/admin/DashboardPage.tsx @@ -1,5 +1,5 @@ import Icon from "../../components/common/Icon"; -import { useAuth } from "../../context/AuthContext"; +import { useAuth, usePermission } from "../../context/AuthContext"; const TREND_VALUES = [38, 55, 44, 73, 61, 86, 78] as const; const TREND_LABELS = ["周一", "周二", "周三", "周四", "周五", "周六", "今天"] as const; @@ -25,7 +25,7 @@ export function DashboardPage({ onNavigate: (page: "scripts" | "schedules" | "system") => void; }) { const { user, currentWorkspace } = useAuth(); - const isSystemAdmin = user?.is_system_admin ?? false; + const hasSystemView = usePermission("system:view"); const notebookCount = Math.max(1, Math.round(scriptCount * 0.67)); const pythonCount = Math.max(0, scriptCount - Math.round(scriptCount * 0.67)); @@ -92,7 +92,7 @@ export function DashboardPage({ 调度配置 - {isSystemAdmin && ( + {hasSystemView && (