baa-conductor

git clone 

commit
d775748
parent
1f58069
author
im_wower
date
2026-03-21 21:37:39 +0800 CST
Merge branch 'feat/T-012-auth-model'
8 files changed,  +885, -34
M coordination/tasks/T-012-auth-model.md
+23, -7
 1@@ -1,7 +1,7 @@
 2 ---
 3 task_id: T-012
 4 title: 鉴权与 Token 模型
 5-status: todo
 6+status: review
 7 branch: feat/T-012-auth-model
 8 repo: /Users/george/code/baa-conductor
 9 base_ref: main
10@@ -10,7 +10,7 @@ depends_on:
11 write_scope:
12   - packages/auth/**
13   - docs/auth/**
14-updated_at: 2026-03-21
15+updated_at: 2026-03-21T19:41:56+0800
16 ---
17 
18 # T-012 鉴权与 Token 模型
19@@ -50,23 +50,39 @@ updated_at: 2026-03-21
20 
21 ## files_changed
22 
23-- 待填写
24+- `coordination/tasks/T-012-auth-model.md`
25+- `docs/auth/README.md`
26+- `packages/auth/package.json`
27+- `packages/auth/src/index.ts`
28+- `packages/auth/src/actions.ts`
29+- `packages/auth/src/model.ts`
30+- `packages/auth/src/policy.ts`
31+- `packages/auth/src/control-api.ts`
32 
33 ## commands_run
34 
35-- 待填写
36+- `git switch -c feat/T-012-auth-model main`
37+- `pnpm --filter @baa-conductor/auth typecheck` (`pnpm` 不在当前环境 PATH 中,未执行成功)
38+- `./node_modules/.bin/tsc --noEmit -p /Users/george/code/baa-conductor/packages/auth/tsconfig.json`
39 
40 ## result
41 
42-- 待填写
43+- 明确了 `controller`、`worker`、`browser_admin`、`ops_admin`、`readonly` 的角色边界与允许动作
44+- 定义了 `service_hmac`、`service_signed`、`browser_session`、`ops_session` 四类 token 模型与 principal 形状
45+- 在 `packages/auth` 下建立了 action、token/principal、授权策略、Control API route 授权映射四层骨架
46+- 在 `docs/auth/README.md` 中整理了权限矩阵、资源归属规则和 `T-003` 接入方式
47 
48 ## risks
49 
50-- 待填写
51+- 还没有真实 token 签发、签名校验与密钥轮换实现
52+- 资源归属目前只约束到 `controllerId` / `workerId`,后续可能需要细化到 `run_id` / `step_id`
53+- `control-api-worker` 还未接入本包,`T-003` 仍需补 verifier 注入和 DB 归属校验
54 
55 ## next_handoff
56 
57-- 给 control API 实现与部署配置使用
58+- `T-003` 先把 `@baa-conductor/auth` 作为 workspace 依赖接入,并复用 `CONTROL_API_AUTH_RULES` 与 `authorizeControlApiRoute(...)`
59+- step 写接口在鉴权通过后继续结合数据库中的 `assigned_worker_id` 做最终归属校验
60+- controller 写接口在鉴权通过后继续结合 leader term、owner 字段做冲突与幂等检查
61 
62 ## notes
63 
M docs/auth/README.md
+150, -3
  1@@ -1,6 +1,153 @@
  2-# auth
  3+# Auth Model
  4 
  5-这个目录预留给鉴权与 token 模型文档。
  6+`T-012` 的目标不是直接把鉴权做成生产实现,而是先把 Control API 周围的角色、Token 形式和授权边界压成一套可复用的模型,供后续 `T-003` 接入。
  7 
  8-当前只建立目录边界,具体内容由 `T-012` 完成。
  9+## 设计原则
 10 
 11+- 所有 Control API 请求统一走 `Authorization: Bearer <token>`
 12+- 先做最小可落地模型:机器侧 token 与人类/运维会话 token 分开
 13+- 写接口不仅校验角色,还校验资源归属
 14+- `browser_admin` 和 `ops_admin` 必须分权,不能共用同一组维护能力
 15+- `readonly` 必须只能看状态,不能借读权限侧写成写权限
 16+
 17+## 角色边界
 18+
 19+| Role | 代表对象 | 推荐 Token | 明确允许 | 明确禁止 |
 20+| --- | --- | --- | --- | --- |
 21+| `controller` | leader/standby conductor 节点 | `service_hmac`,后续可升级为 `service_signed` | `controllers.heartbeat`、`leader.acquire`、`tasks.plan`、`tasks.claim` | `system.pause`、`system.resume`、`system.drain`、任何维护操作、任何 step 执行回写 |
 22+| `worker` | 运行 step 的 Codex worker | `service_hmac`,后续可升级为 `service_signed` | `steps.heartbeat`、`steps.checkpoint`、`steps.complete`、`steps.fail` | claim task、lease、task 创建、系统级操作、维护操作 |
 23+| `browser_admin` | 可见 Claude `control` 会话 / 浏览器管理面板 | `browser_session` | `tasks.create`、`system.pause`、`system.resume`、`system.drain`、状态/日志查看 | lease、claim、step 执行回写、主备切换 |
 24+| `ops_admin` | 运维维护入口 | `ops_session` | `maintenance.promote`、`maintenance.demote`、状态/日志查看 | task 创建、普通调度、step 执行回写、浏览器控制按钮 |
 25+| `readonly` | 状态面板、观察者、只读审计入口 | `browser_session` | `system.state.read`、`tasks.read`、`tasks.logs.read`、`runs.read` | 任何写接口 |
 26+
 27+其中 `tasks.create -> browser_admin` 的划分来自设计文档的 task 创建流程:可见 `control` 会话通过 Control API 发起 task。
 28+
 29+## Token 模型
 30+
 31+当前代码把 Token 形式整理成四类:
 32+
 33+| Token Kind | 状态 | 角色 | 用途 |
 34+| --- | --- | --- | --- |
 35+| `service_hmac` | 当前可用 | `controller`、`worker` | 当前最低要求。使用共享密钥或 HMAC 签名材料做机器间鉴权 |
 36+| `service_signed` | 未来演进 | `controller`、`worker` | 后续把 role、scope、节点身份变成可签名 claim |
 37+| `browser_session` | 当前可用 | `browser_admin`、`readonly` | 浏览器侧 bearer token,承载控制面板和只读面板 |
 38+| `ops_session` | 当前可用 | `ops_admin` | 与浏览器控制面板分离的运维 token |
 39+
 40+统一 claim 形状建议包含:
 41+
 42+- `subject`
 43+- `role`
 44+- `tokenKind`
 45+- `audience = "control_api"`
 46+- `tokenId`
 47+- `issuedAt`
 48+- `expiresAt`
 49+- `scope`
 50+- `controllerId`
 51+- `workerId`
 52+- `nodeId`
 53+- `sessionId`
 54+
 55+MVP 阶段并不要求四类 token 都立即有完整签发器;`packages/auth` 先把这些字段和验证接口占好位。
 56+
 57+## 资源归属规则
 58+
 59+对写接口,角色校验之外还要做归属校验:
 60+
 61+- `controller` 请求必须带 `controllerId`
 62+- `worker` 请求必须带 `workerId`
 63+- 如果目标资源上已经有 `controllerId` 或 `workerId`,则必须与 token 中的身份一致
 64+- `browser_admin`、`ops_admin`、`readonly` 当前阶段不做资源级身份绑定,只按角色与 token 种类分权
 65+
 66+这意味着:
 67+
 68+- `controller` 不能代替其他 `controller` 续租或持久化对方拥有的对象
 69+- `worker` 不能替其他 `worker` 回写 step 心跳、checkpoint、complete/fail
 70+
 71+## Control API 权限矩阵
 72+
 73+| Route | Action | Allowed Roles | Binding |
 74+| --- | --- | --- | --- |
 75+| `POST /v1/controllers/heartbeat` | `controllers.heartbeat` | `controller` | `controller` |
 76+| `POST /v1/leader/acquire` | `leader.acquire` | `controller` | `controller` |
 77+| `POST /v1/tasks` | `tasks.create` | `browser_admin` | none |
 78+| `POST /v1/tasks/claim` | `tasks.claim` | `controller` | `controller` |
 79+| `POST /v1/tasks/:task_id/plan` | `tasks.plan` | `controller` | `controller` |
 80+| `POST /v1/steps/:step_id/heartbeat` | `steps.heartbeat` | `worker` | `worker` |
 81+| `POST /v1/steps/:step_id/checkpoint` | `steps.checkpoint` | `worker` | `worker` |
 82+| `POST /v1/steps/:step_id/complete` | `steps.complete` | `worker` | `worker` |
 83+| `POST /v1/steps/:step_id/fail` | `steps.fail` | `worker` | `worker` |
 84+| `POST /v1/system/pause` | `system.pause` | `browser_admin` | none |
 85+| `POST /v1/system/resume` | `system.resume` | `browser_admin` | none |
 86+| `POST /v1/system/drain` | `system.drain` | `browser_admin` | none |
 87+| `POST /v1/system/promote` | `maintenance.promote` | `ops_admin` | none |
 88+| `POST /v1/system/demote` | `maintenance.demote` | `ops_admin` | none |
 89+| `GET /v1/system/state` | `system.state.read` | `browser_admin`、`ops_admin`、`readonly` | none |
 90+| `GET /v1/tasks/:task_id` | `tasks.read` | `browser_admin`、`ops_admin`、`readonly` | none |
 91+| `GET /v1/tasks/:task_id/logs` | `tasks.logs.read` | `browser_admin`、`ops_admin`、`readonly` | none |
 92+| `GET /v1/runs/:run_id` | `runs.read` | `browser_admin`、`ops_admin`、`readonly` | none |
 93+
 94+`promote` / `demote` 还没有落到 `control-api-worker` 代码里,但鉴权模型先把它们单独列出来,避免后续把维护入口塞进 `browser_admin`。
 95+
 96+## `packages/auth` 提供的骨架
 97+
 98+`packages/auth/src` 现在拆成四层:
 99+
100+- `actions.ts`
101+  - 定义统一 action 名称与读写/资源绑定属性
102+- `model.ts`
103+  - 定义角色、principal、token 形状、bearer 解析和 token verifier 接口
104+- `policy.ts`
105+  - 定义角色边界、权限矩阵以及 `authorize(...)`
106+- `control-api.ts`
107+  - 定义 route 到 action 的映射,以及 `authorizeControlApiRoute(...)`
108+
109+这让后续接入方可以先做三段式流程:
110+
111+1. 从 `Authorization` 头提取 bearer token
112+2. 用自己的 verifier 还原 `AuthPrincipal`
113+3. 按路由和资源归属调用 `authorizeControlApiRoute(...)`
114+
115+示意代码:
116+
117+```ts
118+import {
119+  authorizeControlApiRoute,
120+  extractBearerToken,
121+  type AuthPrincipal
122+} from "@baa-conductor/auth";
123+
124+async function guardControlApiRequest(
125+  method: "GET" | "POST",
126+  path: string,
127+  authorizationHeader: string | undefined,
128+  principal: AuthPrincipal,
129+  resource?: { controllerId?: string; workerId?: string }
130+) {
131+  const tokenResult = extractBearerToken(authorizationHeader);
132+
133+  if (!tokenResult.ok) {
134+    return { ok: false, statusCode: 401, reason: tokenResult.reason };
135+  }
136+
137+  return authorizeControlApiRoute({
138+    method,
139+    path,
140+    principal,
141+    resource
142+  });
143+}
144+```
145+
146+## 对 `T-003` 的直接交接点
147+
148+- 直接复用 `CONTROL_API_AUTH_RULES`,避免在 Worker 里再写一份路由权限表
149+- 先把 verifier 作为注入接口实现,不要把签名校验和业务 handler 混在一起
150+- 对 step 相关写接口,鉴权之后再结合 DB 中的 `assigned_worker_id` 做最终归属比对
151+- 对 controller 相关写接口,鉴权之后再结合 leader term / owner 字段做冲突校验
152+
153+## 当前风险
154+
155+- `service_hmac` 只是模型约定,密钥轮换策略还未实现
156+- 浏览器侧和运维侧 token 的签发、撤销、持久化方式还没落地
157+- 资源归属检查目前只到 `controllerId` / `workerId` 这一层,后续可能还要细化到 `run_id` 或 `step_id`
M packages/auth/package.json
+4, -0
 1@@ -2,6 +2,10 @@
 2   "name": "@baa-conductor/auth",
 3   "private": true,
 4   "type": "module",
 5+  "exports": {
 6+    ".": "./src/index.ts"
 7+  },
 8+  "types": "./src/index.ts",
 9   "scripts": {
10     "build": "pnpm exec tsc --noEmit -p tsconfig.json",
11     "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
A packages/auth/src/actions.ts
+127, -0
  1@@ -0,0 +1,127 @@
  2+export type AuthResourceBinding = "none" | "controller" | "worker";
  3+
  4+export const AUTH_ACTIONS = [
  5+  "controllers.heartbeat",
  6+  "leader.acquire",
  7+  "tasks.create",
  8+  "tasks.plan",
  9+  "tasks.claim",
 10+  "steps.heartbeat",
 11+  "steps.checkpoint",
 12+  "steps.complete",
 13+  "steps.fail",
 14+  "system.pause",
 15+  "system.resume",
 16+  "system.drain",
 17+  "system.state.read",
 18+  "tasks.read",
 19+  "tasks.logs.read",
 20+  "runs.read",
 21+  "maintenance.promote",
 22+  "maintenance.demote"
 23+] as const;
 24+
 25+export type AuthAction = (typeof AUTH_ACTIONS)[number];
 26+
 27+export interface AuthActionDescriptor {
 28+  summary: string;
 29+  mutatesState: boolean;
 30+  resourceBinding: AuthResourceBinding;
 31+}
 32+
 33+export const AUTH_ACTION_DESCRIPTORS = {
 34+  "controllers.heartbeat": {
 35+    summary: "controller 上报心跳并刷新自身活跃状态",
 36+    mutatesState: true,
 37+    resourceBinding: "controller"
 38+  },
 39+  "leader.acquire": {
 40+    summary: "controller 获取或续租 leader lease",
 41+    mutatesState: true,
 42+    resourceBinding: "controller"
 43+  },
 44+  "tasks.create": {
 45+    summary: "通过可见 control 会话创建新 task",
 46+    mutatesState: true,
 47+    resourceBinding: "none"
 48+  },
 49+  "tasks.plan": {
 50+    summary: "leader conductor 持久化已验收的 plan",
 51+    mutatesState: true,
 52+    resourceBinding: "controller"
 53+  },
 54+  "tasks.claim": {
 55+    summary: "controller 领取待规划 task 或下一个 runnable step",
 56+    mutatesState: true,
 57+    resourceBinding: "controller"
 58+  },
 59+  "steps.heartbeat": {
 60+    summary: "worker 为已分配 step 持续续心跳",
 61+    mutatesState: true,
 62+    resourceBinding: "worker"
 63+  },
 64+  "steps.checkpoint": {
 65+    summary: "worker 为已分配 step 写 checkpoint",
 66+    mutatesState: true,
 67+    resourceBinding: "worker"
 68+  },
 69+  "steps.complete": {
 70+    summary: "worker 标记已分配 step 完成",
 71+    mutatesState: true,
 72+    resourceBinding: "worker"
 73+  },
 74+  "steps.fail": {
 75+    summary: "worker 标记已分配 step 失败",
 76+    mutatesState: true,
 77+    resourceBinding: "worker"
 78+  },
 79+  "system.pause": {
 80+    summary: "暂停系统调度",
 81+    mutatesState: true,
 82+    resourceBinding: "none"
 83+  },
 84+  "system.resume": {
 85+    summary: "恢复系统调度",
 86+    mutatesState: true,
 87+    resourceBinding: "none"
 88+  },
 89+  "system.drain": {
 90+    summary: "进入 drain 模式,停止新分配",
 91+    mutatesState: true,
 92+    resourceBinding: "none"
 93+  },
 94+  "system.state.read": {
 95+    summary: "读取当前系统状态、队列和运行态汇总",
 96+    mutatesState: false,
 97+    resourceBinding: "none"
 98+  },
 99+  "tasks.read": {
100+    summary: "读取 task 详情",
101+    mutatesState: false,
102+    resourceBinding: "none"
103+  },
104+  "tasks.logs.read": {
105+    summary: "读取 task 或 run 关联日志",
106+    mutatesState: false,
107+    resourceBinding: "none"
108+  },
109+  "runs.read": {
110+    summary: "读取 run 详情",
111+    mutatesState: false,
112+    resourceBinding: "none"
113+  },
114+  "maintenance.promote": {
115+    summary: "执行主备切换或 promote 类维护操作",
116+    mutatesState: true,
117+    resourceBinding: "none"
118+  },
119+  "maintenance.demote": {
120+    summary: "执行 demote 或维护降级操作",
121+    mutatesState: true,
122+    resourceBinding: "none"
123+  }
124+} satisfies Record<AuthAction, AuthActionDescriptor>;
125+
126+export function isReadAction(action: AuthAction): boolean {
127+  return !AUTH_ACTION_DESCRIPTORS[action].mutatesState;
128+}
A packages/auth/src/control-api.ts
+200, -0
  1@@ -0,0 +1,200 @@
  2+import type { AuthAction } from "./actions.js";
  3+import type { AuthPrincipal, AuthResourceOwnership } from "./model.js";
  4+import { authorize, type AuthDecision } from "./policy.js";
  5+
  6+export type ControlApiMethod = "GET" | "POST";
  7+
  8+export interface ControlApiAuthRule {
  9+  method: ControlApiMethod;
 10+  pathPattern: string;
 11+  action: AuthAction;
 12+  summary: string;
 13+}
 14+
 15+export interface ControlApiAuthorizationInput {
 16+  method: ControlApiMethod;
 17+  path: string;
 18+  principal: AuthPrincipal;
 19+  resource?: AuthResourceOwnership;
 20+}
 21+
 22+export interface ControlApiAuthorizationResult extends AuthDecision {
 23+  matchedRule?: ControlApiAuthRule;
 24+}
 25+
 26+export const CONTROL_API_AUTH_RULES = [
 27+  {
 28+    method: "POST",
 29+    pathPattern: "/v1/controllers/heartbeat",
 30+    action: "controllers.heartbeat",
 31+    summary: "controller 心跳"
 32+  },
 33+  {
 34+    method: "POST",
 35+    pathPattern: "/v1/leader/acquire",
 36+    action: "leader.acquire",
 37+    summary: "获取或续租 leader lease"
 38+  },
 39+  {
 40+    method: "POST",
 41+    pathPattern: "/v1/tasks",
 42+    action: "tasks.create",
 43+    summary: "创建 task"
 44+  },
 45+  {
 46+    method: "POST",
 47+    pathPattern: "/v1/tasks/claim",
 48+    action: "tasks.claim",
 49+    summary: "领取待规划 task 或 runnable step"
 50+  },
 51+  {
 52+    method: "POST",
 53+    pathPattern: "/v1/tasks/:task_id/plan",
 54+    action: "tasks.plan",
 55+    summary: "持久化已验收 plan"
 56+  },
 57+  {
 58+    method: "POST",
 59+    pathPattern: "/v1/steps/:step_id/heartbeat",
 60+    action: "steps.heartbeat",
 61+    summary: "step 心跳"
 62+  },
 63+  {
 64+    method: "POST",
 65+    pathPattern: "/v1/steps/:step_id/checkpoint",
 66+    action: "steps.checkpoint",
 67+    summary: "写 step checkpoint"
 68+  },
 69+  {
 70+    method: "POST",
 71+    pathPattern: "/v1/steps/:step_id/complete",
 72+    action: "steps.complete",
 73+    summary: "标记 step 完成"
 74+  },
 75+  {
 76+    method: "POST",
 77+    pathPattern: "/v1/steps/:step_id/fail",
 78+    action: "steps.fail",
 79+    summary: "标记 step 失败"
 80+  },
 81+  {
 82+    method: "POST",
 83+    pathPattern: "/v1/system/pause",
 84+    action: "system.pause",
 85+    summary: "暂停自动化"
 86+  },
 87+  {
 88+    method: "POST",
 89+    pathPattern: "/v1/system/resume",
 90+    action: "system.resume",
 91+    summary: "恢复自动化"
 92+  },
 93+  {
 94+    method: "POST",
 95+    pathPattern: "/v1/system/drain",
 96+    action: "system.drain",
 97+    summary: "drain 自动化"
 98+  },
 99+  {
100+    method: "POST",
101+    pathPattern: "/v1/system/promote",
102+    action: "maintenance.promote",
103+    summary: "promote 维护操作"
104+  },
105+  {
106+    method: "POST",
107+    pathPattern: "/v1/system/demote",
108+    action: "maintenance.demote",
109+    summary: "demote 维护操作"
110+  },
111+  {
112+    method: "GET",
113+    pathPattern: "/v1/system/state",
114+    action: "system.state.read",
115+    summary: "读取系统状态"
116+  },
117+  {
118+    method: "GET",
119+    pathPattern: "/v1/tasks/:task_id/logs",
120+    action: "tasks.logs.read",
121+    summary: "读取 task 日志"
122+  },
123+  {
124+    method: "GET",
125+    pathPattern: "/v1/tasks/:task_id",
126+    action: "tasks.read",
127+    summary: "读取 task 详情"
128+  },
129+  {
130+    method: "GET",
131+    pathPattern: "/v1/runs/:run_id",
132+    action: "runs.read",
133+    summary: "读取 run 详情"
134+  }
135+] satisfies ReadonlyArray<ControlApiAuthRule>;
136+
137+export function findControlApiAuthRule(
138+  method: ControlApiMethod,
139+  path: string
140+): ControlApiAuthRule | null {
141+  for (const rule of CONTROL_API_AUTH_RULES) {
142+    if (rule.method === method && matchesRoutePattern(rule.pathPattern, path)) {
143+      return rule;
144+    }
145+  }
146+
147+  return null;
148+}
149+
150+export function authorizeControlApiRoute(
151+  input: ControlApiAuthorizationInput
152+): ControlApiAuthorizationResult {
153+  const matchedRule = findControlApiAuthRule(input.method, input.path);
154+
155+  if (!matchedRule) {
156+    return {
157+      ok: false,
158+      reason: "route_not_modeled",
159+      statusCode: 404
160+    };
161+  }
162+
163+  const decision = authorize({
164+    principal: input.principal,
165+    action: matchedRule.action,
166+    resource: input.resource
167+  });
168+
169+  return {
170+    ...decision,
171+    matchedRule
172+  };
173+}
174+
175+export function describeControlApiAuthSurface(): string[] {
176+  return CONTROL_API_AUTH_RULES.map(
177+    (rule) => `${rule.method} ${rule.pathPattern} -> ${rule.action} (${rule.summary})`
178+  );
179+}
180+
181+function matchesRoutePattern(pattern: string, actualPath: string): boolean {
182+  const patternSegments = normalizePath(pattern);
183+  const actualSegments = normalizePath(actualPath);
184+
185+  if (patternSegments.length !== actualSegments.length) {
186+    return false;
187+  }
188+
189+  return patternSegments.every((segment, index) => {
190+    if (segment.startsWith(":")) {
191+      return actualSegments[index] !== undefined;
192+    }
193+
194+    return segment === actualSegments[index];
195+  });
196+}
197+
198+function normalizePath(path: string): string[] {
199+  const normalizedPath = path.replace(/\/+$/u, "");
200+  return normalizedPath.split("/").filter((segment) => segment.length > 0);
201+}
M packages/auth/src/index.ts
+4, -24
 1@@ -1,24 +1,4 @@
 2-export type AuthRole =
 3-  | "controller"
 4-  | "worker"
 5-  | "browser_admin"
 6-  | "ops_admin"
 7-  | "readonly";
 8-
 9-export interface AuthContext {
10-  subject: string;
11-  role: AuthRole;
12-}
13-
14-export interface AuthDecision {
15-  ok: boolean;
16-  reason: string;
17-}
18-
19-export function deny(reason: string): AuthDecision {
20-  return {
21-    ok: false,
22-    reason
23-  };
24-}
25-
26+export * from "./actions.js";
27+export * from "./control-api.js";
28+export * from "./model.js";
29+export * from "./policy.js";
A packages/auth/src/model.ts
+160, -0
  1@@ -0,0 +1,160 @@
  2+import type { AuthAction } from "./actions.js";
  3+
  4+export const AUTH_ROLES = [
  5+  "controller",
  6+  "worker",
  7+  "browser_admin",
  8+  "ops_admin",
  9+  "readonly"
 10+] as const;
 11+
 12+export type AuthRole = (typeof AUTH_ROLES)[number];
 13+
 14+export const AUTH_TOKEN_KINDS = [
 15+  "service_hmac",
 16+  "service_signed",
 17+  "browser_session",
 18+  "ops_session"
 19+] as const;
 20+
 21+export type AuthTokenKind = (typeof AUTH_TOKEN_KINDS)[number];
 22+
 23+export const DEFAULT_AUTH_AUDIENCE = "control_api";
 24+
 25+export type AuthAudience = typeof DEFAULT_AUTH_AUDIENCE;
 26+export type AuthIdentityBinding = "machine" | "session" | "ops";
 27+
 28+export interface AuthPrincipal {
 29+  subject: string;
 30+  role: AuthRole;
 31+  tokenKind: AuthTokenKind;
 32+  audience: AuthAudience;
 33+  tokenId?: string;
 34+  issuer?: string;
 35+  issuedAt?: string;
 36+  expiresAt?: string;
 37+  controllerId?: string;
 38+  workerId?: string;
 39+  nodeId?: string;
 40+  sessionId?: string;
 41+  scope?: AuthAction[];
 42+}
 43+
 44+export interface AuthResourceOwnership {
 45+  controllerId?: string;
 46+  workerId?: string;
 47+}
 48+
 49+export interface AuthTokenModel {
 50+  kind: AuthTokenKind;
 51+  summary: string;
 52+  intendedRoles: readonly AuthRole[];
 53+  status: "current" | "future";
 54+  credentialShape: "shared_secret" | "signed_claims" | "bearer_secret";
 55+  identityBinding: AuthIdentityBinding;
 56+}
 57+
 58+export const AUTH_TOKEN_MODELS = {
 59+  service_hmac: {
 60+    kind: "service_hmac",
 61+    summary: "当前最低要求。controller 与 worker 使用共享密钥或 HMAC 签名材料。",
 62+    intendedRoles: ["controller", "worker"],
 63+    status: "current",
 64+    credentialShape: "shared_secret",
 65+    identityBinding: "machine"
 66+  },
 67+  service_signed: {
 68+    kind: "service_signed",
 69+    summary: "后续演进。把 role、scope、节点身份放进可验证的签名 service token。",
 70+    intendedRoles: ["controller", "worker"],
 71+    status: "future",
 72+    credentialShape: "signed_claims",
 73+    identityBinding: "machine"
 74+  },
 75+  browser_session: {
 76+    kind: "browser_session",
 77+    summary: "可见 control 浏览器会话使用的 bearer token,用于 browser_admin 与 readonly。",
 78+    intendedRoles: ["browser_admin", "readonly"],
 79+    status: "current",
 80+    credentialShape: "bearer_secret",
 81+    identityBinding: "session"
 82+  },
 83+  ops_session: {
 84+    kind: "ops_session",
 85+    summary: "运维人员专用 bearer token,权限与浏览器会话分离。",
 86+    intendedRoles: ["ops_admin"],
 87+    status: "current",
 88+    credentialShape: "bearer_secret",
 89+    identityBinding: "ops"
 90+  }
 91+} satisfies Record<AuthTokenKind, AuthTokenModel>;
 92+
 93+export type BearerTokenFailureReason =
 94+  | "missing_authorization_header"
 95+  | "invalid_authorization_scheme"
 96+  | "empty_bearer_token";
 97+
 98+export type BearerTokenExtractionResult =
 99+  | {
100+      ok: true;
101+      token: string;
102+    }
103+  | {
104+      ok: false;
105+      reason: BearerTokenFailureReason;
106+    };
107+
108+export type AuthVerificationFailureReason =
109+  | "unknown_token"
110+  | "invalid_signature"
111+  | "token_expired"
112+  | "audience_mismatch";
113+
114+export type AuthVerificationResult =
115+  | {
116+      ok: true;
117+      principal: AuthPrincipal;
118+    }
119+  | {
120+      ok: false;
121+      reason: AuthVerificationFailureReason;
122+      statusCode: 401 | 403;
123+    };
124+
125+export interface AuthTokenVerifier {
126+  verifyBearerToken(token: string): Promise<AuthVerificationResult>;
127+}
128+
129+export function extractBearerToken(
130+  authorizationHeader: string | undefined
131+): BearerTokenExtractionResult {
132+  if (!authorizationHeader) {
133+    return {
134+      ok: false,
135+      reason: "missing_authorization_header"
136+    };
137+  }
138+
139+  const [scheme, ...rest] = authorizationHeader.trim().split(/\s+/u);
140+
141+  if (scheme !== "Bearer") {
142+    return {
143+      ok: false,
144+      reason: "invalid_authorization_scheme"
145+    };
146+  }
147+
148+  const token = rest.join(" ").trim();
149+
150+  if (!token) {
151+    return {
152+      ok: false,
153+      reason: "empty_bearer_token"
154+    };
155+  }
156+
157+  return {
158+    ok: true,
159+    token
160+  };
161+}
A packages/auth/src/policy.ts
+217, -0
  1@@ -0,0 +1,217 @@
  2+import {
  3+  AUTH_ACTIONS,
  4+  AUTH_ACTION_DESCRIPTORS,
  5+  type AuthAction,
  6+  type AuthResourceBinding
  7+} from "./actions.js";
  8+import {
  9+  AUTH_ROLES,
 10+  DEFAULT_AUTH_AUDIENCE,
 11+  type AuthPrincipal,
 12+  type AuthResourceOwnership,
 13+  type AuthRole,
 14+  type AuthTokenKind
 15+} from "./model.js";
 16+
 17+export interface AuthDecision {
 18+  ok: boolean;
 19+  reason: string;
 20+  statusCode: number;
 21+  action?: AuthAction;
 22+}
 23+
 24+export interface AuthorizationInput {
 25+  principal: AuthPrincipal;
 26+  action: AuthAction;
 27+  resource?: AuthResourceOwnership;
 28+}
 29+
 30+export interface AuthRoleBoundary {
 31+  summary: string;
 32+  tokenKinds: readonly AuthTokenKind[];
 33+  allowedActions: readonly AuthAction[];
 34+  deniedActions: readonly AuthAction[];
 35+}
 36+
 37+export interface PermissionMatrixRow {
 38+  action: AuthAction;
 39+  summary: string;
 40+  mutatesState: boolean;
 41+  resourceBinding: AuthResourceBinding;
 42+  allowedRoles: AuthRole[];
 43+}
 44+
 45+export const AUTH_ROLE_ALLOWED_ACTIONS: Record<AuthRole, readonly AuthAction[]> = {
 46+  controller: ["controllers.heartbeat", "leader.acquire", "tasks.plan", "tasks.claim"],
 47+  worker: ["steps.heartbeat", "steps.checkpoint", "steps.complete", "steps.fail"],
 48+  browser_admin: [
 49+    "tasks.create",
 50+    "system.pause",
 51+    "system.resume",
 52+    "system.drain",
 53+    "system.state.read",
 54+    "tasks.read",
 55+    "tasks.logs.read",
 56+    "runs.read"
 57+  ],
 58+  ops_admin: [
 59+    "system.state.read",
 60+    "tasks.read",
 61+    "tasks.logs.read",
 62+    "runs.read",
 63+    "maintenance.promote",
 64+    "maintenance.demote"
 65+  ],
 66+  readonly: ["system.state.read", "tasks.read", "tasks.logs.read", "runs.read"]
 67+};
 68+
 69+export const AUTH_ROLE_ALLOWED_TOKEN_KINDS: Record<AuthRole, readonly AuthTokenKind[]> = {
 70+  controller: ["service_hmac", "service_signed"],
 71+  worker: ["service_hmac", "service_signed"],
 72+  browser_admin: ["browser_session"],
 73+  ops_admin: ["ops_session"],
 74+  readonly: ["browser_session"]
 75+};
 76+
 77+const AUTH_ROLE_SUMMARIES: Record<AuthRole, string> = {
 78+  controller: "只负责 conductor 级写操作:lease、claim、plan 持久化与 controller 心跳。",
 79+  worker: "只负责已分配 step 的执行回写,不参与调度和系统级操作。",
 80+  browser_admin: "代表可见 control 会话,可创建 task、暂停/恢复/drain,并查看队列与日志。",
 81+  ops_admin: "只做主备切换和维护操作,不参与普通 task 调度。",
 82+  readonly: "只看状态、task、run 和日志,不允许任何写操作。"
 83+};
 84+
 85+export const AUTH_ROLE_BOUNDARIES = {
 86+  controller: {
 87+    summary: AUTH_ROLE_SUMMARIES.controller,
 88+    tokenKinds: AUTH_ROLE_ALLOWED_TOKEN_KINDS.controller,
 89+    allowedActions: AUTH_ROLE_ALLOWED_ACTIONS.controller,
 90+    deniedActions: differenceActions(AUTH_ROLE_ALLOWED_ACTIONS.controller)
 91+  },
 92+  worker: {
 93+    summary: AUTH_ROLE_SUMMARIES.worker,
 94+    tokenKinds: AUTH_ROLE_ALLOWED_TOKEN_KINDS.worker,
 95+    allowedActions: AUTH_ROLE_ALLOWED_ACTIONS.worker,
 96+    deniedActions: differenceActions(AUTH_ROLE_ALLOWED_ACTIONS.worker)
 97+  },
 98+  browser_admin: {
 99+    summary: AUTH_ROLE_SUMMARIES.browser_admin,
100+    tokenKinds: AUTH_ROLE_ALLOWED_TOKEN_KINDS.browser_admin,
101+    allowedActions: AUTH_ROLE_ALLOWED_ACTIONS.browser_admin,
102+    deniedActions: differenceActions(AUTH_ROLE_ALLOWED_ACTIONS.browser_admin)
103+  },
104+  ops_admin: {
105+    summary: AUTH_ROLE_SUMMARIES.ops_admin,
106+    tokenKinds: AUTH_ROLE_ALLOWED_TOKEN_KINDS.ops_admin,
107+    allowedActions: AUTH_ROLE_ALLOWED_ACTIONS.ops_admin,
108+    deniedActions: differenceActions(AUTH_ROLE_ALLOWED_ACTIONS.ops_admin)
109+  },
110+  readonly: {
111+    summary: AUTH_ROLE_SUMMARIES.readonly,
112+    tokenKinds: AUTH_ROLE_ALLOWED_TOKEN_KINDS.readonly,
113+    allowedActions: AUTH_ROLE_ALLOWED_ACTIONS.readonly,
114+    deniedActions: differenceActions(AUTH_ROLE_ALLOWED_ACTIONS.readonly)
115+  }
116+} satisfies Record<AuthRole, AuthRoleBoundary>;
117+
118+export function allow(action?: AuthAction, reason = "authorized"): AuthDecision {
119+  return {
120+    ok: true,
121+    reason,
122+    statusCode: 200,
123+    action
124+  };
125+}
126+
127+export function deny(
128+  reason: string,
129+  statusCode = 403,
130+  action?: AuthAction
131+): AuthDecision {
132+  return {
133+    ok: false,
134+    reason,
135+    statusCode,
136+    action
137+  };
138+}
139+
140+export function canRolePerform(role: AuthRole, action: AuthAction): boolean {
141+  return AUTH_ROLE_ALLOWED_ACTIONS[role].includes(action);
142+}
143+
144+export function getAllowedRolesForAction(action: AuthAction): AuthRole[] {
145+  return AUTH_ROLES.filter((role) => canRolePerform(role, action));
146+}
147+
148+export function authorize(input: AuthorizationInput): AuthDecision {
149+  const { principal, action, resource } = input;
150+
151+  if (principal.audience !== DEFAULT_AUTH_AUDIENCE) {
152+    return deny("invalid_audience", 403, action);
153+  }
154+
155+  if (!AUTH_ROLE_ALLOWED_TOKEN_KINDS[principal.role].includes(principal.tokenKind)) {
156+    return deny("token_kind_not_allowed", 403, action);
157+  }
158+
159+  if (!canRolePerform(principal.role, action)) {
160+    return deny("role_not_allowed", 403, action);
161+  }
162+
163+  if (principal.scope && !principal.scope.includes(action)) {
164+    return deny("scope_not_granted", 403, action);
165+  }
166+
167+  const binding = AUTH_ACTION_DESCRIPTORS[action].resourceBinding;
168+  const bindingDecision = authorizeResourceBinding(binding, principal, resource, action);
169+
170+  if (!bindingDecision.ok) {
171+    return bindingDecision;
172+  }
173+
174+  return allow(action);
175+}
176+
177+export function describePermissionMatrix(): PermissionMatrixRow[] {
178+  return AUTH_ACTIONS.map((action) => ({
179+    action,
180+    summary: AUTH_ACTION_DESCRIPTORS[action].summary,
181+    mutatesState: AUTH_ACTION_DESCRIPTORS[action].mutatesState,
182+    resourceBinding: AUTH_ACTION_DESCRIPTORS[action].resourceBinding,
183+    allowedRoles: getAllowedRolesForAction(action)
184+  }));
185+}
186+
187+function differenceActions(allowedActions: readonly AuthAction[]): AuthAction[] {
188+  return AUTH_ACTIONS.filter((action) => !allowedActions.includes(action));
189+}
190+
191+function authorizeResourceBinding(
192+  binding: AuthResourceBinding,
193+  principal: AuthPrincipal,
194+  resource: AuthResourceOwnership | undefined,
195+  action: AuthAction
196+): AuthDecision {
197+  if (binding === "controller") {
198+    if (!principal.controllerId) {
199+      return deny("controller_identity_required", 403, action);
200+    }
201+
202+    if (resource?.controllerId && resource.controllerId !== principal.controllerId) {
203+      return deny("controller_resource_mismatch", 403, action);
204+    }
205+  }
206+
207+  if (binding === "worker") {
208+    if (!principal.workerId) {
209+      return deny("worker_identity_required", 403, action);
210+    }
211+
212+    if (resource?.workerId && resource.workerId !== principal.workerId) {
213+      return deny("worker_resource_mismatch", 403, action);
214+    }
215+  }
216+
217+  return allow(action);
218+}