im_wower
·
2026-03-22
policy.ts
1import {
2 AUTH_ACTIONS,
3 AUTH_ACTION_DESCRIPTORS,
4 type AuthAction,
5 type AuthResourceBinding
6} from "./actions.js";
7import {
8 AUTH_ROLES,
9 DEFAULT_AUTH_AUDIENCE,
10 type AuthPrincipal,
11 type AuthResourceOwnership,
12 type AuthRole,
13 type AuthTokenKind
14} from "./model.js";
15
16export interface AuthDecision {
17 ok: boolean;
18 reason: string;
19 statusCode: number;
20 action?: AuthAction;
21}
22
23export interface AuthorizationInput {
24 principal: AuthPrincipal;
25 action: AuthAction;
26 resource?: AuthResourceOwnership;
27}
28
29export interface AuthRoleBoundary {
30 summary: string;
31 tokenKinds: readonly AuthTokenKind[];
32 allowedActions: readonly AuthAction[];
33 deniedActions: readonly AuthAction[];
34}
35
36export interface PermissionMatrixRow {
37 action: AuthAction;
38 summary: string;
39 mutatesState: boolean;
40 resourceBinding: AuthResourceBinding;
41 allowedRoles: AuthRole[];
42}
43
44export const AUTH_ROLE_ALLOWED_ACTIONS: Record<AuthRole, readonly AuthAction[]> = {
45 controller: ["controllers.heartbeat", "leader.acquire", "tasks.plan", "tasks.claim"],
46 worker: ["steps.heartbeat", "steps.checkpoint", "steps.complete", "steps.fail"],
47 browser_admin: [
48 "controllers.list",
49 "tasks.create",
50 "tasks.list",
51 "system.pause",
52 "system.resume",
53 "system.drain",
54 "system.state.read",
55 "runs.list",
56 "tasks.read",
57 "tasks.logs.read",
58 "runs.read"
59 ],
60 ops_admin: [
61 "controllers.list",
62 "system.state.read",
63 "tasks.list",
64 "runs.list",
65 "tasks.read",
66 "tasks.logs.read",
67 "runs.read",
68 "maintenance.promote",
69 "maintenance.demote"
70 ],
71 readonly: [
72 "controllers.list",
73 "system.state.read",
74 "tasks.list",
75 "runs.list",
76 "tasks.read",
77 "tasks.logs.read",
78 "runs.read"
79 ]
80};
81
82export const AUTH_ROLE_ALLOWED_TOKEN_KINDS: Record<AuthRole, readonly AuthTokenKind[]> = {
83 controller: ["service_hmac", "service_signed"],
84 worker: ["service_hmac", "service_signed"],
85 browser_admin: ["browser_session"],
86 ops_admin: ["ops_session"],
87 readonly: ["browser_session"]
88};
89
90const AUTH_ROLE_SUMMARIES: Record<AuthRole, string> = {
91 controller: "只负责 conductor 级写操作:lease、claim、plan 持久化与 controller 心跳。",
92 worker: "只负责已分配 step 的执行回写,不参与调度和系统级操作。",
93 browser_admin: "代表可见 control 会话,可创建 task、暂停/恢复/drain,并查看队列、controller、run 与日志。",
94 ops_admin: "只做主备切换和维护操作,不参与普通 task 调度。",
95 readonly: "只看状态、controller、task、run 和日志,不允许任何写操作。"
96};
97
98export const AUTH_ROLE_BOUNDARIES = {
99 controller: {
100 summary: AUTH_ROLE_SUMMARIES.controller,
101 tokenKinds: AUTH_ROLE_ALLOWED_TOKEN_KINDS.controller,
102 allowedActions: AUTH_ROLE_ALLOWED_ACTIONS.controller,
103 deniedActions: differenceActions(AUTH_ROLE_ALLOWED_ACTIONS.controller)
104 },
105 worker: {
106 summary: AUTH_ROLE_SUMMARIES.worker,
107 tokenKinds: AUTH_ROLE_ALLOWED_TOKEN_KINDS.worker,
108 allowedActions: AUTH_ROLE_ALLOWED_ACTIONS.worker,
109 deniedActions: differenceActions(AUTH_ROLE_ALLOWED_ACTIONS.worker)
110 },
111 browser_admin: {
112 summary: AUTH_ROLE_SUMMARIES.browser_admin,
113 tokenKinds: AUTH_ROLE_ALLOWED_TOKEN_KINDS.browser_admin,
114 allowedActions: AUTH_ROLE_ALLOWED_ACTIONS.browser_admin,
115 deniedActions: differenceActions(AUTH_ROLE_ALLOWED_ACTIONS.browser_admin)
116 },
117 ops_admin: {
118 summary: AUTH_ROLE_SUMMARIES.ops_admin,
119 tokenKinds: AUTH_ROLE_ALLOWED_TOKEN_KINDS.ops_admin,
120 allowedActions: AUTH_ROLE_ALLOWED_ACTIONS.ops_admin,
121 deniedActions: differenceActions(AUTH_ROLE_ALLOWED_ACTIONS.ops_admin)
122 },
123 readonly: {
124 summary: AUTH_ROLE_SUMMARIES.readonly,
125 tokenKinds: AUTH_ROLE_ALLOWED_TOKEN_KINDS.readonly,
126 allowedActions: AUTH_ROLE_ALLOWED_ACTIONS.readonly,
127 deniedActions: differenceActions(AUTH_ROLE_ALLOWED_ACTIONS.readonly)
128 }
129} satisfies Record<AuthRole, AuthRoleBoundary>;
130
131export function allow(action?: AuthAction, reason = "authorized"): AuthDecision {
132 return {
133 ok: true,
134 reason,
135 statusCode: 200,
136 action
137 };
138}
139
140export function deny(
141 reason: string,
142 statusCode = 403,
143 action?: AuthAction
144): AuthDecision {
145 return {
146 ok: false,
147 reason,
148 statusCode,
149 action
150 };
151}
152
153export function canRolePerform(role: AuthRole, action: AuthAction): boolean {
154 return AUTH_ROLE_ALLOWED_ACTIONS[role].includes(action);
155}
156
157export function getAllowedRolesForAction(action: AuthAction): AuthRole[] {
158 return AUTH_ROLES.filter((role) => canRolePerform(role, action));
159}
160
161export function authorize(input: AuthorizationInput): AuthDecision {
162 const { principal, action, resource } = input;
163
164 if (principal.audience !== DEFAULT_AUTH_AUDIENCE) {
165 return deny("invalid_audience", 403, action);
166 }
167
168 if (!AUTH_ROLE_ALLOWED_TOKEN_KINDS[principal.role].includes(principal.tokenKind)) {
169 return deny("token_kind_not_allowed", 403, action);
170 }
171
172 if (!canRolePerform(principal.role, action)) {
173 return deny("role_not_allowed", 403, action);
174 }
175
176 if (principal.scope && !principal.scope.includes(action)) {
177 return deny("scope_not_granted", 403, action);
178 }
179
180 const binding = AUTH_ACTION_DESCRIPTORS[action].resourceBinding;
181 const bindingDecision = authorizeResourceBinding(binding, principal, resource, action);
182
183 if (!bindingDecision.ok) {
184 return bindingDecision;
185 }
186
187 return allow(action);
188}
189
190export function describePermissionMatrix(): PermissionMatrixRow[] {
191 return AUTH_ACTIONS.map((action) => ({
192 action,
193 summary: AUTH_ACTION_DESCRIPTORS[action].summary,
194 mutatesState: AUTH_ACTION_DESCRIPTORS[action].mutatesState,
195 resourceBinding: AUTH_ACTION_DESCRIPTORS[action].resourceBinding,
196 allowedRoles: getAllowedRolesForAction(action)
197 }));
198}
199
200function differenceActions(allowedActions: readonly AuthAction[]): AuthAction[] {
201 return AUTH_ACTIONS.filter((action) => !allowedActions.includes(action));
202}
203
204function authorizeResourceBinding(
205 binding: AuthResourceBinding,
206 principal: AuthPrincipal,
207 resource: AuthResourceOwnership | undefined,
208 action: AuthAction
209): AuthDecision {
210 if (binding === "controller") {
211 if (!principal.controllerId) {
212 return deny("controller_identity_required", 403, action);
213 }
214
215 if (resource?.controllerId && resource.controllerId !== principal.controllerId) {
216 return deny("controller_resource_mismatch", 403, action);
217 }
218 }
219
220 if (binding === "worker") {
221 if (!principal.workerId) {
222 return deny("worker_identity_required", 403, action);
223 }
224
225 if (resource?.workerId && resource.workerId !== principal.workerId) {
226 return deny("worker_resource_mismatch", 403, action);
227 }
228 }
229
230 return allow(action);
231}