feat(portal-bff): audit log foundation per ADR-0013 (#76)
## 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:
@@ -0,0 +1,67 @@
|
|||||||
|
-- CreateSchema
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "audit";
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "audit"."AuditAudience" AS ENUM ('workforce', 'customer');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "audit"."AuditOutcome" AS ENUM ('success', 'failure', 'denied');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "audit"."events" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"event_type" TEXT NOT NULL,
|
||||||
|
"audience" "audit"."AuditAudience" NOT NULL,
|
||||||
|
"actor_id_hash" TEXT,
|
||||||
|
"trace_id" TEXT,
|
||||||
|
"subject" TEXT,
|
||||||
|
"outcome" "audit"."AuditOutcome" NOT NULL,
|
||||||
|
"payload" JSONB,
|
||||||
|
|
||||||
|
CONSTRAINT "events_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "events_created_at_idx" ON "audit"."events"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "events_event_type_idx" ON "audit"."events"("event_type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "events_trace_id_idx" ON "audit"."events"("trace_id");
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Append-only contract per ADR-0013
|
||||||
|
-- ============================================================
|
||||||
|
-- The schema-level DEFAULT PRIVILEGES set in
|
||||||
|
-- infra/local/init/postgres/01-init.sql only fire when audit_owner
|
||||||
|
-- creates the object. This migration runs as a privileged migrator
|
||||||
|
-- (the same login that owns the public schema), so the table is
|
||||||
|
-- initially owned by that user and audit_writer/reader/archiver
|
||||||
|
-- have no grants. Re-apply the owner transfer + grants explicitly
|
||||||
|
-- so the runtime role contract holds.
|
||||||
|
|
||||||
|
ALTER TABLE "audit"."events" OWNER TO audit_owner;
|
||||||
|
ALTER TYPE "audit"."AuditAudience" OWNER TO audit_owner;
|
||||||
|
ALTER TYPE "audit"."AuditOutcome" OWNER TO audit_owner;
|
||||||
|
|
||||||
|
GRANT INSERT ON "audit"."events" TO audit_writer;
|
||||||
|
GRANT SELECT ON "audit"."events" TO audit_reader;
|
||||||
|
-- audit_archiver needs both DELETE *and* SELECT: per ADR-0013 it
|
||||||
|
-- removes rows "older than retention", which means evaluating a
|
||||||
|
-- WHERE clause on `created_at`. Postgres requires SELECT on every
|
||||||
|
-- column referenced in DELETE's WHERE, even when the row count is
|
||||||
|
-- the only thing returned. SELECT here does NOT widen the contract
|
||||||
|
-- — UPDATE / TRUNCATE remain ungranted to every role.
|
||||||
|
GRANT SELECT, DELETE ON "audit"."events" TO audit_archiver;
|
||||||
|
|
||||||
|
-- USAGE on the enum types is needed by every role that touches the
|
||||||
|
-- column. Without it, audit_writer's INSERT fails with "permission
|
||||||
|
-- denied for type audit.AuditAudience".
|
||||||
|
GRANT USAGE ON TYPE "audit"."AuditAudience" TO audit_writer, audit_reader, audit_archiver;
|
||||||
|
GRANT USAGE ON TYPE "audit"."AuditOutcome" TO audit_writer, audit_reader, audit_archiver;
|
||||||
|
|
||||||
|
-- No GRANT for UPDATE / TRUNCATE to anyone, including audit_owner
|
||||||
|
-- at runtime — schema migrations alone amend the table going
|
||||||
|
-- forward.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -1,13 +1,84 @@
|
|||||||
// This is your Prisma schema file,
|
// Prisma schema for portal-bff.
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
//
|
||||||
|
// `multiSchema` preview is enabled because per ADR-0013 the audit log
|
||||||
// Get a free hosted Postgres database in seconds: `npx create-db`
|
// lives in its own `audit` schema with role-based append-only access
|
||||||
|
// (audit_owner / audit_writer / audit_reader / audit_archiver). The
|
||||||
|
// public schema holds the regular business data; only audit.events
|
||||||
|
// lives in audit.
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["multiSchema"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
|
schemas = ["public", "audit"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Audit log (per ADR-0013)
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Append-only by Postgres role grants — the schema, roles, and
|
||||||
|
// default privileges are provisioned by infra/local/init/postgres/
|
||||||
|
// 01-init.sql in dev and the equivalent production manifest. The
|
||||||
|
// migration that creates this table re-applies the grants
|
||||||
|
// explicitly and ALTERs the table owner to `audit_owner` so the
|
||||||
|
// runtime role contract holds even when the migration runs as a
|
||||||
|
// privileged migrator account.
|
||||||
|
//
|
||||||
|
// At runtime, the BFF wraps every INSERT into this table in a
|
||||||
|
// transaction that begins with `SET LOCAL ROLE audit_writer`, so
|
||||||
|
// even a compromised BFF connection cannot UPDATE / TRUNCATE /
|
||||||
|
// DELETE — those grants are not on `audit_writer`.
|
||||||
|
|
||||||
|
enum AuditAudience {
|
||||||
|
workforce
|
||||||
|
customer
|
||||||
|
|
||||||
|
@@schema("audit")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuditOutcome {
|
||||||
|
success
|
||||||
|
failure
|
||||||
|
denied
|
||||||
|
|
||||||
|
@@schema("audit")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditEvent {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
eventType String @map("event_type")
|
||||||
|
audience AuditAudience
|
||||||
|
// Salted hash (LOG_USER_ID_SALT) of the actor's stable id. NULL
|
||||||
|
// when the actor is unauthenticated (e.g. failed login attempt
|
||||||
|
// before resolving an identity). The same salt is used by the
|
||||||
|
// BFF Pino logger so audit and app logs cross-correlate on this
|
||||||
|
// field.
|
||||||
|
actorIdHash String? @map("actor_id_hash")
|
||||||
|
// W3C trace id (32 hex chars) of the request that produced the
|
||||||
|
// event. Cross-correlates with traces in Jaeger and with Pino
|
||||||
|
// log lines that carry the same `trace_id` field. NULL only if
|
||||||
|
// the event was emitted outside any inbound request (e.g. a
|
||||||
|
// future cron job).
|
||||||
|
traceId String? @map("trace_id")
|
||||||
|
// Free-form identifier of what the event is *about* — typically
|
||||||
|
// a domain entity URI like `user:42` or `dossier:xyz`. NULL when
|
||||||
|
// the event is system-wide and has no clear subject.
|
||||||
|
subject String?
|
||||||
|
outcome AuditOutcome
|
||||||
|
// Event-specific structured detail. Redaction of PII is the
|
||||||
|
// caller's responsibility; the BFF Pino redact list is the
|
||||||
|
// reference allow-/deny-list.
|
||||||
|
payload Json?
|
||||||
|
|
||||||
|
@@map("events")
|
||||||
|
@@schema("audit")
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([eventType])
|
||||||
|
@@index([traceId])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import { AppController } from './app.controller';
|
|||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { ObservabilityModule } from '../observability/observability.module';
|
import { ObservabilityModule } from '../observability/observability.module';
|
||||||
import { HealthModule } from '../health/health.module';
|
import { HealthModule } from '../health/health.module';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ObservabilityModule, PrismaModule.forRoot({ isGlobal: true }), HealthModule],
|
imports: [
|
||||||
|
ObservabilityModule,
|
||||||
|
PrismaModule.forRoot({ isGlobal: true }),
|
||||||
|
AuditModule,
|
||||||
|
HealthModule,
|
||||||
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
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;
|
||||||
|
}
|
||||||
@@ -156,14 +156,25 @@ Hooks for **admin actions** and **sensitive data access** are designed-in: the w
|
|||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
- `apps/portal-bff/prisma/schema.prisma` enables `multiSchema` and declares the `audit` schema; the `AuditEvent` model and `AuditOutcome` enum live in it.
|
**Wired in the foundation PR:**
|
||||||
- Three Postgres roles exist: `audit_writer` (INSERT only on `audit.events`), `audit_reader` (SELECT only), `audit_archiver` (DELETE only, on rows older than the configured retention). The schema migration includes the explicit `REVOKE UPDATE, DELETE, TRUNCATE FROM PUBLIC` on `audit.events` and the targeted grants.
|
|
||||||
- `apps/portal-bff/src/audit/audit.module.ts` provides an `AuditService` with one typed method per event family (`signIn`, `signInFailed`, `signOut`, `sessionExpired`, `sessionRevoked`, `tokenValidationFailed`, `mfaAssertionFailed`, `authzDeny`, plus dormant `adminAction` and `sensitiveDataAccess`).
|
- `apps/portal-bff/prisma/schema.prisma` enables the `multiSchema` preview, declares the `audit` schema alongside `public`, and carries the `AuditEvent` model with `AuditAudience` (`workforce | customer`) and `AuditOutcome` (`success | failure | denied`) enums.
|
||||||
- `AuditService` connects via `AUDIT_DATABASE_URL` as `audit_writer`; a startup probe asserts that the role can `INSERT` and cannot `UPDATE` (a deliberate failing UPDATE during boot is rejected, the BFF starts; if it succeeds, the BFF refuses to start).
|
- The migration `prisma/migrations/*_init_audit_schema/migration.sql` creates `audit.events`, `ALTER`s table + enum types to be owned by `audit_owner`, and re-applies the role grants explicitly: `INSERT` to `audit_writer`, `SELECT` to `audit_reader`, `SELECT, DELETE` to `audit_archiver` (SELECT is needed for archiver to evaluate the `created_at` predicate of "delete older than retention"). No grant of `UPDATE` or `TRUNCATE` to anyone — including the migrator's own login at runtime; only fresh schema migrations amend the table.
|
||||||
- The auth controller, token-validation interceptor, MFA guard, sessions module, and authorization guards each call `AuditService` on the relevant outcomes. Tests assert one audit row per event.
|
- The roles themselves and the schema with default privileges are provisioned earlier by `infra/local/init/postgres/01-init.sql` (dev) — production replicates the same SQL via the future on-prem infrastructure ADR.
|
||||||
- The retention purge runs daily; failure raises an alert and emits `audit.retention.purge` with `outcome = failure`.
|
- `apps/portal-bff/src/audit/audit.service.ts` exposes a single `AuditWriter.recordEvent(input)` method. Every write runs in a transaction whose first statement is `SET LOCAL ROLE audit_writer`, so the runtime contract holds even if the BFF connection is otherwise privileged. `trace_id` is auto-resolved from the active OTel span; `actor_id_hash` is read from CLS or accepted as an explicit override (placeholder until ADR-0009 / ADR-0010 land their guards). Failures propagate — no catch-and-swallow, per "blocking writes: no audit ⇒ no action".
|
||||||
- The same `LOG_USER_ID_SALT` is used by app logs and by audit; an integration test asserts that the same user produces the same `actor_id_hash` in both streams.
|
- BFF connects via the shared `DATABASE_URL` (the role switch is per-transaction). A separate `AUDIT_DATABASE_URL` connection pool is the production hardening, deferred — see "wired as features land" below.
|
||||||
- `AUDIT_RETENTION_DAYS < 30` and missing required env vars prevent BFF startup.
|
- Smoke-tested end to end against the local-dev Postgres: `audit_writer` INSERTs successfully, fails on `UPDATE` and `DELETE`; `audit_archiver` SELECTs + DELETEs successfully.
|
||||||
|
|
||||||
|
**Wired as the corresponding features land:**
|
||||||
|
|
||||||
|
- One typed method per event family on `AuditWriter` — `signIn`, `signInFailed`, `signOut`, `sessionExpired`, `sessionRevoked`, `tokenValidationFailed`, `mfaAssertionFailed`, `authzDeny`, `adminAction`, `sensitiveDataAccess` — added as the matching feature ships (ADR-0009 / ADR-0010 / ADR-0011 / future authz). v1 keeps the surface narrow with the single `recordEvent` so events can be emitted today (e.g. by the auth flow once it lands) while the typed catalogue accretes on a real basis.
|
||||||
|
- `AUDIT_DATABASE_URL` separate connection (a distinct pool with `audit_writer`-only login credentials) — defense-in-depth that locks down what an at-runtime SQL injection can do further. v1 mitigates with `SET LOCAL ROLE` at the cost of sharing the pool with public-schema reads/writes; v2 splits.
|
||||||
|
- Startup self-test probe — a deliberate failing `UPDATE` against `audit.events` during boot, asserting it is rejected; if it succeeds, the BFF refuses to start. Lands with the connection split above.
|
||||||
|
- Retention purge job invoking the `audit_archiver` role daily via cron, emitting `audit.retention.purge` with `outcome = failure` on error. Operational concern — phase-3b infra ADR.
|
||||||
|
- Auth controller, MFA guard, sessions module, and authorization guards each call `AuditWriter` on the relevant outcomes. Tests assert one audit row per event.
|
||||||
|
- Integration test verifying that the same user produces the same `actor_id_hash` in both Pino logs and audit rows (same `LOG_USER_ID_SALT`, same hashing path) — wired with the auth + LOG_USER_ID_SALT enforcement.
|
||||||
|
- `AUDIT_RETENTION_DAYS < 30` and missing required env vars prevent BFF startup — wired with the retention job.
|
||||||
|
- Live-DB integration tests asserting the role contract (UPDATE/TRUNCATE rejected at runtime) — Testcontainers harness, separate PR.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user