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:
@@ -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,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Get a free hosted Postgres database in seconds: `npx create-db`
|
||||
// Prisma schema for portal-bff.
|
||||
//
|
||||
// `multiSchema` preview is enabled because per ADR-0013 the audit log
|
||||
// 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 {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["multiSchema"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user