baa-conductor

git clone 

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