feat(portal-bff): admin audit-log read endpoint + audit_reader-locked path (#132)
CI / check (push) Successful in 2m49s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m11s
CI / a11y (push) Successful in 1m6s
CI / perf (push) Successful in 3m39s

## 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`.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #132
This commit was merged in pull request #132.
This commit is contained in:
2026-05-14 16:08:58 +02:00
parent 77343e3113
commit 261203ec5a
10 changed files with 803 additions and 2 deletions
@@ -0,0 +1,198 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from 'nestjs-prisma';
import { DEFAULT_LIMIT, MAX_LIMIT, type AdminAuditQueryDto } from './audit-query.dto';
/**
* A single audit row projected for SPA consumption. Naming matches
* the `AuditEvent` Prisma model but exposes ISO-string timestamps
* (`created_at` → ISO 8601) and `Record<string, unknown>` payloads —
* the SPA shouldn't have to know about Prisma `Json` runtime
* shapes.
*/
export interface AdminAuditEventDto {
readonly id: string;
readonly createdAt: string;
readonly eventType: string;
readonly audience: string;
readonly actorIdHash: string | null;
readonly traceId: string | null;
readonly subject: string | null;
readonly outcome: string;
readonly payload: Record<string, unknown> | null;
}
export interface AdminAuditPage {
readonly items: readonly AdminAuditEventDto[];
/**
* Total rows matching the filter, ignoring `limit` / `offset`.
* COUNT(*) on the filtered set — fine at v1 audit volume; if the
* table grows past a few million rows we'll switch to keyset
* pagination and drop the total.
*/
readonly total: number;
readonly limit: number;
readonly offset: number;
}
/**
* Raw shape returned by the underlying `$queryRawUnsafe`. Postgres
* returns timestamps as `Date` and json columns as already-parsed
* objects; the projection in {@link toDto} normalises both into
* SPA-friendly types.
*/
interface RawAuditRow {
id: string;
created_at: Date;
event_type: string;
audience: string;
actor_id_hash: string | null;
trace_id: string | null;
subject: string | null;
outcome: string;
payload: Record<string, unknown> | null;
}
/**
* `AuditReader` — single entry point for ADR-0013 audit-log reads.
*
* Contract
* --------
* - **Always runs under `audit_reader`.** Every query is wrapped in
* a transaction whose first statement is `SET LOCAL ROLE
* audit_reader`. That role has SELECT-only on `audit.events`
* (per the migration that created the schema); UPDATE / INSERT /
* DELETE all fail at the Postgres level even when the BFF's
* connection is otherwise privileged. The role resets at
* transaction end. Mirrors the `AuditWriter`'s role-locking
* posture for symmetry.
*
* - **Parameterised SQL only.** Filter values flow into the
* `$queryRawUnsafe` parameter array, never concatenated into the
* SQL string — defends against admin-side SQL injection through
* the audit-viewer filter form.
*
* - **No PII redaction here.** The payload JSON is returned as-is
* to admin callers. Per ADR-0013, redaction is the writer's
* responsibility; what landed in the row is what comes out. The
* admin role is the trust boundary.
*/
@Injectable()
export class AuditReader {
constructor(private readonly prisma: PrismaService) {}
async findEvents(filters: AdminAuditQueryDto): Promise<AdminAuditPage> {
const limit = filters.limit ?? DEFAULT_LIMIT;
const offset = filters.offset ?? 0;
// Hard cap at the DTO ceiling even if a future caller bypasses
// the ValidationPipe — defense in depth.
const safeLimit = Math.min(limit, MAX_LIMIT);
const { whereSql, params } = buildWhere(filters);
return this.prisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL ROLE audit_reader`);
const totalRows = await tx.$queryRawUnsafe<Array<{ count: bigint }>>(
`SELECT COUNT(*)::bigint AS count FROM "audit"."events" ${whereSql}`,
...params,
);
const total = Number(totalRows[0]?.count ?? 0n);
// Ordering is `created_at DESC, id DESC` so the SPA gets the
// newest events first (typical audit-viewer expectation) and
// ties on identical timestamps break deterministically on the
// uuid — preventing pagination drift if two events share a
// microsecond.
//
// Postgres parameter numbering continues from the WHERE clause
// (`params.length`), so LIMIT lands at `$N+1` and OFFSET at
// `$N+2`.
const items = await tx.$queryRawUnsafe<RawAuditRow[]>(
`SELECT id, created_at, event_type, audience, actor_id_hash,
trace_id, subject, outcome, payload
FROM "audit"."events"
${whereSql}
ORDER BY created_at DESC, id DESC
LIMIT $${params.length + 1}
OFFSET $${params.length + 2}`,
...params,
safeLimit,
offset,
);
return {
items: items.map(toDto),
total,
limit: safeLimit,
offset,
};
});
}
}
/**
* Build the parameterised WHERE clause + the matching positional
* parameter array. Every filter is optional; missing filters drop
* out of the SQL so the planner can pick the cheapest index.
*/
function buildWhere(filters: AdminAuditQueryDto): { whereSql: string; params: unknown[] } {
const clauses: string[] = [];
const params: unknown[] = [];
if (filters.eventType !== undefined) {
params.push(filters.eventType);
clauses.push(`event_type = $${params.length}`);
}
if (filters.actorIdHash !== undefined) {
params.push(filters.actorIdHash);
clauses.push(`actor_id_hash = $${params.length}`);
}
if (filters.audience !== undefined) {
params.push(filters.audience);
clauses.push(`audience = $${params.length}::"audit"."AuditAudience"`);
}
if (filters.outcome !== undefined) {
params.push(filters.outcome);
clauses.push(`outcome = $${params.length}::"audit"."AuditOutcome"`);
}
if (filters.subjectPrefix !== undefined) {
// `LIKE` with a literal escape so a `%` in the prefix doesn't
// act as a wildcard. The prefix bound is concatenated *to a
// parameter*, never to the SQL string — the parameter is the
// already-escaped literal followed by `%`.
params.push(`${escapeLikeLiteral(filters.subjectPrefix)}%`);
clauses.push(`subject LIKE $${params.length} ESCAPE '\\'`);
}
if (filters.createdAtFrom !== undefined) {
params.push(new Date(filters.createdAtFrom));
clauses.push(`created_at >= $${params.length}`);
}
if (filters.createdAtTo !== undefined) {
params.push(new Date(filters.createdAtTo));
clauses.push(`created_at < $${params.length}`);
}
const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
return { whereSql, params };
}
function escapeLikeLiteral(raw: string): string {
// `\` first, then the two LIKE meta-characters. Order matters —
// if `%`/`_` were escaped first their inserted `\` would be
// double-escaped on the second pass.
return raw.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
}
function toDto(row: RawAuditRow): AdminAuditEventDto {
return {
id: row.id,
createdAt: row.created_at.toISOString(),
eventType: row.event_type,
audience: row.audience,
actorIdHash: row.actor_id_hash,
traceId: row.trace_id,
subject: row.subject,
outcome: row.outcome,
payload: row.payload,
};
}