Files
apf_portal/apps
Julien Gautier 707ff25ee0
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
feat(portal-bff): admin audit-log read endpoint + audit_reader-locked path
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.
2026-05-14 16:07:02 +02:00
..