update: auth

update auth api
This commit is contained in:
tao.chen
2026-08-07 14:41:20 +08:00
parent 984894d797
commit 2ee36b48ec
4 changed files with 88 additions and 68 deletions
+20 -13
View File
@@ -45,7 +45,8 @@
- 缺失或过期 → HTTP `401`
- 有效但用户不在 workspace → HTTP `403`(由 Nginx `auth_request` 透传给客户端)。
> `/api/v1/auth/me` 与 `/api/v1/auth/login` 响应中的 `data.user` 对象额外携带以下个字段:
> `/api/v1/auth/me` 与 `/api/v1/auth/login` 响应中的 `data.user` 对象额外携带以下个字段:
> - `role_code: string | null` —— 用户的**平台角色**(`users.platform_role_id` 指向的 Roles 行的 `role_code`),取值为 `admin` / `developer` / `null`(未分配)。**注意:本字段同时也是该用户在所有 workspace 中的角色**——workspace 角色始终继承自平台角色,本端点不再返回 workspace 级独立角色码。
> - `is_system_admin: bool` —— 派生自 `users.platform_role_id` 指向的角色 `role_code == 'admin'` 且用户状态为 `active`。前端据此决定是否渲染"系统管理"入口。
> - `permissions: string[]` —— 当前用户通过其平台角色(`platform_role_id`)间接持有的菜单权限码列表(`permission_code`),按字典序排列;未分配平台角色时为空数组。前端据此过滤菜单与 `<RequirePermission>` 路由守卫。**仅控制前端展示,不参与后端 endpoint 鉴权**——后端鉴权继续由 `system_admin_context`(`role_code == 'admin'`)与 workspace membership 负责。详见 §7.12-7.14。
@@ -512,7 +513,7 @@ Base 前缀 `/api/v1/admin`。
| `DELETE` | `/api/v1/platform/workspaces/{workspace_id}` | 软删 workspace;级联软删其成员 |
| `GET` | `/api/v1/platform/workspaces/{workspace_id}/members` | 列成员 |
| `POST` | `/api/v1/platform/workspaces/{workspace_id}/members` | 添加成员(返回 201) |
| `PATCH` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 改成员角色/状态 |
| `PATCH` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 改成员 `member_status`;**不能改 role_code**(workspace 角色继承自平台角色) |
| `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`(仅系统管理员) |
@@ -597,28 +598,31 @@ Base 前缀 `/api/v1/admin`。
添加成员。
> **Workspace 角色继承平台角色**:本端点不接受 `role_code`,新成员的 `workspace_members.role_id` 始终等于其 `users.platform_role_id` 指向的角色行。**不可填** `system_admin`(那是用户级身份,不是 workspace 角色)。要改某成员的 workspace 角色,请改 `users.platform_role_id`,即 `PATCH /api/v1/platform/employees/{user_id}`。
- **请求体字段**:
| 字段 | 类型 | 必填 | 限制 | 说明 |
|---|---|---|---|---|
| `user_id` | string | 是 | 26 字符 ULID | |
| `role_code` | string | 是 | `admin` \| `developer` | **不可填 `system_admin`**(那是用户级身份,不是 workspace 角色) |
- 服务端默认 `member_status='active'`。
- 用户不存在或已软删除 → 404;用户状态不是 `active` → 409;用户已是该 workspace 成员 → 409
- 新员工必须先通过 `POST /api/v1/platform/employees` 或现有 workspace 员工创建接口建立用户账号
- 用户不存在或已软删除 → 404;用户状态不是 `active` → 409;用户**尚未分配平台角色**(`users.platform_role_id IS NULL`)→ 409 "目标用户尚未分配平台角色,无法加入 workspace"
- 用户已是该 workspace 成员 → 409
- 新员工必须先通过 `POST /api/v1/platform/employees` 建立账号(可以同时传 `role_code=admin|developer`)。
### 7.6 `PATCH /api/v1/platform/workspaces/{workspace_id}/members/{user_id}`
修改成员的角色或状态。
修改成员的状态。**不能通过本端点修改 role_code**——workspace 角色始终继承自 `users.platform_role_id`;要改角色请 `PATCH /api/v1/platform/employees/{user_id}`
- **请求体字段**(全部可选):
| 字段 | 类型 | 限制 | 说明 |
|---|---|---|---|
| `role_code` | string | `admin` \| `developer` | 降级最后 admin → 409 |
| `member_status` | string | `active` \| `disabled` \| `locked` | 停用 / 锁定最后 admin → 409 |
- 提交 `role_code` 字段 → 422(Pydantic `extra='forbid'`),不是静默忽略。
### 7.7 `DELETE /api/v1/platform/workspaces/{workspace_id}/members/{user_id}`
软删除成员。
@@ -669,13 +673,15 @@ Base 前缀 `/api/v1/admin`。
| `display_name` | string | 是 | 1~100 字符 | 显示名称 |
| `email` | string | 否 | ≤255 字符 | 邮箱,全平台唯一 |
| `password` | string | 是 | 8~72 字符 | 登录密码 |
| `role_code` | string | 否 | `admin` \| `developer` | 平台角色;不传或 `null` 表示**不分配角色**(用户无法加入任何 workspace,见 §7.5) |
- 新用户状态固定为 `active`,且不分配平台角色
- 新用户状态固定为 `active`;`role_code` 决定 `users.platform_role_id` 指向 `role_code='admin'` / `'developer'` 的 Roles 行,未传则 `platform_role_id=NULL`
- 平台**仅**有 `admin` / `developer` 两个平台角色;不存在第三个角色枚举值。
- 密码使用 bcrypt 哈希保存;响应不包含 `password` 或 `password_hash`。
- 用户名或邮箱重复 → 409。
- 创建成功后,可调用 `POST /api/v1/platform/workspaces/{workspace_id}/members` 将用户加入指定 workspace。
- 用户名或邮箱重复 → 409;`role_code` 取值非法 → 422
- 创建成功后,可调用 `POST /api/v1/platform/workspaces/{workspace_id}/members` 将用户加入指定 workspace(会要求用户已有 `platform_role_id`,否则 409)
- **响应 201**:字段与 §7.8 的员工元素一致,其中 `role_code``role_name` 均为 `null`,`meta` 为空对象。
- **响应 201**:字段与 §7.8 的员工元素一致;当 `role_code` 传入时,`role_code` / `role_name` 反映 `platform_role_id`;未传入时均为 `null``meta` 为空对象。
### 7.10 `PATCH /api/v1/platform/employees/{user_id}`
@@ -691,11 +697,12 @@ Base 前缀 `/api/v1/admin`。
| `role_code` | string | `admin` \| `developer` | 同步设置 `users.platform_role_id`;`admin` 指向 `role_code='admin'` 的 Roles 行,`developer` 指向 `role_code='developer'` 的 Roles 行 |
- 禁止通过该端点修改:`username`、`password`、`password_hash`、`platform_role_id`;请求体中包含这些字段 → 422。
- `role_code` 只能取 `admin` / `developer`;不能通过本端点把 `platform_role_id` 置为 `null`。
- `role_code` 只能取 `admin` / `developer`;**不能通过本端点把 `platform_role_id` 置为 `null`**(降级为"无平台角色"须走单独的内部流程,前端不要尝试)
- `role_code` 改动会**立即影响**该用户在**所有** workspace 中的角色——因为 `workspace_members.role_id` 在 §7.5/§7.6 不再被端点改写,workspace 角色始终等于 `users.platform_role_id` 指向的 Roles 行。本端点是调整任何成员 workspace 角色的**唯一**入口。
- 自保护:
- 修改自身 `status` 为非 `active` → 409 "不能停用当前登录账号"。
- 修改自身 `role_code` 为 `developer`(即降级系统管理员身份)→ 409 "不能降级自身管理员角色"。
- 最后系统管理员保护:当目标用户当前为 active 系统管理员,本次变更会让其离开"active 系统管理员"集合(降级角色 / 停用账号)时,平台必须仍保留至少一名 active 系统管理员,否则 → 409 "platform 必须保留至少一个 active 系统管理员"。
- 最后系统管理员保护:当目标用户当前为 active 系统管理员,本次变更会让其离开"active 系统管理员"集合(降级角色 / 停用账号)时,平台必须仍保留至少一名 active 系统管理员,否则 → 409 "platform 必须保留至少一个 active 系统管理员"。**自保护在前、last-admin 计数在后**(参考 CLAUDE.md 工程笔记)。
- 目标用户不存在或已软删除 → 404 "用户不存在";`role_code` 对应的角色行不存在 → 422。
- **响应 200**:返回更新后的 `PlatformEmployeePayload`,`role_code` / `role_name` 反映最新的 `platform_role_id`。
+3 -1
View File
@@ -79,8 +79,10 @@ Hard-won lessons. Read the relevant bullet before touching the named area.
Lessons from splitting `frontend/app/features/platform/ModelPlatformApp.tsx` (1057 → 121 lines) into zustand stores + nested routes.
- **Barrel-file CSS imports vanish on refactor.** `features/admin/AdminPages.tsx` was a barrel that side-effect-imported `admin.css` + `dashboard.css`. When route files started importing `DashboardPage` / `SystemAdminPage` directly, the CSS disappeared silently. Fix: each page component imports its own CSS at the top — `DashboardPage` needs **both** `admin.css` (`.dashboard-page`, `.dashboard-hero`, `.dashboard-metrics`, `.dashboard-actions`) **and** `dashboard.css` (`.dashboard-grid`). `UserManagementPage` / `ProjectManagementPage` / `SystemAdminPage` only need `admin.css`. Don't put CSS imports in route files; let the components own their styles. `SchedulePage` already follows this pattern with `schedule.css`.
- **Module-level zustand store + `bindApi(api)`** for auth-dependent APIs. Keep a module-level `_api` ref; expose `bindScriptWorkspaceApi(api)`; layout calls it in `useEffect([api])`. Actions read `_api` internally — no api param on every call. `bindScriptWorkspaceApi(null)` in cleanup avoids stale refs on logout.
- **Module-level zustand store + `bindApi(api)`** for auth-dependent APIs. Keep a module-level `_api` ref; expose `bindScriptWorkspaceApi(api)`; layout calls it in **render body** (not `useEffect([api])`). Actions read `_api` internally — no api param on every call. Pair the render-body bind with a separate empty-deps `useEffect(() => () => bindScriptWorkspaceApi(null), [])` for unmount cleanup only.
- **Why render body, not `useEffect([api])`:** React effect order on deps change is *parent cleanup → child effect → parent effect*. With `useEffect([api])`, the parent's cleanup wipes `_api` to `null` *before* child effects (e.g. `ScriptsPage`'s `useEffect([workspaceId])` calling `load()`) run, producing the `script workspace API 未绑定` race whenever `currentWorkspace.workspace_id` changes. Render-body binding runs synchronously during the parent's render, which happens before the child's render and effects, so `_api` is always current by the time child code touches the store.
- **Lifecycle hooks belong in the layout, not in route components.** Heartbeats (active edit session + cached sessions), the 10-min cleanup timer, and `beforeunload` lock-release must mount at the layout level — navigating to `/schedules` otherwise unmounts them and cached locks expire. Pattern: store exposes `tickHeartbeats()` / `tickCleanup()` / `releaseActiveOnUnload()`; the layout hook just owns the `setInterval` and `addEventListener`.
- **Route components with their own internal state must remount on workspace/user change.** `SchedulesPage` / `SystemAdminPage` / `UserManagementPage` / `ProjectManagementPage` keep `useState` for fetched data (`schedules`, `artifacts`, `selectedSchedule`, employees, projects). When `currentWorkspace` or `user` changes, the `api` reference updates but the cached state does not — the UI shows the previous workspace's data. Pattern: route wrappers set `key={\`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}\`}` on the page component to force React to unmount and remount, resetting all internal state and re-running `useEffect` data fetches. `ScriptsPage` doesn't need this — its store-backed state is reset via `scriptWorkspaceStore.reset()` on workspace change.
- **`{ current: T | null }` module-level handle for non-subscribing consumers.** Sidebar reads "is there an active edit session?" without subscribing to the store — expose a plain `{ current: ... }` object at module scope and update it synchronously inside the store's `setEditSession` action.
- **zustand `StateCreator` enforces declared action signatures.** Declaring `loadLatestVersion: () => Promise<void>` while accidentally returning a cleanup function from the implementation makes `tsc` reject the whole store with TS2345. Match the declared type exactly.
- **Nested routes in React Router v8.** Use `route("", "layout.tsx", [route("x", "x.tsx"), ...])` from `@react-router/dev/routes`. URLs stay flat; the layout renders `<Outlet />`. Don't use `route("*", ...)` as a wildcard — it skips the nested children config.
+16 -16
View File
@@ -163,17 +163,17 @@ async def login(
if default_workspace_id is None:
default_workspace_id = workspace.workspace_id
# Pick a default role_code for the user payload: prefer admin if
# the user has it in any workspace, otherwise use the first one
# returned. This is only for UI greeting; access control checks
# run on a per-request basis via the chosen workspace_id.
# Workspace role is always inherited from the user's platform role
# (``Users.platform_role_id``); the iteration over rows above was
# a legacy way to find the "highest" workspace role and is no
# longer correct now that admin/developer are platform-only.
user_role_code: str | None = None
for workspace, role, _ in rows:
if role.role_code == "admin":
user_role_code = "admin"
break
if user_role_code is None:
user_role_code = rows[0][1].role_code
if user.platform_role_id is not None:
platform_role_row = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
if platform_role_row is not None:
user_role_code = platform_role_row.role_code
token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS)
_set_session_cookie(request, response, token)
@@ -256,12 +256,12 @@ async def me(
workspaces = [_workspace_payload(ws, role) for ws, role, _ in rows]
default_workspace_id = workspaces[0]["workspace_id"] if workspaces else None
user_role_code: str | None = None
for _ws, role, _ in rows:
if role.role_code == "admin":
user_role_code = "admin"
break
if user_role_code is None and rows:
user_role_code = rows[0][1].role_code
if user.platform_role_id is not None:
platform_role_row = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
if platform_role_row is not None:
user_role_code = platform_role_row.role_code
is_system_admin = await resolve_is_system_admin(session, user)
permissions = await load_user_permissions(session, user)
+49 -38
View File
@@ -109,16 +109,22 @@ class WorkspaceUpdate(BaseModel):
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)
role_code: Literal["admin", "developer"]
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")
role_code: Literal["admin", "developer"] | None = None
member_status: Literal["active", "disabled", "locked"] | None = None
@@ -129,6 +135,7 @@ class PlatformEmployeeCreate(BaseModel):
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):
@@ -387,6 +394,10 @@ async def create_platform_employee(
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,
@@ -394,12 +405,15 @@ async def create_platform_employee(
email=payload.email.strip() if payload.email else None,
password_hash=hash_password(payload.password),
status="active",
platform_role_id=None,
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, None))
return _envelope(
context.request_id,
platform_employee_payload(user, new_role),
)
@router.patch("/employees/{user_id}")
@@ -724,7 +738,12 @@ async def add_member(
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'."""
"""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:
@@ -734,7 +753,20 @@ async def add_member(
status.HTTP_409_CONFLICT,
f"用户状态为 {user.status},无法加入 workspace",
)
role = await _load_role_by_code(session, payload.role_code)
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)
)
if role is None:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"用户的平台角色行不存在",
)
duplicate = await session.scalar(
select(WorkspaceMembers.user_id).where(
WorkspaceMembers.workspace_id == workspace_id,
@@ -747,12 +779,6 @@ async def add_member(
status.HTTP_409_CONFLICT,
"用户已是该 workspace 成员",
)
# 不变量: 给用户授予 workspace admin 时同步设置 platform_role_id,
# 否则前端 role_code === "admin" 与后端 is_system_admin 会给出
# 不同的结论。降级路径不在此处处理(用户可能在其他 workspace
# 仍是 admin), 升级路径必须在此处理。
if role.role_code == "admin" and user.platform_role_id is None:
user.platform_role_id = role.role_id
membership = WorkspaceMembers(
workspace_id=workspace_id,
user_id=payload.user_id,
@@ -773,7 +799,16 @@ async def update_member(
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Update a member's role and/or status. Last-admin guard applies."""
"""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(
@@ -794,30 +829,6 @@ async def update_member(
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
user, role, membership = row
next_role = role
if payload.role_code is not None and payload.role_code != role.role_code:
if (
role.role_code == "admin"
and payload.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",
)
next_role = await _load_role_by_code(session, payload.role_code)
membership.role_id = next_role.role_id
# 不变量: 升级到 workspace admin 时强制覆盖 platform_role_id。
# (用户可能之前是 developer, platform_role_id 指向 developer role,
# 现在变成 admin 必须提升到 admin role。) 降级路径不动 (用户
# 可能在其他 workspace 仍是 admin, 端点看不到全局)。
if next_role.role_code == "admin":
user.platform_role_id = next_role.role_id
if payload.member_status is not None and payload.member_status != membership.member_status:
if (
role.role_code == "admin"
@@ -835,7 +846,7 @@ async def update_member(
await session.flush()
await session.refresh(membership)
return _envelope(context.request_id, member_payload(user, next_role, membership))
return _envelope(context.request_id, member_payload(user, role, membership))
@router.delete("/workspaces/{workspace_id}/members/{user_id}")