baa-conductor


baa-conductor / packages / auth / src
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}