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