im_wower
·
2026-03-21
control-api.ts
1import type { AuthAction } from "./actions.js";
2import type { AuthPrincipal, AuthResourceOwnership } from "./model.js";
3import { authorize, type AuthDecision } from "./policy.js";
4
5export type ControlApiMethod = "GET" | "POST";
6
7export interface ControlApiAuthRule {
8 method: ControlApiMethod;
9 pathPattern: string;
10 action: AuthAction;
11 summary: string;
12}
13
14export interface ControlApiAuthorizationInput {
15 method: ControlApiMethod;
16 path: string;
17 principal: AuthPrincipal;
18 resource?: AuthResourceOwnership;
19}
20
21export interface ControlApiAuthorizationResult extends AuthDecision {
22 matchedRule?: ControlApiAuthRule;
23}
24
25export const CONTROL_API_AUTH_RULES = [
26 {
27 method: "POST",
28 pathPattern: "/v1/controllers/heartbeat",
29 action: "controllers.heartbeat",
30 summary: "controller 心跳"
31 },
32 {
33 method: "POST",
34 pathPattern: "/v1/leader/acquire",
35 action: "leader.acquire",
36 summary: "获取或续租 leader lease"
37 },
38 {
39 method: "POST",
40 pathPattern: "/v1/tasks",
41 action: "tasks.create",
42 summary: "创建 task"
43 },
44 {
45 method: "POST",
46 pathPattern: "/v1/tasks/claim",
47 action: "tasks.claim",
48 summary: "领取待规划 task 或 runnable step"
49 },
50 {
51 method: "POST",
52 pathPattern: "/v1/tasks/:task_id/plan",
53 action: "tasks.plan",
54 summary: "持久化已验收 plan"
55 },
56 {
57 method: "POST",
58 pathPattern: "/v1/steps/:step_id/heartbeat",
59 action: "steps.heartbeat",
60 summary: "step 心跳"
61 },
62 {
63 method: "POST",
64 pathPattern: "/v1/steps/:step_id/checkpoint",
65 action: "steps.checkpoint",
66 summary: "写 step checkpoint"
67 },
68 {
69 method: "POST",
70 pathPattern: "/v1/steps/:step_id/complete",
71 action: "steps.complete",
72 summary: "标记 step 完成"
73 },
74 {
75 method: "POST",
76 pathPattern: "/v1/steps/:step_id/fail",
77 action: "steps.fail",
78 summary: "标记 step 失败"
79 },
80 {
81 method: "POST",
82 pathPattern: "/v1/system/pause",
83 action: "system.pause",
84 summary: "暂停自动化"
85 },
86 {
87 method: "POST",
88 pathPattern: "/v1/system/resume",
89 action: "system.resume",
90 summary: "恢复自动化"
91 },
92 {
93 method: "POST",
94 pathPattern: "/v1/system/drain",
95 action: "system.drain",
96 summary: "drain 自动化"
97 },
98 {
99 method: "POST",
100 pathPattern: "/v1/system/promote",
101 action: "maintenance.promote",
102 summary: "promote 维护操作"
103 },
104 {
105 method: "POST",
106 pathPattern: "/v1/system/demote",
107 action: "maintenance.demote",
108 summary: "demote 维护操作"
109 },
110 {
111 method: "GET",
112 pathPattern: "/v1/system/state",
113 action: "system.state.read",
114 summary: "读取系统状态"
115 },
116 {
117 method: "GET",
118 pathPattern: "/v1/tasks/:task_id/logs",
119 action: "tasks.logs.read",
120 summary: "读取 task 日志"
121 },
122 {
123 method: "GET",
124 pathPattern: "/v1/tasks/:task_id",
125 action: "tasks.read",
126 summary: "读取 task 详情"
127 },
128 {
129 method: "GET",
130 pathPattern: "/v1/runs/:run_id",
131 action: "runs.read",
132 summary: "读取 run 详情"
133 }
134] satisfies ReadonlyArray<ControlApiAuthRule>;
135
136export function findControlApiAuthRule(
137 method: ControlApiMethod,
138 path: string
139): ControlApiAuthRule | null {
140 for (const rule of CONTROL_API_AUTH_RULES) {
141 if (rule.method === method && matchesRoutePattern(rule.pathPattern, path)) {
142 return rule;
143 }
144 }
145
146 return null;
147}
148
149export function authorizeControlApiRoute(
150 input: ControlApiAuthorizationInput
151): ControlApiAuthorizationResult {
152 const matchedRule = findControlApiAuthRule(input.method, input.path);
153
154 if (!matchedRule) {
155 return {
156 ok: false,
157 reason: "route_not_modeled",
158 statusCode: 404
159 };
160 }
161
162 const decision = authorize({
163 principal: input.principal,
164 action: matchedRule.action,
165 resource: input.resource
166 });
167
168 return {
169 ...decision,
170 matchedRule
171 };
172}
173
174export function describeControlApiAuthSurface(): string[] {
175 return CONTROL_API_AUTH_RULES.map(
176 (rule) => `${rule.method} ${rule.pathPattern} -> ${rule.action} (${rule.summary})`
177 );
178}
179
180function matchesRoutePattern(pattern: string, actualPath: string): boolean {
181 const patternSegments = normalizePath(pattern);
182 const actualSegments = normalizePath(actualPath);
183
184 if (patternSegments.length !== actualSegments.length) {
185 return false;
186 }
187
188 return patternSegments.every((segment, index) => {
189 if (segment.startsWith(":")) {
190 return actualSegments[index] !== undefined;
191 }
192
193 return segment === actualSegments[index];
194 });
195}
196
197function normalizePath(path: string): string[] {
198 const normalizedPath = path.replace(/\/+$/u, "");
199 return normalizedPath.split("/").filter((segment) => segment.length > 0);
200}