feat(portal-bff): audit log foundation per ADR-0013
Lays down the append-only audit log: schema, migration with role grants, NestJS AuditWriter service. Typed event-family methods, separate AUDIT_DATABASE_URL pool, retention job, and live-DB integration tests are explicitly listed as "wired as features land" in ADR-0013 §Confirmation. Schema (apps/portal-bff/prisma/schema.prisma): - multiSchema preview enabled; datasource declares public + audit schemas. - AuditEvent model with: id (uuid), createdAt, eventType (free-form in v1, will formalise into a catalogue once we have N stable types), audience (workforce|customer enum), actorIdHash, traceId, subject, outcome (success|failure|denied enum), payload (jsonb). - Indexes on createdAt, eventType, traceId — covering the obvious query shapes (date-range scan, by event family, by trace for log-audit correlation). Migration (prisma/migrations/*_init_audit_schema/migration.sql): - CREATE TABLE / enums via Prisma's standard output. - Append-only contract re-applied explicitly: ALTER TABLE / TYPE OWNER TO audit_owner, then GRANT INSERT to audit_writer, SELECT to audit_reader, SELECT+DELETE to audit_archiver. SELECT is required for archiver because Postgres needs SELECT on every column referenced in DELETE's WHERE clause to evaluate "older than retention" — granted explicitly in this PR rather than silently failing later. - USAGE on the enum types granted to all three roles so audit_ writer's INSERT does not fail with "permission denied for type". - No GRANT for UPDATE / TRUNCATE to anyone, including audit_owner at runtime — only fresh schema migrations amend the table. Service (apps/portal-bff/src/audit/): - AuditWriter.recordEvent(input) — single entry point. Wraps every INSERT in a transaction whose first statement is `SET LOCAL ROLE audit_writer`, so the role contract holds at runtime even from the otherwise-privileged BFF connection. SET LOCAL is reset on COMMIT/ROLLBACK so the pool's next consumer sees the original role. - traceId auto-resolved from the active OTel span context. - actorIdHash auto-resolved from CLS (key 'actorIdHash') with explicit input-side override; null when neither is set (placeholder until ADR-0009 / ADR-0010 guards populate CLS). - Errors propagate (no catch-and-swallow), per ADR-0013's "blocking writes: no audit ⇒ no action". - AuditModule exposes the writer; wired into AppModule so any future feature module can inject it. Tests (apps/portal-bff/src/audit/audit.service.spec.ts, 8 cases): - Locks the transaction to audit_writer before INSERTing. - Passes input fields through. - Records Prisma.JsonNull when no payload is provided. - Reads actorIdHash from CLS when not passed. - Prefers explicit actorIdHash over CLS-resolved. - Stores actorIdHash = null when neither input nor CLS has one. - Captures the active OTel trace id. - Stores traceId = null when no span is active. - Propagates the underlying error (no swallow). End-to-end smoke against local-dev Postgres (manual, via psql): - INSERT under audit_writer: ok. - UPDATE under audit_writer: "permission denied for table events". - DELETE under audit_writer: "permission denied for table events". - DELETE under audit_archiver (after the SELECT grant fix): ok, row removed. ADR-0013 §Confirmation rewritten as "wired in foundation PR" / "wired as features land", with the latter listing the typed event-family methods, AUDIT_DATABASE_URL split, startup self-test probe, retention purge job, salt-shared cross-correlation test, and live-DB role-contract integration tests.
This commit is contained in:
@@ -4,9 +4,15 @@ import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { ObservabilityModule } from '../observability/observability.module';
|
||||
import { HealthModule } from '../health/health.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
|
||||
@Module({
|
||||
imports: [ObservabilityModule, PrismaModule.forRoot({ isGlobal: true }), HealthModule],
|
||||
imports: [
|
||||
ObservabilityModule,
|
||||
PrismaModule.forRoot({ isGlobal: true }),
|
||||
AuditModule,
|
||||
HealthModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditWriter } from './audit.service';
|
||||
|
||||
/**
|
||||
* Provides the AuditWriter to the rest of the BFF. Imported globally
|
||||
* by AppModule so any feature module can inject it without an extra
|
||||
* import. The actual append-only contract is enforced by the
|
||||
* Postgres role grants set up in the audit-schema migration — see
|
||||
* apps/portal-bff/prisma/migrations/*_init_audit_schema.
|
||||
*/
|
||||
@Module({
|
||||
providers: [AuditWriter],
|
||||
exports: [AuditWriter],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { PrismaService } from 'nestjs-prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AuditWriter } from './audit.service';
|
||||
import type { AuditEventInput } from './audit.types';
|
||||
|
||||
interface AuditEventCreateCall {
|
||||
data: {
|
||||
eventType: string;
|
||||
audience: 'workforce' | 'customer';
|
||||
outcome: 'success' | 'failure' | 'denied';
|
||||
subject: string | null;
|
||||
actorIdHash: string | null;
|
||||
traceId: string | null;
|
||||
payload: Prisma.InputJsonValue | typeof Prisma.JsonNull;
|
||||
};
|
||||
}
|
||||
|
||||
interface MockTx {
|
||||
$executeRawUnsafe: jest.Mock;
|
||||
auditEvent: { create: jest.Mock };
|
||||
}
|
||||
|
||||
interface MockPrisma {
|
||||
$transaction: jest.Mock;
|
||||
tx: MockTx;
|
||||
}
|
||||
|
||||
function buildMocks(): { prisma: MockPrisma; cls: { get: jest.Mock } } {
|
||||
const tx: MockTx = {
|
||||
$executeRawUnsafe: jest.fn().mockResolvedValue(0),
|
||||
auditEvent: { create: jest.fn().mockResolvedValue(undefined) },
|
||||
};
|
||||
const prisma: MockPrisma = {
|
||||
tx,
|
||||
$transaction: jest.fn(async (fn: (tx: MockTx) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
const cls = { get: jest.fn() };
|
||||
return { prisma, cls };
|
||||
}
|
||||
|
||||
async function createSubject(): Promise<{
|
||||
writer: AuditWriter;
|
||||
prisma: MockPrisma;
|
||||
cls: { get: jest.Mock };
|
||||
}> {
|
||||
const { prisma, cls } = buildMocks();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuditWriter,
|
||||
{ provide: PrismaService, useValue: prisma },
|
||||
{ provide: ClsService, useValue: cls },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
return { writer: moduleRef.get(AuditWriter), prisma, cls };
|
||||
}
|
||||
|
||||
const baseInput: AuditEventInput = {
|
||||
eventType: 'auth.login',
|
||||
audience: 'workforce',
|
||||
outcome: 'success',
|
||||
subject: 'user:42',
|
||||
};
|
||||
|
||||
describe('AuditWriter', () => {
|
||||
it('locks the transaction to audit_writer before INSERTing', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.recordEvent(baseInput);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.tx.$executeRawUnsafe).toHaveBeenCalledWith('SET LOCAL ROLE audit_writer');
|
||||
// SET ROLE must precede the INSERT, otherwise the runtime
|
||||
// privilege check happens with the wrong role.
|
||||
const setRoleOrder = prisma.tx.$executeRawUnsafe.mock.invocationCallOrder[0];
|
||||
const createOrder = prisma.tx.auditEvent.create.mock.invocationCallOrder[0];
|
||||
expect(setRoleOrder).toBeLessThan(createOrder);
|
||||
});
|
||||
|
||||
it('passes the input fields through to the create call', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.recordEvent({
|
||||
...baseInput,
|
||||
payload: { route: '/auth/login', clientId: 'spa' },
|
||||
});
|
||||
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.eventType).toBe('auth.login');
|
||||
expect(call.data.audience).toBe('workforce');
|
||||
expect(call.data.outcome).toBe('success');
|
||||
expect(call.data.subject).toBe('user:42');
|
||||
expect(call.data.payload).toEqual({ route: '/auth/login', clientId: 'spa' });
|
||||
});
|
||||
|
||||
it('records Prisma.JsonNull when no payload is provided', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.recordEvent(baseInput);
|
||||
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.payload).toBe(Prisma.JsonNull);
|
||||
});
|
||||
|
||||
it('reads actorIdHash from CLS when not passed explicitly', async () => {
|
||||
const { writer, prisma, cls } = await createSubject();
|
||||
cls.get.mockReturnValue('hash-from-cls');
|
||||
|
||||
await writer.recordEvent(baseInput);
|
||||
|
||||
expect(cls.get).toHaveBeenCalledWith('actorIdHash');
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.actorIdHash).toBe('hash-from-cls');
|
||||
});
|
||||
|
||||
it('prefers an explicit actorIdHash over the CLS-resolved one', async () => {
|
||||
const { writer, prisma, cls } = await createSubject();
|
||||
cls.get.mockReturnValue('hash-from-cls');
|
||||
|
||||
await writer.recordEvent({ ...baseInput, actorIdHash: 'hash-from-input' });
|
||||
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.actorIdHash).toBe('hash-from-input');
|
||||
});
|
||||
|
||||
it('stores actorIdHash = null when neither input nor CLS has one', async () => {
|
||||
const { writer, prisma, cls } = await createSubject();
|
||||
cls.get.mockReturnValue(undefined);
|
||||
|
||||
await writer.recordEvent(baseInput);
|
||||
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.actorIdHash).toBeNull();
|
||||
});
|
||||
|
||||
it('captures the active OTel trace id', async () => {
|
||||
const fakeSpan = { spanContext: () => ({ traceId: 'abc123', spanId: 'def', traceFlags: 0 }) };
|
||||
const getActiveSpanSpy = jest.spyOn(trace, 'getActiveSpan').mockReturnValue(fakeSpan as never);
|
||||
|
||||
try {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.recordEvent(baseInput);
|
||||
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.traceId).toBe('abc123');
|
||||
} finally {
|
||||
getActiveSpanSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('stores traceId = null when no span is active', async () => {
|
||||
const getActiveSpanSpy = jest.spyOn(trace, 'getActiveSpan').mockReturnValue(undefined);
|
||||
|
||||
try {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.recordEvent(baseInput);
|
||||
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.traceId).toBeNull();
|
||||
} finally {
|
||||
getActiveSpanSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('propagates the underlying error — no catch-and-swallow', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
const dbError = new Error('permission denied for table events');
|
||||
prisma.tx.auditEvent.create.mockRejectedValueOnce(dbError);
|
||||
|
||||
await expect(writer.recordEvent(baseInput)).rejects.toThrow(
|
||||
'permission denied for table events',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { PrismaService } from 'nestjs-prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { AuditEventInput } from './audit.types';
|
||||
|
||||
/**
|
||||
* AuditWriter — single entry point for ADR-0013 audit-log writes.
|
||||
*
|
||||
* Contract
|
||||
* --------
|
||||
* - **Append-only at the database level.** Every write runs inside a
|
||||
* transaction whose first statement is `SET LOCAL ROLE
|
||||
* audit_writer`. That role only has `INSERT` on `audit.events`
|
||||
* (per the migration that created the table); `UPDATE`, `DELETE`,
|
||||
* `TRUNCATE` all fail at the Postgres level even if the BFF
|
||||
* connection is otherwise privileged. The role is reset
|
||||
* automatically at transaction end.
|
||||
*
|
||||
* - **Fail loud, never swallow.** Per ADR-0013 §"Blocking writes":
|
||||
* no audit ⇒ no action. Callers must propagate the rejection up
|
||||
* so the requested action does not proceed when its audit trail
|
||||
* cannot be written. The service throws the underlying Prisma
|
||||
* error unchanged; do not wrap it in a catch-and-log block.
|
||||
*
|
||||
* - **trace_id and actor_id_hash are auto-resolved.** trace_id is
|
||||
* read from the active OTel span context (so the audit row joins
|
||||
* with the BFF request span and the Pino log lines on the same
|
||||
* request). actor_id_hash is read from the CLS context populated
|
||||
* by future auth guards (ADR-0009 / ADR-0010); v1 stores `null`
|
||||
* when no actor is established. Callers can override either by
|
||||
* passing them on `AuditEventInput`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditWriter {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly cls: ClsService,
|
||||
) {}
|
||||
|
||||
async recordEvent(input: AuditEventInput): Promise<void> {
|
||||
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
|
||||
const actorIdHash =
|
||||
input.actorIdHash ?? this.cls.get<string | undefined>('actorIdHash') ?? null;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// Lock the connection to audit_writer for the duration of this
|
||||
// transaction. SET LOCAL is reset at COMMIT/ROLLBACK so the
|
||||
// pool's next consumer sees the original role.
|
||||
await tx.$executeRawUnsafe(`SET LOCAL ROLE audit_writer`);
|
||||
|
||||
await tx.auditEvent.create({
|
||||
data: {
|
||||
eventType: input.eventType,
|
||||
audience: input.audience,
|
||||
outcome: input.outcome,
|
||||
subject: input.subject ?? null,
|
||||
actorIdHash,
|
||||
traceId,
|
||||
payload: this.toJsonInput(input.payload),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Prisma's `Json` field accepts `Prisma.JsonNull` (null literal in
|
||||
// SQL JSONB) or a serialisable value; explicit `undefined` skips
|
||||
// the column. Map `payload` accordingly.
|
||||
private toJsonInput(
|
||||
payload: AuditEventInput['payload'],
|
||||
): Prisma.InputJsonValue | typeof Prisma.JsonNull {
|
||||
if (payload === undefined) return Prisma.JsonNull;
|
||||
return payload as Prisma.InputJsonValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Public type surface for the audit module — kept narrow so the
|
||||
* generated Prisma types do not leak through the rest of the BFF.
|
||||
*/
|
||||
|
||||
export type AuditAudience = 'workforce' | 'customer';
|
||||
export type AuditOutcome = 'success' | 'failure' | 'denied';
|
||||
|
||||
export interface AuditEventInput {
|
||||
/**
|
||||
* Free-form categorical identifier. Convention: lower-case dotted
|
||||
* verb-on-noun, e.g. `auth.login`, `dossier.update`. We keep it
|
||||
* free-form in v1; the catalogue is formalised when we have N
|
||||
* stable event types in production.
|
||||
*/
|
||||
eventType: string;
|
||||
|
||||
audience: AuditAudience;
|
||||
outcome: AuditOutcome;
|
||||
|
||||
/**
|
||||
* What the event is about — typically a domain entity URI like
|
||||
* `user:42` or `dossier:xyz`. Optional when the event is
|
||||
* system-wide and has no clear subject.
|
||||
*/
|
||||
subject?: string;
|
||||
|
||||
/**
|
||||
* Event-specific structured detail. **Caller-redacted PII** —
|
||||
* the audit module does not run a redact pass; it is the writer's
|
||||
* responsibility. Mirror of the Pino redact list in scope.
|
||||
*/
|
||||
payload?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Override of the auto-resolved actor id hash. Normally left
|
||||
* undefined — the service reads from the CLS context (set by
|
||||
* future auth interceptors per ADR-0009 / ADR-0010). Pass this
|
||||
* only when emitting events from a context where CLS is not
|
||||
* populated (background jobs, future cron).
|
||||
*/
|
||||
actorIdHash?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user