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.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
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.
|
||||
@@ -28,7 +30,7 @@ import { AdminRoleGuard } from './admin-role.guard';
|
||||
*/
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [AdminController, AdminAuthController],
|
||||
providers: [AdminRoleGuard],
|
||||
controllers: [AdminController, AdminAuthController, AdminAuditController],
|
||||
providers: [AdminRoleGuard, AuditReader],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
Reference in New Issue
Block a user