From 53d9229b45ea6661830e0bee004fc50107ba5f98 Mon Sep 17 00:00:00 2001 From: chick Date: Sat, 18 Jul 2026 00:44:23 +0800 Subject: [PATCH] feat(api): add redacted audit read endpoints --- .../audit/audit-query-service.test.ts | 159 +++++++++++++ .../application/audit/audit-query-service.ts | 223 ++++++++++++++++++ apps/api/src/control-plane.test.ts | 3 + apps/api/src/control-plane.ts | 4 + .../src/interface/http/audit-routes.test.ts | 149 ++++++++++++ apps/api/src/interface/http/audit-routes.ts | 152 ++++++++++++ 6 files changed, 690 insertions(+) create mode 100644 apps/api/src/application/audit/audit-query-service.test.ts create mode 100644 apps/api/src/application/audit/audit-query-service.ts create mode 100644 apps/api/src/interface/http/audit-routes.test.ts create mode 100644 apps/api/src/interface/http/audit-routes.ts diff --git a/apps/api/src/application/audit/audit-query-service.test.ts b/apps/api/src/application/audit/audit-query-service.test.ts new file mode 100644 index 0000000..e2b674d --- /dev/null +++ b/apps/api/src/application/audit/audit-query-service.test.ts @@ -0,0 +1,159 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { AuditQueryError, AuditQueryService } from './audit-query-service.js'; + +const t1 = '2026-07-17T10:00:00.000Z'; +const t2 = '2026-07-17T11:00:00.000Z'; +function setup() { + const db = new Database(':memory:'); + migrateDatabase(db); + return { db, subject: new AuditQueryService(db) }; +} +function insert(db: Database.Database, values: Record = {}): void { + const row = { + id: 'audit-1', + actor: 'actor-1', + operation: 'network.register-auto', + request: 'request-1', + summary: JSON.stringify([{ fieldId: 'password', displayValue: 'secret', redacted: true }]), + outcome: 'succeeded', + created: t1, + ...values, + }; + db.prepare( + `INSERT INTO audit_events + (id, instance_id, job_id, actor, operation_id, risk_level, request_id, + parameters_summary_json, result_code, duration_ms, created_at) + VALUES (?, NULL, NULL, ?, ?, 'R1', ?, ?, ?, 1, ?)`, + ).run(row.id, row.actor, row.operation, row.request, row.summary, row.outcome, row.created); +} +function expectCode(action: () => unknown, code: string): void { + try { + action(); + } catch (error) { + expect(error).toBeInstanceOf(AuditQueryError); + expect((error as AuditQueryError).code).toBe(code); + return; + } + throw new Error('expected AuditQueryError'); +} + +describe('AuditQueryService', () => { + it('projects the exact frozen contract and replaces persisted display values', () => { + const { db, subject } = setup(); + insert(db); + const event = subject.get('audit-1'); + expect(event).toEqual({ + id: 'audit-1', + occurredAt: t1, + actorId: 'actor-1', + action: 'network.register-auto', + outcome: 'succeeded', + requestId: 'request-1', + parameterSummary: [{ fieldId: 'password', displayValue: '[REDACTED]', redacted: true }], + }); + expect(Object.isFrozen(event)).toBe(true); + expect(Object.isFrozen(event.parameterSummary)).toBe(true); + expect(Object.isFrozen(event.parameterSummary![0])).toBe(true); + }); + + it.each(['succeeded', 'failed', 'partially-succeeded', 'denied'])( + 'accepts only the frozen outcome %s without mapping', + (outcome) => { + const { db, subject } = setup(); + insert(db, { outcome }); + expect(subject.get('audit-1').outcome).toBe(outcome); + }, + ); + + it('filters all fields, applies inclusive date bounds, and uses ascending id as tie-break', () => { + const { db, subject } = setup(); + insert(db, { id: 'z', created: t1 }); + insert(db, { id: 'a', created: t1 }); + insert(db, { id: 'other', actor: 'other', created: t2 }); + expect( + subject + .list({ + actorId: 'actor-1', + operationId: 'network.register-auto', + requestId: 'request-1', + outcome: 'succeeded', + occurredFrom: t1, + occurredTo: t1, + sort: 'occurredAt', + direction: 'desc', + }) + .items.map(({ id }) => id), + ).toEqual(['a', 'z']); + }); + + it('paginates in SQL with safe offset bounds', () => { + const { db, subject } = setup(); + insert(db, { id: 'a' }); + insert(db, { id: 'b', created: t2 }); + expect(subject.list({ page: 2, pageSize: 1 })).toMatchObject({ + items: [{ id: 'b' }], + page: { page: 2, pageSize: 1, total: 2 }, + }); + expect( + subject.list({ page: Math.floor(Number.MAX_SAFE_INTEGER / 100) + 1, pageSize: 100 }).items, + ).toEqual([]); + expectCode( + () => subject.list({ page: Math.floor(Number.MAX_SAFE_INTEGER / 100) + 2, pageSize: 100 }), + 'VALIDATION_FAILED', + ); + }); + + it.each([ + ['unknown outcome', { outcome: 'ok' }], + ['noncanonical timestamp', { created: '2026-07-17T10:00:00Z' }], + ['malformed summary', { summary: '{bad' }], + ['non-array summary', { summary: '{}' }], + ['false redaction', { summary: '[{"fieldId":"x","displayValue":"x","redacted":false}]' }], + ['missing key', { summary: '[{"fieldId":"x","redacted":true}]' }], + ['extra key', { summary: '[{"fieldId":"x","displayValue":"x","redacted":true,"x":1}]' }], + ])('fails the whole result as UNREPRESENTABLE for %s', (_name, values) => { + const { db, subject } = setup(); + insert(db, values); + expectCode(() => subject.list(), 'UNREPRESENTABLE'); + }); + + it('bounds summary collections at 100 and persisted strings at 200 characters', () => { + const atBound = setup(); + insert(atBound.db, { + actor: 'a'.repeat(200), + summary: JSON.stringify( + Array.from({ length: 100 }, (_, index) => ({ + fieldId: String(index), + displayValue: 'hidden', + redacted: true, + })), + ), + }); + expect(atBound.subject.get('audit-1').parameterSummary).toHaveLength(100); + const over = setup(); + insert(over.db, { + summary: JSON.stringify( + Array.from({ length: 101 }, (_, index) => ({ + fieldId: String(index), + displayValue: 'hidden', + redacted: true, + })), + ), + }); + expectCode(() => over.subject.get('audit-1'), 'UNREPRESENTABLE'); + const long = setup(); + insert(long.db, { actor: 'a'.repeat(201) }); + expectCode(() => long.subject.get('audit-1'), 'UNREPRESENTABLE'); + }); + + it('returns NOT_FOUND and rejects invalid service input', () => { + const { subject } = setup(); + expectCode(() => subject.get('missing'), 'NOT_FOUND'); + expectCode(() => subject.get(''), 'VALIDATION_FAILED'); + expectCode(() => subject.list({ page: 0 }), 'VALIDATION_FAILED'); + expectCode(() => subject.list({ occurredFrom: t2, occurredTo: t1 }), 'VALIDATION_FAILED'); + }); +}); diff --git a/apps/api/src/application/audit/audit-query-service.ts b/apps/api/src/application/audit/audit-query-service.ts new file mode 100644 index 0000000..f762556 --- /dev/null +++ b/apps/api/src/application/audit/audit-query-service.ts @@ -0,0 +1,223 @@ +import type Database from 'better-sqlite3'; +import { + AUDIT_OUTCOMES, + type AuditEvent, + type AuditPage, + type AuditPageQuery, + type RedactedParameterSummaryItem, +} from '@multi-simadmin/contracts'; + +export type AuditQueryErrorCode = 'VALIDATION_FAILED' | 'NOT_FOUND' | 'UNREPRESENTABLE'; + +export class AuditQueryError extends Error { + constructor( + readonly code: AuditQueryErrorCode, + message: string, + ) { + super(message); + this.name = 'AuditQueryError'; + } +} + +interface AuditRow { + id: unknown; + instance_id: unknown; + job_id: unknown; + actor: unknown; + operation_id: unknown; + request_id: unknown; + parameters_summary_json: unknown; + result_code: unknown; + created_at: unknown; +} + +const COLUMNS = + 'id, instance_id, job_id, actor, operation_id, request_id, parameters_summary_json, result_code, created_at'; +const CANONICAL_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; +const MAX_STRING_LENGTH = 200; +const MAX_PARAMETER_ITEMS = 100; + +function validation(message: string): never { + throw new AuditQueryError('VALIDATION_FAILED', message); +} +function fail(message: string): never { + throw new AuditQueryError('UNREPRESENTABLE', message); +} +function freeze(value: T): Readonly { + return Object.freeze(value); +} +function boundedString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.length < 1 || value.length > MAX_STRING_LENGTH) + fail(`Persisted audit ${field} is invalid`); + return value; +} +function optionalString(value: unknown, field: string): string | undefined { + return value === null ? undefined : boundedString(value, field); +} +function canonicalTimestamp(value: unknown, field: string): string { + const result = boundedString(value, field); + if (!CANONICAL_TIMESTAMP.test(result)) fail(`Persisted audit ${field} is not canonical`); + const parsed = new Date(result); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== result) + fail(`Persisted audit ${field} is invalid`); + return result; +} +function inputString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.length < 1 || value.length > MAX_STRING_LENGTH) + validation(`${field} is invalid`); + return value; +} +function inputTimestamp(value: unknown, field: string): string { + const result = inputString(value, field); + if (!CANONICAL_TIMESTAMP.test(result)) validation(`${field} must be a canonical timestamp`); + const parsed = new Date(result); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== result) + validation(`${field} must be a canonical timestamp`); + return result; +} +function positiveInteger(value: unknown, fallback: number, maximum: number, field: string): number { + const result = value ?? fallback; + if (!Number.isSafeInteger(result) || (result as number) < 1 || (result as number) > maximum) + validation(`${field} is invalid`); + return result as number; +} +function parameterSummary(value: unknown): readonly RedactedParameterSummaryItem[] { + if (typeof value !== 'string') fail('Persisted audit parameter summary is invalid'); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + fail('Persisted audit parameter summary is invalid'); + } + if (!Array.isArray(parsed) || parsed.length > MAX_PARAMETER_ITEMS) + fail('Persisted audit parameter summary is invalid'); + return freeze( + parsed.map((item): RedactedParameterSummaryItem => { + if (typeof item !== 'object' || item === null || Array.isArray(item)) + fail('Persisted audit parameter summary item is invalid'); + const row = item as Record; + const keys = Object.keys(row).sort(); + if ( + keys.length !== 3 || + keys[0] !== 'displayValue' || + keys[1] !== 'fieldId' || + keys[2] !== 'redacted' || + row.redacted !== true + ) + fail('Persisted audit parameter summary item is invalid'); + boundedString(row.displayValue, 'parameter displayValue'); + return freeze({ + fieldId: boundedString(row.fieldId, 'parameter fieldId'), + displayValue: '[REDACTED]', + redacted: true, + }); + }), + ); +} + +export class AuditQueryService { + constructor(private readonly db: Database.Database) {} + + get(id: string): AuditEvent { + inputString(id, 'eventId'); + const row = this.db.prepare(`SELECT ${COLUMNS} FROM audit_events WHERE id = ?`).get(id) as + | AuditRow + | undefined; + if (!row) throw new AuditQueryError('NOT_FOUND', 'Audit event was not found'); + return this.project(row); + } + + list(query: AuditPageQuery = {}): AuditPage { + const pageSize = positiveInteger(query.pageSize, 25, 100, 'pageSize'); + const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1; + const page = positiveInteger(query.page, 1, maximumPage, 'page'); + const sort = query.sort ?? 'occurredAt'; + if (sort !== 'occurredAt' && sort !== 'action' && sort !== 'outcome') + validation('sort is invalid'); + const direction = query.direction ?? 'asc'; + if (direction !== 'asc' && direction !== 'desc') validation('direction is invalid'); + + const clauses: string[] = []; + const parameters: unknown[] = []; + const equal = (column: string, value: unknown, field: string): void => { + if (value === undefined) return; + clauses.push(`${column} = ?`); + parameters.push(inputString(value, field)); + }; + equal('a.actor', query.actorId, 'actorId'); + equal('a.instance_id', query.instanceId, 'instanceId'); + equal('a.job_id', query.jobId, 'jobId'); + equal('a.operation_id', query.operationId, 'operationId'); + equal('a.request_id', query.requestId, 'requestId'); + if (query.outcome !== undefined) { + if (!(AUDIT_OUTCOMES as readonly string[]).includes(query.outcome)) + validation('outcome is invalid'); + equal('a.result_code', query.outcome, 'outcome'); + } + // Both date bounds are deliberately inclusive. + if (query.occurredFrom !== undefined) { + clauses.push('a.created_at >= ?'); + parameters.push(inputTimestamp(query.occurredFrom, 'occurredFrom')); + } + if (query.occurredTo !== undefined) { + clauses.push('a.created_at <= ?'); + parameters.push(inputTimestamp(query.occurredTo, 'occurredTo')); + } + if ( + query.occurredFrom !== undefined && + query.occurredTo !== undefined && + query.occurredFrom > query.occurredTo + ) + validation('occurredFrom must not be after occurredTo'); + + const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : ''; + const count = this.db + .prepare(`SELECT COUNT(*) AS total FROM audit_events a${where}`) + .get(...parameters) as { total: unknown } | undefined; + if ( + !count || + typeof count.total !== 'number' || + !Number.isSafeInteger(count.total) || + count.total < 0 + ) + fail('Persisted audit count is invalid'); + const sortColumn = { + occurredAt: 'a.created_at', + action: 'a.operation_id', + outcome: 'a.result_code', + }[sort]; + const offset = (page - 1) * pageSize; + const rows = this.db + .prepare( + `SELECT ${COLUMNS.split(', ') + .map((column) => `a.${column}`) + .join(', ')} + FROM audit_events a${where} + ORDER BY ${sortColumn} ${direction.toUpperCase()}, a.id ASC LIMIT ? OFFSET ?`, + ) + .all(...parameters, pageSize, offset) as AuditRow[]; + return freeze({ + items: freeze(rows.map((row) => this.project(row))), + page: freeze({ page, pageSize, total: count.total }), + }); + } + + private project(row: AuditRow): AuditEvent { + const outcome = boundedString(row.result_code, 'result_code'); + if (!(AUDIT_OUTCOMES as readonly string[]).includes(outcome)) + fail('Persisted audit result_code is unrepresentable'); + const instanceId = optionalString(row.instance_id, 'instance_id'); + const jobId = optionalString(row.job_id, 'job_id'); + return freeze({ + id: boundedString(row.id, 'id'), + occurredAt: canonicalTimestamp(row.created_at, 'created_at'), + actorId: boundedString(row.actor, 'actor'), + action: boundedString(row.operation_id, 'operation_id'), + outcome: outcome as AuditEvent['outcome'], + requestId: boundedString(row.request_id, 'request_id'), + ...(instanceId === undefined ? {} : { instanceId }), + ...(jobId === undefined ? {} : { jobId }), + parameterSummary: parameterSummary(row.parameters_summary_json), + }); + } +} diff --git a/apps/api/src/control-plane.test.ts b/apps/api/src/control-plane.test.ts index 250198e..9677496 100644 --- a/apps/api/src/control-plane.test.ts +++ b/apps/api/src/control-plane.test.ts @@ -46,6 +46,9 @@ describe('buildControlPlaneApp', () => { const jobs = await app.inject({ method: 'GET', url: '/api/v1/jobs' }); expect(jobs.statusCode).toBe(200); expect(jobs.json()).toEqual({ items: [], page: { page: 1, pageSize: 25, total: 0 } }); + const audit = await app.inject({ method: 'GET', url: '/api/v1/audit' }); + expect(audit.statusCode).toBe(200); + expect(audit.json()).toEqual({ items: [], page: { page: 1, pageSize: 25, total: 0 } }); await app.close(); }); diff --git a/apps/api/src/control-plane.ts b/apps/api/src/control-plane.ts index 42fe401..3ad7c32 100644 --- a/apps/api/src/control-plane.ts +++ b/apps/api/src/control-plane.ts @@ -26,6 +26,8 @@ import { registerInstanceRoutes } from './interface/http/instance-routes.js'; import { registerEventRoutes } from './interface/http/event-routes.js'; import { JobQueryService } from './application/jobs/job-query-service.js'; import { registerJobRoutes } from './interface/http/job-routes.js'; +import { AuditQueryService } from './application/audit/audit-query-service.js'; +import { registerAuditRoutes } from './interface/http/audit-routes.js'; export interface SafeControlPlaneUpstream extends ConnectionTransport { request: UpstreamSessionClientOptions['request']; @@ -45,6 +47,7 @@ export interface ControlPlaneApp extends FastifyInstance { export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlaneApp { const eventJournal = new EventJournal(options.db); const jobs = new JobQueryService(options.db); + const audit = new AuditQueryService(options.db); const instances = options.now ? new InstanceService({ db: options.db, store: options.store, now: options.now }) : new InstanceService({ db: options.db, store: options.store }); @@ -90,6 +93,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane }); registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion); registerJobRoutes(app, { jobs }); + registerAuditRoutes(app, { audit }); registerEventRoutes(app, { journal: eventJournal, ...(options.authenticateEventStream diff --git a/apps/api/src/interface/http/audit-routes.test.ts b/apps/api/src/interface/http/audit-routes.test.ts new file mode 100644 index 0000000..4368803 --- /dev/null +++ b/apps/api/src/interface/http/audit-routes.test.ts @@ -0,0 +1,149 @@ +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildApp } from '../../app.js'; +import { AuditQueryService } from '../../application/audit/audit-query-service.js'; +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { registerAuditRoutes } from './audit-routes.js'; + +const dbs: Database.Database[] = []; +const timestamp = '2026-07-17T10:00:00.000Z'; +afterEach(() => { + for (const db of dbs.splice(0)) db.close(); +}); +function fixture() { + const db = new Database(':memory:'); + migrateDatabase(db); + dbs.push(db); + const audit = new AuditQueryService(db); + const app = buildApp({ registerRoutes: (scope) => registerAuditRoutes(scope, { audit }) }); + return { app, db, audit }; +} +function insert(db: Database.Database, outcome = 'succeeded'): void { + db.prepare( + `INSERT INTO audit_events + (id, actor, operation_id, risk_level, request_id, parameters_summary_json, + result_code, duration_ms, created_at) + VALUES ('event-1', 'actor-1', 'op.one', 'R1', 'request-1', '[]', ?, 1, ?)`, + ).run(outcome, timestamp); +} +function exactProblem( + response: { + headers: Record; + json(): unknown; + }, + values: Record, +): void { + expect(response.headers['content-type']).toContain('application/problem+json'); + expect(response.headers['x-request-id']).toEqual(expect.any(String)); + expect(response.json()).toEqual({ + type: 'about:blank', + ...values, + requestId: response.headers['x-request-id'], + }); +} + +describe('audit HTTP read routes', () => { + it('lists and gets audit events with exact defaults', async () => { + const { app, db, audit } = fixture(); + insert(db); + const listCall = vi.spyOn(audit, 'list'); + const list = await app.inject({ method: 'GET', url: '/api/v1/audit' }); + expect(list.statusCode).toBe(200); + expect(listCall).toHaveBeenCalledWith({ page: 1, pageSize: 25, direction: 'asc' }); + expect(list.json()).toEqual({ + items: [ + { + id: 'event-1', + occurredAt: timestamp, + actorId: 'actor-1', + action: 'op.one', + outcome: 'succeeded', + requestId: 'request-1', + parameterSummary: [], + }, + ], + page: { page: 1, pageSize: 25, total: 1 }, + }); + const get = await app.inject({ method: 'GET', url: '/api/v1/audit/event-1' }); + expect(get.statusCode).toBe(200); + expect(get.json()).toEqual(list.json().items[0]); + await app.close(); + }); + + it('parses every allowlisted scalar query', async () => { + const { app, audit } = fixture(); + const call = vi.spyOn(audit, 'list'); + const response = await app.inject({ + method: 'GET', + url: `/api/v1/audit?page=2&pageSize=10&sort=action&direction=desc&actorId=a&instanceId=i&jobId=j&operationId=op&outcome=denied&occurredFrom=${timestamp}&occurredTo=${timestamp}&requestId=r`, + }); + expect(response.statusCode).toBe(200); + expect(call).toHaveBeenCalledWith({ + page: 2, + pageSize: 10, + sort: 'action', + direction: 'desc', + actorId: 'a', + instanceId: 'i', + jobId: 'j', + operationId: 'op', + outcome: 'denied', + occurredFrom: timestamp, + occurredTo: timestamp, + requestId: 'r', + }); + await app.close(); + }); + + it.each([ + 'unknown=x', + 'page=1&page=2', + 'actorId=a&actorId=b', + 'page=0', + 'page=01', + 'page=1.5', + 'page=9007199254740992', + 'pageSize=101', + 'sort=', + 'sort=id', + 'direction=ASC', + 'outcome=ok', + 'actorId=', + 'operationId=', + 'occurredFrom=bad', + `occurredFrom=2026-07-17T11%3A00%3A00.000Z&occurredTo=${timestamp}`, + ])('rejects invalid, repeated, unknown, or noncanonical query: %s', async (query) => { + const { app } = fixture(); + const response = await app.inject({ method: 'GET', url: `/api/v1/audit?${query}` }); + expect(response.statusCode).toBe(400); + exactProblem(response, { + title: 'Bad Request', + status: 400, + code: 'VALIDATION_FAILED', + detail: 'The audit query is invalid.', + }); + await app.close(); + }); + + it('returns exact redacted 404 and 503 problems', async () => { + const { app, db } = fixture(); + const missing = await app.inject({ method: 'GET', url: '/api/v1/audit/missing' }); + exactProblem(missing, { + title: 'Not Found', + status: 404, + code: 'NOT_FOUND', + detail: 'The requested audit event does not exist.', + }); + insert(db, 'secret-internal-result-code'); + const unavailable = await app.inject({ method: 'GET', url: '/api/v1/audit/event-1' }); + exactProblem(unavailable, { + title: 'Service Unavailable', + status: 503, + code: 'UNREPRESENTABLE', + detail: 'The requested audit data is temporarily unavailable.', + }); + expect(unavailable.body).not.toContain('secret-internal-result-code'); + await app.close(); + }); +}); diff --git a/apps/api/src/interface/http/audit-routes.ts b/apps/api/src/interface/http/audit-routes.ts new file mode 100644 index 0000000..35925c3 --- /dev/null +++ b/apps/api/src/interface/http/audit-routes.ts @@ -0,0 +1,152 @@ +import { AUDIT_OUTCOMES, type AuditOutcome, type AuditPageQuery } from '@multi-simadmin/contracts'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +import { AuditQueryError, AuditQueryService } from '../../application/audit/audit-query-service.js'; + +export interface AuditRoutesOptions { + readonly audit: AuditQueryService; +} + +const QUERY_KEYS = new Set([ + 'page', + 'pageSize', + 'sort', + 'direction', + 'actorId', + 'instanceId', + 'jobId', + 'operationId', + 'outcome', + 'occurredFrom', + 'occurredTo', + 'requestId', +]); +const CANONICAL_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; +const MAX_STRING_LENGTH = 200; +const responses = { + VALIDATION_FAILED: { + status: 400, + title: 'Bad Request', + detail: 'The audit query is invalid.', + }, + NOT_FOUND: { + status: 404, + title: 'Not Found', + detail: 'The requested audit event does not exist.', + }, + UNREPRESENTABLE: { + status: 503, + title: 'Service Unavailable', + detail: 'The requested audit data is temporarily unavailable.', + }, +} as const; + +function invalid(): never { + throw new AuditQueryError('VALIDATION_FAILED', 'Invalid audit query'); +} +function parseQuery(query: unknown): AuditPageQuery { + if (query === null || typeof query !== 'object' || Array.isArray(query)) invalid(); + const input = query as Record; + for (const key of Object.keys(input)) { + if (!QUERY_KEYS.has(key) || typeof input[key] !== 'string') invalid(); + } + const scalar = (key: string): string | undefined => { + const value = input[key]; + return value === undefined ? undefined : (value as string); + }; + const integer = (key: string, fallback: number, maximum: number): number => { + const value = scalar(key); + if (value === undefined) return fallback; + if (!/^[1-9]\d*$/u.test(value)) invalid(); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed > maximum) invalid(); + return parsed; + }; + const pageSize = integer('pageSize', 25, 100); + const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1; + const result: { + page: number; + pageSize: number; + sort?: 'occurredAt' | 'action' | 'outcome'; + direction: 'asc' | 'desc'; + actorId?: string; + instanceId?: string; + jobId?: string; + operationId?: string; + outcome?: AuditOutcome; + occurredFrom?: string; + occurredTo?: string; + requestId?: string; + } = { page: integer('page', 1, maximumPage), pageSize, direction: 'asc' }; + const sort = scalar('sort'); + if (sort !== undefined) { + if (sort !== 'occurredAt' && sort !== 'action' && sort !== 'outcome') invalid(); + result.sort = sort; + } + const direction = scalar('direction'); + if (direction !== undefined) { + if (direction !== 'asc' && direction !== 'desc') invalid(); + result.direction = direction; + } + const outcome = scalar('outcome'); + if (outcome !== undefined) { + if (!(AUDIT_OUTCOMES as readonly string[]).includes(outcome)) invalid(); + result.outcome = outcome as AuditOutcome; + } + for (const key of ['actorId', 'instanceId', 'jobId', 'operationId', 'requestId'] as const) { + const value = scalar(key); + if (value !== undefined) { + if (value.length < 1 || value.length > MAX_STRING_LENGTH) invalid(); + result[key] = value; + } + } + for (const key of ['occurredFrom', 'occurredTo'] as const) { + const value = scalar(key); + if (value !== undefined) { + const parsed = new Date(value); + if ( + !CANONICAL_TIMESTAMP.test(value) || + !Number.isFinite(parsed.getTime()) || + parsed.toISOString() !== value + ) + invalid(); + result[key] = value; + } + } + if ( + result.occurredFrom !== undefined && + result.occurredTo !== undefined && + result.occurredFrom > result.occurredTo + ) + invalid(); + return result; +} + +export function registerAuditRoutes(app: FastifyInstance, options: AuditRoutesOptions): void { + const handler = + (action: (request: FastifyRequest) => T) => + async (request: FastifyRequest, reply: FastifyReply) => { + try { + return action(request); + } catch (error) { + if (!(error instanceof AuditQueryError)) throw error; + const mapped = responses[error.code]; + return reply.code(mapped.status).type('application/problem+json').send({ + type: 'about:blank', + title: mapped.title, + status: mapped.status, + code: error.code, + detail: mapped.detail, + requestId: request.id, + }); + } + }; + app.get( + '/api/v1/audit', + handler((request) => options.audit.list(parseQuery(request.query))), + ); + app.get( + '/api/v1/audit/:eventId', + handler((request) => options.audit.get((request.params as { eventId: string }).eventId)), + ); +}