Files
apf_portal/apps/portal-bff/src/admin/admin.module.ts
T
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

37 lines
1.5 KiB
TypeScript

import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AdminAuditController } from './admin-audit.controller';
import { AdminAuthController } from './admin-auth.controller';
import { AdminController } from './admin.controller';
import { AdminRoleGuard } from './admin-role.guard';
import { AuditReader } from './audit-reader.service';
/**
* `AdminModule` — root of the `/api/admin/*` surface per ADR-0020.
*
* v1 ships:
* - `GET /api/admin/me` (self-test, guarded by `@RequireAdmin`).
* - `/api/admin/auth/{login,callback,me,logout}` — the distinct
* admin auth flow that establishes the `__Host-portal_admin_session`
* per ADR-0020 §"Sessions — distinct from `portal-shell`".
*
* Imports `AuthModule` to consume `AuthService`, `SessionEstablisher`,
* and `ENTRA_CONFIG` — the auth flow itself is shared with the
* user-portal `AuthController`; what differs is the redirect URIs
* passed in (admin-specific) and the session that gets populated
* (resolved by the path-routed session middleware in `main.ts`).
*
* The functional admin modules (audit log viewer, CMS, menu
* management, user list) land as separate sub-modules consumed from
* here in upcoming PRs.
*
* `AuditWriter` (required by `AdminRoleGuard`) is provided globally
* by `AuditModule`, so no extra import is needed for it.
*/
@Module({
imports: [AuthModule],
controllers: [AdminController, AdminAuthController, AdminAuditController],
providers: [AdminRoleGuard, AuditReader],
})
export class AdminModule {}