feat(portal-bff): admin audit-log read endpoint + audit_reader-locked path #132

Merged
julien merged 1 commits from feat/portal-bff-admin-audit-query-endpoint into main 2026-05-14 16:09:00 +02:00
Owner

Summary

First real consumer of the admin module — GET /api/admin/audit, the paginated audit-log viewer named in ADR-0020's v1 catalogue. Gated by @RequireAdmin, reads through the audit_reader Postgres role only, and emits admin.audit.query on every call as the "fishing expedition" deterrent ADR-0020 calls out (§"Read actions are also captured … to deter fishing expeditions"). This is the BFF half of the audit-viewer chantier — the SPA screen lands later.

What ships

AuditReader

  • Wraps every read in a transaction whose first statement is SET LOCAL ROLE audit_reader. Symmetric with AuditWriter's SET LOCAL ROLE audit_writer: the runtime role contract holds even when the BFF connection is otherwise privileged. UPDATE / INSERT / DELETE on audit.events fail at the Postgres level regardless of what gets through the application layer.
  • Parameterised SELECT only — filter values flow into $queryRawUnsafe's positional params, never concatenated into the SQL string. Subject-prefix filter uses LIKE with explicit ESCAPE '\\' and escapes % / _ / \ in the literal so an admin-side wildcard can't masquerade as a meta-character.
  • COUNT(*) + LIMIT / OFFSET pagination. Ordering is created_at DESC, id DESC for deterministic page boundaries on identical timestamps (UUIDs break ties).
  • Hard caps limit at MAX_LIMIT (200) even if a caller bypasses the DTO — defense in depth on the BFF's event loop.

AdminAuditQueryDto

Filter Type Notes
eventType string ≤128 Exact match (e.g. auth.sign_in).
actorIdHash string ≤128 Exact match on the salted hash from the writer.
audience enum workforce | customer.
outcome enum success | failure | denied.
subjectPrefix string ≤128 LIKE 'prefix%', escaped literal.
createdAtFrom ISO-8601 Inclusive lower bound.
createdAtTo ISO-8601 Exclusive upper bound.
limit int 1..200 Default 50.
offset int ≥0 Default 0.

Bound through Nest's global ValidationPipe — unknown query keys are rejected by forbidNonWhitelisted (defends against query-string smuggling), transform: true coerces numeric strings into numbers.

AdminAuditController

@Controller('admin/audit') + @RequireAdmin() at the class level. The handler:

  1. Calls AuditReader.findEvents(filters).
  2. Emits admin.audit.query with { filters, resultCount } so a reviewer can see exactly what the admin searched for and how many rows came back.
  3. Returns the page to the SPA.

@RequireMfa({ freshness: 600 }) is intentionally not applied in v1: the admin surface already sits behind a freshly-MFA'd session at sign-in (per ADR-0020), and the per-query audit row is the deterrent. Adding @RequireMfa later is a one-line change — that's why the decorator was designed-in by PR #128.

AuditWriter.adminAuditQuery()

New typed method using outcome=success. The read happened regardless of whether it matched rows; row count lives in resultCount. An outcome=denied from this surface is reserved for the day we add per-row authZ.

Operational notes

  • Dev pool, SET LOCAL ROLE pattern (per ADR-0018): the BFF talks to Postgres on the shared DATABASE_URL pool and switches role per-transaction. In production the audit-write pool is already split via AUDIT_DATABASE_URL; a dedicated audit_reader-only pool is a future follow-up if read-side isolation is desired (the role-locking on the shared pool already prevents privilege bleed at the Postgres level).
  • COUNT(*) is fine at v1 audit volume; if the table grows past a few million rows we'll switch to keyset pagination and drop the total.

