import { randomUUID } from "node:crypto"; import { KUBER_API_VERSION, type ObjectMeta } from "./workspace-store"; import { redactString, REDACTED } from "./redact"; export const AUDIT_REDACTED = REDACTED; export const MAX_AUDIT_EVENT_BYTES = 256 * 1024; export const MAX_AUDIT_EVENTS = 100; const SENSITIVE_KEY = /(?:authorization|cookie|credential|password|passwd|secret|token|api[-_]?key|private[-_]?key)/i; const SENSITIVE_VALUE = /^(?:bearer|basic)\s+\S+/i; export interface AuditActor { username: string; roles?: string[]; ip?: string; userAgent?: string; } export interface AuditEvent { apiVersion: typeof KUBER_API_VERSION; kind: "AuditEvent"; metadata: ObjectMeta; spec: { actor: AuditActor; action: string; workspaceId?: string; operationId?: string; outcome: "success" | "failure" | "denied"; details?: unknown; }; } export interface AppendAuditEventInput { actor: AuditActor; action: string; workspaceId?: string; operationId?: string; outcome: AuditEvent["spec"]["outcome"]; details?: unknown; } /** Implementations expose append/list only: audit records are never updated or deleted. */ export interface AuditPersistence { append(event: AuditEvent): Promise; list(workspaceId?: string): Promise; } export interface AuditStore { append(input: AppendAuditEventInput): Promise; list(workspaceId?: string): Promise; } export class AuditValidationError extends Error { readonly code = "AUDIT_INVALID"; } function clone(value: T): T { return structuredClone(value); } export function redactAuditValue(value: unknown): unknown { if (typeof value === "string") { return SENSITIVE_VALUE.test(value) ? AUDIT_REDACTED : redactString(value); } if (Array.isArray(value)) return value.map(redactAuditValue); if (value && typeof value === "object") { const redacted: Record = {}; for (const [key, nested] of Object.entries(value)) { redacted[key] = SENSITIVE_KEY.test(key) ? AUDIT_REDACTED : redactAuditValue(nested); } return redacted; } return value; } export class RedactingAuditStore implements AuditStore { constructor( private readonly persistence: AuditPersistence, private readonly now: () => Date = () => new Date(), private readonly uid: () => string = randomUUID, ) {} async append(input: AppendAuditEventInput): Promise { if (!input.actor.username.trim() || !input.action.trim()) { throw new AuditValidationError("Audit actor and action are required"); } const id = this.uid(); const event: AuditEvent = { apiVersion: KUBER_API_VERSION, kind: "AuditEvent", metadata: { name: `audit-${id}`, uid: id, resourceVersion: "1", creationTimestamp: this.now().toISOString(), }, spec: { actor: redactAuditValue(input.actor) as AuditActor, action: input.action, ...(input.workspaceId && { workspaceId: input.workspaceId }), ...(input.operationId && { operationId: input.operationId }), outcome: input.outcome, ...(input.details !== undefined && { details: redactAuditValue(input.details), }), }, }; const serialized = JSON.stringify(event); if (Buffer.byteLength(serialized) > MAX_AUDIT_EVENT_BYTES) { throw new AuditValidationError("Audit event is too large"); } await this.persistence.append(event); return clone(event); } async list(workspaceId?: string) { return clone(await this.persistence.list(workspaceId)); } } export class MemoryAuditPersistence implements AuditPersistence { private readonly events: AuditEvent[] = []; async append(event: AuditEvent) { this.events.push(clone(event)); this.events.sort((a, b) => a.metadata.creationTimestamp.localeCompare(b.metadata.creationTimestamp), ); while (this.events.length > MAX_AUDIT_EVENTS) this.events.shift(); } async list(workspaceId?: string) { return this.events .filter((event) => workspaceId ? event.spec.workspaceId === workspaceId : true, ) .map(clone); } } export class MemoryAuditStore extends RedactingAuditStore { constructor(now?: () => Date, uid?: () => string) { super(new MemoryAuditPersistence(), now, uid); } }