/** * Authorization model for the GateRunner control plane. * * Structured authorizations allow soft-deny findings to be overridden * under controlled conditions. Hard-deny findings are NEVER overridable. * * Rules: * - hard-deny → NEVER overridable by any authorization * - soft-deny → overridable by structured authorization * - evidence-required → only overridable by "evidence-update" grant * - warning → no authorization needed * * Expiry defaults: * - "user" grants → 30 minutes unless explicit expiresAt * - "plan-revision" grants → persist until explicitly revoked * - "evidence-update" grants → persist until explicitly revoked */ import type { GateContext, GateDecision, GateFinding } from "./findings.ts"; import type { GateSeverity } from "./taxonomy.ts"; import { GateRunner } from "./gate-runner.ts"; // ── Types ──────────────────────────────────────────────────────────────────── /** * Scope of an authorization — how broadly it applies. * * - "single-finding" → applies to one specific finding * - "checkpoint" → applies to all findings at the same checkpoint * - "phase" → applies to all findings for the current phase */ export type AuthorizationScope = | "single-finding" | "checkpoint" | "phase"; /** * Who or what granted the authorization. * * - "user" → explicit human override (expires in 30 min by default) * - "plan-revision" → granted automatically when plan is revised * - "evidence-update" → granted when evidence is provided */ export type AuthorizationGrantor = | "user" | "plan-revision" | "evidence-update"; /** * A structured authorization record. * * Each authorization is linked to a specific finding and declares * who granted it, at what scope, and when it expires. */ export interface UserAuthorization { /** Unique authorization id, formatted "AUTH-{seq_3digits}". */ id: string; /** The finding id this authorization overrides (e.g. "F-001"). */ findingId: string; /** How broadly this authorization applies. */ scope: AuthorizationScope; /** Who or what granted this authorization. */ grantedBy: AuthorizationGrantor; /** Human-readable reason for the authorization. */ reason: string; /** ISO timestamp when this authorization expires. Undefined = never. */ expiresAt?: string; /** ISO timestamp when this authorization was created. */ createdAt: string; } /** * Result of checking whether a finding is authorized. */ export interface AuthorizationResult { /** Whether the finding is authorized to proceed. */ allowed: boolean; /** Reason for the decision. Present when allowed is false. */ reason?: string; } // ── Constants ──────────────────────────────────────────────────────────────── /** Default expiry for "user" grants: 30 minutes in milliseconds. */ const USER_GRANT_DEFAULT_EXPIRY_MS = 30 * 60 * 1000; // ── AuthorizationModel ─────────────────────────────────────────────────────── /** * Manages structured authorizations for gate findings. * * Authorizations are stored in memory and keyed by finding id. * Each authorization can override a soft-deny finding (but NEVER a hard-deny). */ export class AuthorizationModel { private readonly authorizations = new Map(); private authSeq = 0; /** * Register a new authorization. * * @param auth Authorization details (id is auto-generated if not provided). * @returns The assigned authorization id. */ grant( auth: Omit & { id?: string; createdAt?: string }, ): string { const id = auth.id ?? this.nextAuthId(); const createdAt = auth.createdAt ?? new Date().toISOString(); // Compute default expiry for "user" grants. let expiresAt = auth.expiresAt; if (auth.grantedBy === "user" && !expiresAt) { expiresAt = new Date(Date.now() + USER_GRANT_DEFAULT_EXPIRY_MS).toISOString(); } const record: UserAuthorization = { id, findingId: auth.findingId, scope: auth.scope, grantedBy: auth.grantedBy, reason: auth.reason, expiresAt, createdAt, }; this.authorizations.set(id, record); return id; } /** * Remove an authorization by id. * * @param authId The authorization id to revoke. * @returns true if the authorization was found and removed. */ revoke(authId: string): boolean { return this.authorizations.delete(authId); } /** * Check whether a finding is authorized. * * Rules: * - hard-deny → always { allowed: false, reason: "hard-deny cannot be authorized by user" } * - soft-deny → allowed if a valid (non-expired) authorization exists * - evidence-required → allowed only if a valid "evidence-update" authorization exists * - warning → always { allowed: true } * * @param finding The finding to check. * @param ctx Optional gate context for scope-based matching. * @returns AuthorizationResult indicating whether the finding is authorized. */ check(finding: GateFinding, ctx?: GateContext): AuthorizationResult { // Hard-deny is NEVER overridable. if (isHardDeny(finding.severity)) { return { allowed: false, reason: "hard-deny cannot be authorized by user", }; } // Warning never needs authorization. if (isWarning(finding.severity)) { return { allowed: true }; } // Find a valid authorization for this finding. const validAuth = this.findValidAuthorization(finding, ctx); if (!validAuth) { if (isEvidenceRequired(finding.severity)) { return { allowed: false, reason: "evidence-required can only be authorized by evidence-update grant", }; } return { allowed: false, reason: `no valid authorization for ${finding.severity} finding ${finding.id}`, }; } // evidence-required only accepts "evidence-update" grants. if (isEvidenceRequired(finding.severity) && validAuth.grantedBy !== "evidence-update") { return { allowed: false, reason: `evidence-required finding ${finding.id} can only be authorized by evidence-update grant, got ${validAuth.grantedBy}`, }; } return { allowed: true }; } /** * Check whether a finding is a hard-deny severity. * * This is a convenience alias for the standalone isHardDeny function, * kept here for API completeness. */ isHardDeny(finding: GateFinding): boolean { return isHardDeny(finding.severity); } /** * Get all active (non-expired) authorizations. */ getActive(): UserAuthorization[] { const now = new Date(); return Array.from(this.authorizations.values()).filter( (a) => !a.expiresAt || new Date(a.expiresAt) > now, ); } /** * Get all authorizations (including expired). */ getAll(): UserAuthorization[] { return Array.from(this.authorizations.values()); } /** * Get a specific authorization by id. */ getById(authId: string): UserAuthorization | undefined { return this.authorizations.get(authId); } // ── Private helpers ────────────────────────────────────────────────────── /** * Find a valid (non-expired) authorization that covers the given finding. */ private findValidAuthorization( finding: GateFinding, ctx?: GateContext, ): UserAuthorization | undefined { const now = new Date(); const all = Array.from(this.authorizations.values()); for (const auth of all) { // Skip expired authorizations. if (auth.expiresAt && new Date(auth.expiresAt) <= now) { continue; } // Check scope match. if (this.matchesScope(auth, finding, ctx)) { return auth; } } return undefined; } /** * Check whether an authorization's scope covers the given finding. */ private matchesScope( auth: UserAuthorization, finding: GateFinding, ctx?: GateContext, ): boolean { switch (auth.scope) { case "single-finding": return auth.findingId === finding.id; case "checkpoint": // If we have context, match by checkpoint; otherwise fall back to finding id. if (ctx?.checkpoint) { return auth.findingId === finding.id; // Note: checkpoint scope means "all findings at this checkpoint", // but we still anchor to a specific finding id for traceability. // The caller should grant per-finding for checkpoint scope. } return auth.findingId === finding.id; case "phase": // Phase scope is broader — matches any finding in the current phase. // We still require findingId to be set for traceability, but // a phase-level grant overrides all findings at the same checkpoint. if (ctx?.checkpoint) { return true; } return auth.findingId === finding.id; default: return false; } } /** * Generate a unique authorization id in the format "AUTH-001", "AUTH-002", etc. */ private nextAuthId(): string { this.authSeq += 1; return `AUTH-${String(this.authSeq).padStart(3, "0")}`; } } // ── Severity helper functions ──────────────────────────────────────────────── /** * Check whether a severity is "hard-deny". */ export function isHardDeny(severity: GateSeverity): boolean { return severity === "hard-deny"; } /** * Check whether a severity is "soft-deny". */ export function isSoftDeny(severity: GateSeverity): boolean { return severity === "soft-deny"; } /** * Check whether a severity is "evidence-required". */ export function isEvidenceRequired(severity: GateSeverity): boolean { return severity === "evidence-required"; } /** * Check whether a severity is "warning". */ export function isWarning(severity: GateSeverity): boolean { return severity === "warning"; } // ── Integration helper ─────────────────────────────────────────────────────── /** * Run a gate evaluation and apply authorizations to soft-deny findings. * * Flow: * 1. Run the gate via GateRunner. * 2. For each finding, check if it's authorized. * 3. Recompute the allowed flag: hard-deny always blocks; soft-deny * blocks only if not authorized; evidence-required blocks unless * authorized by "evidence-update"; warning never blocks. * * @param runner The GateRunner instance. * @param ctx The gate context for this evaluation. * @param authModel The authorization model to check against. * @returns A GateDecision with the allowed flag recalculated based on authorizations. */ export async function evaluateGateWithAuth( runner: GateRunner, ctx: GateContext, authModel: AuthorizationModel, ): Promise { // 1. Run the gate normally. const decision = await runner.runGate(ctx); // 2. If already allowed, nothing to override. if (decision.allowed) { return decision; } // 3. Re-evaluate each finding against the authorization model. let hasHardDeny = false; let hasUnauthorizedBlocker = false; for (const finding of decision.findings) { const result = authModel.check(finding, ctx); if (isHardDeny(finding.severity)) { // Hard-deny is NEVER overridable. hasHardDeny = true; } else if (!result.allowed) { // This finding is still blocking. hasUnauthorizedBlocker = true; } // If result.allowed is true, the finding is authorized and no longer blocks. } // 4. Recompute allowed: only allow if no hard-deny and no unauthorized blockers. const allowed = !hasHardDeny && !hasUnauthorizedBlocker; return { ...decision, allowed, }; }