feat(api): add redacted audit read endpoints
This commit is contained in:
@@ -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<string, unknown> = {}): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<T extends object>(value: T): Readonly<T> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,9 @@ describe('buildControlPlaneApp', () => {
|
|||||||
const jobs = await app.inject({ method: 'GET', url: '/api/v1/jobs' });
|
const jobs = await app.inject({ method: 'GET', url: '/api/v1/jobs' });
|
||||||
expect(jobs.statusCode).toBe(200);
|
expect(jobs.statusCode).toBe(200);
|
||||||
expect(jobs.json()).toEqual({ items: [], page: { page: 1, pageSize: 25, total: 0 } });
|
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();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { registerInstanceRoutes } from './interface/http/instance-routes.js';
|
|||||||
import { registerEventRoutes } from './interface/http/event-routes.js';
|
import { registerEventRoutes } from './interface/http/event-routes.js';
|
||||||
import { JobQueryService } from './application/jobs/job-query-service.js';
|
import { JobQueryService } from './application/jobs/job-query-service.js';
|
||||||
import { registerJobRoutes } from './interface/http/job-routes.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 {
|
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||||
request: UpstreamSessionClientOptions['request'];
|
request: UpstreamSessionClientOptions['request'];
|
||||||
@@ -45,6 +47,7 @@ export interface ControlPlaneApp extends FastifyInstance {
|
|||||||
export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlaneApp {
|
export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlaneApp {
|
||||||
const eventJournal = new EventJournal(options.db);
|
const eventJournal = new EventJournal(options.db);
|
||||||
const jobs = new JobQueryService(options.db);
|
const jobs = new JobQueryService(options.db);
|
||||||
|
const audit = new AuditQueryService(options.db);
|
||||||
const instances = options.now
|
const instances = options.now
|
||||||
? new InstanceService({ db: options.db, store: options.store, now: options.now })
|
? new InstanceService({ db: options.db, store: options.store, now: options.now })
|
||||||
: new InstanceService({ db: options.db, store: options.store });
|
: new InstanceService({ db: options.db, store: options.store });
|
||||||
@@ -90,6 +93,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
|||||||
});
|
});
|
||||||
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
|
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
|
||||||
registerJobRoutes(app, { jobs });
|
registerJobRoutes(app, { jobs });
|
||||||
|
registerAuditRoutes(app, { audit });
|
||||||
registerEventRoutes(app, {
|
registerEventRoutes(app, {
|
||||||
journal: eventJournal,
|
journal: eventJournal,
|
||||||
...(options.authenticateEventStream
|
...(options.authenticateEventStream
|
||||||
|
|||||||
@@ -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<string, string | string[] | number | undefined>;
|
||||||
|
json(): unknown;
|
||||||
|
},
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
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 =
|
||||||
|
<T>(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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user