feat(portal-bff): audit log foundation per ADR-0013 (#76)
CI / check (push) Successful in 1m52s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m7s
CI / a11y (push) Successful in 52s
CI / perf (push) Successful in 2m17s

## Summary

Lays down the append-only audit log per ADR-0013: schema declaration, first migration with role grants, NestJS `AuditWriter` service. Typed event-family methods, the separate `AUDIT_DATABASE_URL` pool, the retention job, and the live-DB integration tests are explicitly listed as "wired as features land" in the ADR's confirmation block — they ship when the matching feature ADRs do.

## What lands

**Prisma schema** ([`apps/portal-bff/prisma/schema.prisma`](apps/portal-bff/prisma/schema.prisma)):

- `multiSchema` preview enabled; datasource declares `public` + `audit` schemas.
- `AuditEvent` model: `id` (uuid), `createdAt`, `eventType` (free-form in v1), `audience` enum (`workforce | customer`), `actorIdHash`, `traceId`, `subject`, `outcome` enum (`success | failure | denied`), `payload` (jsonb).
- Indexes on `createdAt`, `eventType`, `traceId` — covering the three obvious query shapes.

**Migration** ([`prisma/migrations/*_init_audit_schema/migration.sql`](apps/portal-bff/prisma/migrations/20260510011453_init_audit_schema/migration.sql)):

- Standard Prisma `CREATE TABLE` / enums output, then the **append-only contract** re-applied explicitly:
  - `ALTER TABLE/TYPE OWNER TO audit_owner`.
  - `GRANT INSERT` to `audit_writer`, `SELECT` to `audit_reader`, **`SELECT, DELETE`** to `audit_archiver` (SELECT is needed to evaluate the `created_at` predicate of "delete older than retention" — Postgres requires SELECT on every column referenced in DELETE's WHERE).
  - `GRANT USAGE` on the enum types to all three roles (without it `audit_writer.INSERT` fails 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/`](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.
- `traceId` auto-resolved from the active OTel span (so audit row joins with traces and Pino logs on the same `trace_id`).
- `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".

**Tests** — 8 unit tests on `AuditWriter` (mocked Prisma + CLS): role-locking ordering, input pass-through, `Prisma.JsonNull` for missing payload, CLS-vs-input precedence on `actorIdHash`, OTel trace capture, error propagation.

## End-to-end verification (manual, against local-dev Postgres)

```
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: ok, row removed (after the SELECT-grant fix)
```

## ADR-0013 §Confirmation rewritten

Two-block split: "wired in foundation PR" lists what landed here; "wired as features land" lists the typed event-family methods, AUDIT_DATABASE_URL connection split, startup self-test probe, retention purge job, salt-shared cross-correlation test, and live-DB role-contract integration tests — each anchored to the feature ADR that triggers it.

## Recovery for anyone with a pre-existing local-dev DB

If your local-dev Postgres already had the audit migration applied **before** the SELECT-grant fix, the archiver's DELETE will fail. Two options:

1. Apply the missing grant directly:
   ```bash
   psql "$DATABASE_URL" -c "GRANT SELECT ON audit.events TO audit_archiver;"
   ```
2. Or wipe the volume and re-migrate cleanly:
   ```bash
   ./infra/local/dev.sh down -v
   ./infra/local/dev.sh up
   pnpm --filter @apf-portal/source exec prisma migrate deploy   # or `cd apps/portal-bff && pnpm exec prisma migrate deploy`
   ```

Fresh DBs land with the corrected migration directly.

## Out of scope (separate PRs)

- Typed event-family methods (`signIn`, `signInFailed`, …) — added per matching feature ADR.
- `AUDIT_DATABASE_URL` separate connection pool — defense-in-depth, when production needs it.
- Startup self-test probe (deliberate failing UPDATE asserting rejection) — lands with the connection split.
- Retention purge job (`audit_archiver` daily cron) — phase-3b infra.
- Live-DB integration tests asserting the role contract — Testcontainers-style harness, separate PR.

## Test plan

- [ ] CI green on this PR.
- [ ] `prisma migrate deploy` succeeds on a fresh DB (the recovery instructions cover the SELECT-grant gap for already-migrated dev DBs).
- [ ] `psql -c "\dp audit.events"` shows the expected privilege matrix: `audit_owner=arwdDxtm/audit_owner`, `audit_writer=a/audit_owner`, `audit_reader=r/audit_owner`, `audit_archiver=rd/audit_owner`.
- [ ] BFF boots; calling `AuditWriter.recordEvent` from a controller (manual smoke once a real flow lands) writes to `audit.events` with the expected `trace_id` matching the request's Jaeger span.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #76
This commit was merged in pull request #76.
This commit is contained in:
2026-05-10 03:44:01 +02:00
parent e2dd2e4dd8
commit 02ac44e498
9 changed files with 480 additions and 14 deletions
+7 -1
View File
@@ -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],
})
+15
View File
@@ -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;
}
}
+43
View File
@@ -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;
}