Test plan

  • pnpm nx test portal-bff308 specs pass (was 278; +30: AuditReader 10, AdminAuditController 6, AuditWriter typed event 2, DTO validation 12).
  • pnpm exec nx affected -t format:check lint test build --base=origin/main — clean (the pre-existing _res / _next warnings in rate-limit.middleware.ts are unrelated).
  • SQL-injection probe via fixture ('; DROP TABLE events; -- as eventType) — value lands in the params array, SQL stays templated.
  • LIKE escaping verified: %, _, \ in subjectPrefix are escaped to their literal form.
  • e2e — pending the admin SPA + at least one admin Entra role assignment. Once both exist: curl --cookie 'portal_admin_session=…' /api/admin/audit?eventType=auth.sign_in&limit=10 returns the most recent sign-ins and psql shows the matching admin.audit.query row in audit.events.
## Summary First real consumer of the admin module — `GET /api/admin/audit`, the paginated audit-log viewer named in [ADR-0020](docs/decisions/0020-portal-admin-app.md)'s v1 catalogue. Gated by `@RequireAdmin`, reads through the `audit_reader` Postgres role only, and emits `admin.audit.query` on every call as the "fishing expedition" deterrent ADR-0020 calls out (§"Read actions are also captured … to deter fishing expeditions"). This is the BFF half of the audit-viewer chantier — the SPA screen lands later. ## What ships ### [`AuditReader`](apps/portal-bff/src/admin/audit-reader.service.ts) - Wraps every read in a transaction whose first statement is `SET LOCAL ROLE audit_reader`. Symmetric with `AuditWriter`'s `SET LOCAL ROLE audit_writer`: the runtime role contract holds even when the BFF connection is otherwise privileged. UPDATE / INSERT / DELETE on `audit.events` fail at the Postgres level regardless of what gets through the application layer. - Parameterised SELECT only — filter values flow into `$queryRawUnsafe`'s positional params, never concatenated into the SQL string. Subject-prefix filter uses `LIKE` with explicit `ESCAPE '\\'` and escapes `%` / `_` / `\` in the literal so an admin-side wildcard can't masquerade as a meta-character. - COUNT(\*) + `LIMIT` / `OFFSET` pagination. Ordering is `created_at DESC, id DESC` for deterministic page boundaries on identical timestamps (UUIDs break ties). - Hard caps `limit` at `MAX_LIMIT` (200) even if a caller bypasses the DTO — defense in depth on the BFF's event loop. ### [`AdminAuditQueryDto`](apps/portal-bff/src/admin/audit-query.dto.ts) | Filter | Type | Notes | | --- | --- | --- | | `eventType` | string ≤128 | Exact match (e.g. `auth.sign_in`). | | `actorIdHash` | string ≤128 | Exact match on the salted hash from the writer. | | `audience` | enum | `workforce` \| `customer`. | | `outcome` | enum | `success` \| `failure` \| `denied`. | | `subjectPrefix` | string ≤128 | `LIKE 'prefix%'`, escaped literal. | | `createdAtFrom` | ISO-8601 | Inclusive lower bound. | | `createdAtTo` | ISO-8601 | Exclusive upper bound. | | `limit` | int 1..200 | Default **50**. | | `offset` | int ≥0 | Default **0**. | Bound through Nest's global `ValidationPipe` — unknown query keys are rejected by `forbidNonWhitelisted` (defends against query-string smuggling), `transform: true` coerces numeric strings into numbers. ### [`AdminAuditController`](apps/portal-bff/src/admin/admin-audit.controller.ts) `@Controller('admin/audit')` + `@RequireAdmin()` at the class level. The handler: 1. Calls `AuditReader.findEvents(filters)`. 2. Emits `admin.audit.query` with `{ filters, resultCount }` so a reviewer can see exactly what the admin searched for and how many rows came back. 3. Returns the page to the SPA. `@RequireMfa({ freshness: 600 })` is **intentionally not applied** in v1: the admin surface already sits behind a freshly-MFA'd session at sign-in (per ADR-0020), and the per-query audit row is the deterrent. Adding `@RequireMfa` later is a one-line change — that's why the decorator was designed-in by PR #128. ### [`AuditWriter.adminAuditQuery()`](apps/portal-bff/src/audit/audit.service.ts) New typed method using `outcome=success`. The read **happened** regardless of whether it matched rows; row count lives in `resultCount`. An `outcome=denied` from this surface is reserved for the day we add per-row authZ. ## Operational notes - **Dev pool, `SET LOCAL ROLE` pattern** (per [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md)): the BFF talks to Postgres on the shared `DATABASE_URL` pool and switches role per-transaction. In production the audit-write pool is already split via `AUDIT_DATABASE_URL`; a dedicated `audit_reader`-only pool is a future follow-up if read-side isolation is desired (the role-locking on the shared pool already prevents privilege bleed at the Postgres level). - COUNT(\*) is fine at v1 audit volume; if the table grows past a few million rows we'll switch to keyset pagination and drop the total. ## Test plan - [x] `pnpm nx test portal-bff` — **308 specs pass** (was 278; +30: AuditReader 10, AdminAuditController 6, AuditWriter typed event 2, DTO validation 12). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean (the pre-existing `_res` / `_next` warnings in `rate-limit.middleware.ts` are unrelated). - [x] SQL-injection probe via fixture (`'; DROP TABLE events; --` as `eventType`) — value lands in the params array, SQL stays templated. - [x] LIKE escaping verified: `%`, `_`, `\` in `subjectPrefix` are escaped to their literal form. - [ ] e2e — pending the admin SPA + at least one `admin` Entra role assignment. Once both exist: `curl --cookie 'portal_admin_session=…' /api/admin/audit?eventType=auth.sign_in&limit=10` returns the most recent sign-ins and `psql` shows the matching `admin.audit.query` row in `audit.events`.
julien added 1 commit 2026-05-14 16:08:41 +02:00
feat(portal-bff): admin audit-log read endpoint + audit_reader-locked path
CI / commits (pull_request) Successful in 2m58s
CI / scan (pull_request) Successful in 2m59s
CI / check (pull_request) Successful in 3m10s
CI / a11y (pull_request) Successful in 2m47s
CI / perf (pull_request) Successful in 5m56s
707ff25ee0
First real consumer of the admin module — paginated audit-log viewer
per ADR-0020's v1 catalogue, gated by @RequireAdmin and emitting
admin.audit.query on every read as the "fishing expedition" deterrent
called out in ADR-0020 §"Audit log captures … Read actions are also
captured … to deter fishing expeditions".

What ships

- AuditReader (apps/portal-bff/src/admin/audit-reader.service.ts):
  - SET LOCAL ROLE audit_reader inside the transaction — symmetric
    with AuditWriter's SET LOCAL ROLE audit_writer. The runtime
    role contract holds even when the BFF connection is otherwise
    privileged; UPDATE/INSERT/DELETE on audit.events fail at the
    Postgres level.
  - Parameterised SELECT only — never concatenates filter values
    into SQL. Subject prefix uses LIKE with explicit ESCAPE and
    escapes %/_ in the literal so an admin-side wildcard can't
    masquerade as a meta-character.
  - COUNT(*) + LIMIT/OFFSET pagination. Order: created_at DESC,
    id DESC for deterministic page boundaries on identical
    timestamps.
  - Hard caps limit at MAX_LIMIT (200) even if a caller bypasses
    the DTO — defense in depth on the BFF's event loop.

- AdminAuditQueryDto (apps/portal-bff/src/admin/audit-query.dto.ts):
  - Filters: eventType, actorIdHash, audience, outcome,
    subjectPrefix, createdAtFrom/createdAtTo (ISO-8601).
  - Pagination: limit (1..200, default 50), offset (default 0).
  - Wired through Nest's global ValidationPipe so unknown query keys
    are rejected (forbidNonWhitelisted) and limit is bounded.

- AdminAuditController:
  - GET /api/admin/audit, @RequireAdmin at the class level.
  - Forwards the validated DTO to AuditReader, then emits
    admin.audit.query with { filters, resultCount } as payload so a
    reviewer can see what the admin searched for.
  - @RequireMfa intentionally NOT applied in v1: the admin surface
    already sits behind a freshly-MFA'd session at sign-in, and the
    per-query audit row is the deterrent. Adding @RequireMfa later
    is a one-line change.

- AuditWriter gains adminAuditQuery() typed method emitting the
  event with outcome=success (the read happened; whether it
  returned rows is captured separately in resultCount).

Tests: +30 specs (AuditReader 10, AdminAuditController 6, AuditWriter
typed event 2, DTO validation 12). All 308 BFF specs pass.
julien merged commit 261203ec5a into main 2026-05-14 16:09:00 +02:00
julien deleted branch feat/portal-bff-admin-audit-query-endpoint 2026-05-14 16:09:01 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#132