Files
kuber/server/audit-store.ts
T
2026-09-27 10:40:50 +00:00

149 lines
4.3 KiB
TypeScript

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<void>;
list(workspaceId?: string): Promise<AuditEvent[]>;
}
export interface AuditStore {
append(input: AppendAuditEventInput): Promise<AuditEvent>;
list(workspaceId?: string): Promise<AuditEvent[]>;
}
export class AuditValidationError extends Error {
readonly code = "AUDIT_INVALID";
}
function clone<T>(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<string, unknown> = {};
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<AuditEvent> {
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);
}
}