149 lines
4.3 KiB
TypeScript
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);
|
|
}
|
|
}
|