Files
apf_portal/apps/portal-bff/src/admin/admin-audit.controller.ts
T
Julien Gautier 19001192f0
CI / scan (pull_request) Successful in 2m46s
CI / commits (pull_request) Successful in 2m45s
CI / check (pull_request) Successful in 4m45s
CI / a11y (pull_request) Successful in 1m54s
CI / perf (pull_request) Successful in 5m10s
feat(portal-bff): openapi spec + scalar api reference UI (dev-only)
Wires `@nestjs/swagger` for spec generation + `@scalar/nestjs-api-
reference` for the UI, mounted in dev only. Closes the "no API
visualization in dev" gap that was forcing curl + grep navigation
of the controller tree.

Routes (NODE_ENV !== 'production' only):

- `GET /api/openapi.json` — raw OpenAPI 3 document. External tools
  (Bruno, Insomnia, Postman) import from this URL. Served via a
  plain Express GET handler — no DTO, no guard, no middleware to
  thread through; the spec is a static asset.
- `GET /api/docs` — Scalar API Reference UI. Loads the spec from
  the JSON endpoint at render time. Clean, dark-mode-aware,
  searchable.

Implementation

- `apps/portal-bff/src/openapi/openapi.ts` exposes two functions:
  - `buildOpenApiDocument(app)` — wraps `SwaggerModule.create
    Document` with the project's title / version / two cookie auth
    schemes. Returns an `OpenAPIObject`. Public so the spec can be
    inspected in tests.
  - `setupOpenApi(app, globalPrefix)` — guards on `NODE_ENV`,
    mounts the two routes when allowed.

- Two cookie security schemes declared at build time:
  `portal_session` and `portal_admin_session`. Controllers
  annotate via `@ApiCookieAuth(<name>)`; Scalar shows the lock
  icon per endpoint.

- No bearer-auth scheme. The BFF never exposes a bearer surface
  — the SPA never holds tokens (ADR-0009), downstream OBO tokens
  are server-side only (ADR-0014).

Production gating

The setup function short-circuits on `NODE_ENV === 'production'`.
Exposing the spec in prod would hand an attacker a curated map of
every authenticated endpoint and every DTO shape — opt-in only,
not default-on. A future `OPENAPI_PUBLISH=true` env knob can
re-enable it for ops use-cases (internal gateway, partner
integrations); kept out of v1 to avoid the YAGNI knob.

Controllers decorated

- `auth (user portal)` (AuthController): login / callback / me /
  logout, /me + /logout marked `@ApiCookieAuth('portal_session')`.
- `auth (admin portal)` (AdminAuthController): same shape, admin
  cookie.
- `admin (self-test)`, `admin (audit log)`, `admin (user directory)`
  — all tagged + `@ApiCookieAuth('portal_admin_session')`.
- `health` (HealthController): tagged.
- `app (scaffolding)` (AppController): tagged so the leftover root
  route doesn't pollute the "default" group.

Each tagged controller method gets a one-line `@ApiOperation
summary` so Scalar lists them readably.

CSRF caveat documented in the API description

Try-it on POST/PUT/PATCH/DELETE needs the `X-CSRF-Token` header
echoed from the `portal_csrf` cookie (ADR-0009 §"Double-submit
CSRF"). The description spells this out so an admin curling
mutations from Scalar doesn't 403 mysteriously. v1 doesn't auto-
inject the header; future polish if the pattern becomes common.

Deps

- `@nestjs/swagger@^12` added as a direct dep.
- `@scalar/nestjs-api-reference@^1.1` added as a direct dep. Its
  transitive `@scalar/client-side-rendering` is ESM-only;
  `jest.config.cts`'s `transformIgnorePatterns` is widened to
  include `@scalar/` alongside the existing `jose` whitelist.

Tests: +5 specs (document title + version + securitySchemes,
smoke-controller path captured, prod short-circuit asserts no
side-effect, dev mount asserts the two route bindings).
2026-05-14 20:37:49 +02:00

65 lines
2.6 KiB
TypeScript

import { Controller, Get, Query, Req } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { AdminAuditQueryDto } from './audit-query.dto';
import { AuditReader, type AdminAuditPage } from './audit-reader.service';
import { RequireAdmin } from './require-admin.decorator';
/**
* `GET /api/admin/audit` — paginated audit-log viewer per
* ADR-0020's v1 admin module catalogue. Reads `audit.events` via
* the `audit_reader` Postgres role (locked inside the `AuditReader`
* service), shapes the result for SPA consumption, and emits an
* `admin.audit.query` row so the read itself is auditable per
* ADR-0020 §"Read actions are also captured … to deter fishing
* expeditions".
*
* The query DTO (`AdminAuditQueryDto`) carries every supported
* filter + pagination knob; Nest's global ValidationPipe rejects
* unknown keys before they reach this handler.
*
* `@RequireAdmin()` at the class level gates the route on the
* `admin` Entra app role per ADR-0020 §"Auth — same Entra ID".
* `@RequireMfa()` is not applied here in v1: the entire admin
* surface already sits behind a freshly-MFA'd session at the
* sign-in entry, and the audit row itself is the per-query
* deterrent. A future security review can layer `@RequireMfa`
* with a tighter freshness here without touching this code's
* shape — that's exactly why the decorator was designed-in.
*/
@ApiTags('admin (audit log)')
@ApiCookieAuth('portal_admin_session')
@Controller('admin/audit')
@RequireAdmin()
export class AdminAuditController {
constructor(
private readonly auditReader: AuditReader,
private readonly audit: AuditWriter,
) {}
@ApiOperation({
summary: 'Paginated audit-log query — emits `admin.audit.query` on every call',
})
@Get()
async list(@Req() req: Request, @Query() filters: AdminAuditQueryDto): Promise<AdminAuditPage> {
const page = await this.auditReader.findEvents(filters);
// Guard guarantees `req.session.user`; we still defensively
// narrow rather than assert — the audit emission is what
// makes this read auditable, so if we somehow get here without
// an actor we should still surface the read (with no actor
// hash) rather than silently swallow it.
const actorOid = req.session.user?.oid;
if (actorOid !== undefined) {
await this.audit.adminAuditQuery({
actor: { oid: actorOid },
filters: { ...filters },
resultCount: page.items.length,
});
}
return page;
}
}