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:
@@ -156,14 +156,25 @@ Hooks for **admin actions** and **sensitive data access** are designed-in: the w
|
||||
|
||||
### Confirmation
|
||||
|
||||
- `apps/portal-bff/prisma/schema.prisma` enables `multiSchema` and declares the `audit` schema; the `AuditEvent` model and `AuditOutcome` enum live in it.
|
||||
- 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`).
|
||||
- `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 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 retention purge runs daily; failure raises an alert and emits `audit.retention.purge` with `outcome = failure`.
|
||||
- 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.
|
||||
- `AUDIT_RETENTION_DAYS < 30` and missing required env vars prevent BFF startup.
|
||||
**Wired in the foundation PR:**
|
||||
|
||||
- `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.
|
||||
- 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 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.
|
||||
- `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".
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user