- 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
+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
+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`
+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"
+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+}
+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+}
+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";
+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+}
+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+}