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"
|
||||
Reference in New Issue
Block a user