feat(portal-bff): admin module + role guard + /api/admin/me self-test
CI / commits (pull_request) Successful in 2m35s
CI / scan (pull_request) Successful in 2m46s
CI / check (pull_request) Successful in 3m10s
CI / a11y (pull_request) Successful in 1m29s
CI / perf (pull_request) Successful in 6m17s

Lays the foundation for the `/api/admin/*` surface per ADR-0020.
This PR ships the guard, the `@RequireAdmin()` decorator, and a
self-test endpoint — no business routes yet.

- `AdminRoleGuard` (apps/portal-bff/src/admin/admin-role.guard.ts):
  - No session → 401 (no audit; unauthenticated probes are noise).
  - Session but `roles` lacks `admin` → 403 + `admin.access_denied`
    audit row capturing actor hash, attempted route, and roles held.
  - Session with `admin` → pass through.
  - Audit-write failures propagate (no audit ⇒ no action,
    consistent with the existing call sites in AuthController).

- `@RequireAdmin()` decorator wraps `@UseGuards(AdminRoleGuard)`
  via `applyDecorators` so future composition (e.g. with the
  upcoming `@RequireMfa()` for the admin entry route) is mechanical.

- `GET /api/admin/me` returns the public user payload + `roles` for
  ops to verify the gate is wired correctly end-to-end. The handler
  intentionally has no logic beyond the projection — the value of
  the endpoint is the guard chain it forces traffic through.

- `AuditWriter.adminAccessDenied()` typed method keeps the salt
  inside the audit module and matches the pattern of the existing
  typed events. Uses the pre-existing `denied` outcome enum value.

Surface impact:
- No new env vars, no new dependencies.
- The shared portal-shell session is the auth source for this PR.
  The distinct `__Host-portal_admin_session` cookie + admin auth
  flow land in the next PR per the chantier sequence.

Tests: +11 specs (7 guard, 2 controller, 2 typed audit method).
This commit is contained in:
Julien Gautier
2026-05-14 00:43:53 +02:00
parent f9f0151717
commit c30b663c51
10 changed files with 448 additions and 0 deletions
@@ -0,0 +1,178 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { ADMIN_ROLE, AdminRoleGuard } from './admin-role.guard';
interface SessionStub {
user?: {
oid: string;
tid: string;
username: string;
displayName: string;
amr: readonly string[];
roles: readonly string[];
};
}
function makeRequest(opts: {
session?: SessionStub;
method?: string;
originalUrl?: string;
}): Request {
return {
session: opts.session,
method: opts.method ?? 'GET',
originalUrl: opts.originalUrl ?? '/api/admin/me',
} as unknown as Request;
}
function makeContext(req: Request): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
} as unknown as ExecutionContext;
}
function makeAuditStub(): jest.Mocked<Pick<AuditWriter, 'adminAccessDenied'>> {
return {
adminAccessDenied: jest.fn().mockResolvedValue(undefined),
};
}
describe('AdminRoleGuard', () => {
it('throws 401 when the request has no session at all', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(makeRequest({ session: undefined }));
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('throws 401 when the session exists but no user is bound to it', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(makeRequest({ session: {} }));
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('throws 403 + records admin.access_denied when the user has no roles at all', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: [],
},
},
method: 'GET',
originalUrl: '/api/admin/me',
}),
);
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/admin/me',
rolesHeld: [],
});
});
it('throws 403 + records admin.access_denied when the user has unrelated roles only', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: ['editor', 'auditor'],
},
},
method: 'POST',
originalUrl: '/api/admin/cms/pages',
}),
);
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
actor: { oid: 'user-oid' },
attemptedRoute: 'POST /api/admin/cms/pages',
rolesHeld: ['editor', 'auditor'],
});
});
it('returns true and does not audit when the user has the admin role', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: [ADMIN_ROLE],
},
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('returns true when admin is one of several roles', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: ['editor', ADMIN_ROLE, 'auditor'],
},
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('propagates audit write failures (no audit ⇒ no action, per ADR-0013)', async () => {
const audit = {
adminAccessDenied: jest.fn().mockRejectedValue(new Error('audit_writer denied')),
};
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: [],
},
},
}),
);
// The 403 path goes through audit first; if audit throws, the
// guard surfaces the underlying error rather than the
// ForbiddenException — the caller sees a 500 and the audit
// invariant is preserved.
await expect(guard.canActivate(ctx)).rejects.toThrow('audit_writer denied');
});
});
@@ -0,0 +1,68 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
/**
* Single Entra app role that gates the entire `/api/admin/*` surface
* per ADR-0020 §"Auth — `admin` role claim". The value matches the
* `value` field declared on the app role in the Entra app
* registration manifest. Defined as a constant so spec assertions
* can refer to the same source of truth.
*/
export const ADMIN_ROLE = 'admin';
/**
* `AdminRoleGuard` — enforces ADR-0020's role-based admin gate.
*
* Contract
* --------
* - **No session → 401.** The user is not authenticated at all; the
* SPA should kick off the login flow. We do not audit anonymous
* probes here because the route family is not a target — any
* unauthenticated 401 is normal traffic the absolute-timeout
* middleware would surface anyway.
*
* - **Session but missing `admin` role → 403 + audit.** The user
* *is* authenticated; they just are not authorised for admin.
* This is the privilege-escalation attempt audit signal — every
* denial lands in `audit.events` with `outcome=denied`, the
* attempted route as `subject`, and the roles the user did hold
* in the payload. Per ADR-0013 §"Blocking writes": no audit ⇒ no
* action — if the audit write fails, the request fails too
* (consistent with the existing audit call sites in
* `AuthController`).
*
* - **Session with `admin` role → pass through.** Downstream
* controllers see `req.session.user` populated and can rely on
* the role check having happened.
*/
@Injectable()
export class AdminRoleGuard implements CanActivate {
constructor(private readonly audit: AuditWriter) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const user = req.session?.user;
if (!user) {
throw new UnauthorizedException();
}
if (!user.roles.includes(ADMIN_ROLE)) {
await this.audit.adminAccessDenied({
actor: { oid: user.oid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
rolesHeld: user.roles,
});
throw new ForbiddenException();
}
return true;
}
}
@@ -0,0 +1,51 @@
import type { Request } from 'express';
import { AdminController } from './admin.controller';
function makeReq(user: {
oid: string;
tid: string;
username: string;
displayName: string;
amr: readonly string[];
roles: readonly string[];
}): Request {
return { session: { user } } as unknown as Request;
}
describe('AdminController', () => {
it('GET /me returns the public payload of the admin user, including roles', () => {
const controller = new AdminController();
const res = controller.me(
makeReq({
oid: 'admin-oid',
tid: 'tenant-1',
username: 'admin@apf.example',
displayName: 'Admin Smith',
amr: ['pwd', 'mfa'],
roles: ['admin'],
}),
);
expect(res).toEqual({
oid: 'admin-oid',
tid: 'tenant-1',
username: 'admin@apf.example',
displayName: 'Admin Smith',
roles: ['admin'],
});
});
it('does not leak server-internal claims (`amr`) to the response', () => {
const controller = new AdminController();
const res = controller.me(
makeReq({
oid: 'admin-oid',
tid: 'tenant-1',
username: 'admin@apf.example',
displayName: 'Admin Smith',
amr: ['pwd', 'mfa'],
roles: ['admin'],
}),
);
expect(res).not.toHaveProperty('amr');
});
});
@@ -0,0 +1,40 @@
import { Controller, Get, Req } from '@nestjs/common';
import type { Request } from 'express';
import { RequireAdmin } from './require-admin.decorator';
/**
* `/api/admin/*` controllers per ADR-0020. The `@RequireAdmin()`
* class-level decorator gates every route on the Entra `admin` role;
* downstream PRs will add per-method `@RequireMfa({ freshness: … })`
* for the entry route.
*
* `GET /api/admin/me` is the self-test endpoint named in ADR-0020's
* "Confirmation" §3 ("AdminModule with AdminRoleGuard + first
* endpoint (`GET /api/admin/me` returning the session for
* self-test)"). It lets ops verify the guard is wired correctly
* (curl with no cookies → 401; curl with a non-admin session → 403
* with an `admin.access_denied` audit row; curl with an admin
* session → 200 + the public user payload) before any real admin
* route ships.
*/
@Controller('admin')
@RequireAdmin()
export class AdminController {
@Get('me')
me(@Req() req: Request) {
// `AdminRoleGuard` has already established that `req.session.user`
// is present and has the `admin` role — the handler can read the
// field without re-checking. Using the non-null assertion keeps
// the dead-branch noise out of the controller while leaving the
// safety contract anchored in the guard's spec.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed by AdminRoleGuard
const user = req.session.user!;
return {
oid: user.oid,
tid: user.tid,
username: user.username,
displayName: user.displayName,
roles: user.roles,
};
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminRoleGuard } from './admin-role.guard';
/**
* `AdminModule` — root of the `/api/admin/*` surface per ADR-0020.
*
* v1 ships the self-test endpoint only (`GET /api/admin/me`). The
* functional admin modules (audit log viewer, CMS, menu management,
* user list) land as separate sub-modules consumed from here once
* the distinct admin session + the audit query endpoint are in
* place — see the chantier sequence in `notes/handoff.md`.
*
* `AuditWriter` (required by `AdminRoleGuard`) is provided globally
* by `AuditModule`, so no extra import is needed here.
*/
@Module({
controllers: [AdminController],
providers: [AdminRoleGuard],
})
export class AdminModule {}
@@ -0,0 +1,20 @@
import { UseGuards, applyDecorators } from '@nestjs/common';
import { AdminRoleGuard } from './admin-role.guard';
/**
* `@RequireAdmin()` — semantic sugar for `@UseGuards(AdminRoleGuard)`.
*
* Applied at the controller (class) level so every route in an admin
* controller is gated uniformly, or at the method level on the rare
* route inside a mixed controller that needs admin. ADR-0020 §"Auth"
* names this decorator alongside `@RequireMfa()`; we expose the
* decorator API so both gates compose the same way in upcoming PRs.
*
* Using `applyDecorators` (instead of a one-line factory function)
* leaves room for additional decorators in the same wrapper later —
* e.g. an `@SetMetadata('rbac:requires', ['admin'])` for OpenAPI /
* route-introspection tooling — without changing call sites.
*/
export function RequireAdmin(): ClassDecorator & MethodDecorator {
return applyDecorators(UseGuards(AdminRoleGuard));
}
+2
View File
@@ -4,6 +4,7 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ObservabilityModule } from '../observability/observability.module';
import { HealthModule } from '../health/health.module';
import { AdminModule } from '../admin/admin.module';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { RedisModule } from '../redis/redis.module';
@@ -20,6 +21,7 @@ import { SessionModule } from '../session/session.module';
SessionModule,
SecurityModule,
HealthModule,
AdminModule,
],
controllers: [AppController],
providers: [AppService],
@@ -315,4 +315,33 @@ describe('AuditWriter — typed event methods', () => {
);
});
});
describe('adminAccessDenied()', () => {
it('records admin.access_denied with outcome=denied, attempted route as subject, and rolesHeld payload', async () => {
const { writer, prisma, hashUserId } = await createSubject();
await writer.adminAccessDenied({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/admin/audit',
rolesHeld: ['editor'],
});
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
const row = extractInsertedRow(prisma);
expect(row.eventType).toBe('admin.access_denied');
expect(row.audience).toBe('workforce');
expect(row.outcome).toBe('denied');
expect(row.actorIdHash).toBe('hash(user-oid)');
expect(row.subject).toBe('GET /api/admin/audit');
expect(row.payloadJson).toBe(JSON.stringify({ rolesHeld: ['editor'] }));
});
it('persists an empty rolesHeld array verbatim (distinguishable from missing payload)', async () => {
const { writer, prisma } = await createSubject();
await writer.adminAccessDenied({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/admin/me',
rolesHeld: [],
});
expect(extractInsertedRow(prisma).payloadJson).toBe(JSON.stringify({ rolesHeld: [] }));
});
});
});
@@ -3,6 +3,7 @@ import { trace } from '@opentelemetry/api';
import { ClsService } from 'nestjs-cls';
import { PrismaService } from 'nestjs-prisma';
import type {
AdminAccessDeniedInput,
AuditEventInput,
SignInActor,
SignInFailedInput,
@@ -113,6 +114,26 @@ export class AuditWriter {
});
}
/**
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
* because the session's `roles` claim does not include `admin`.
* Per ADR-0020 §"Auth — same Entra ID … `admin` role claim", every
* 403 from the admin surface is captured here with the attempted
* route and the roles the user actually held — auditors looking
* for privilege-escalation attempts pivot on `subject` (the route)
* and `outcome=denied`.
*/
async adminAccessDenied(input: AdminAccessDeniedInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.access_denied',
audience: 'workforce',
outcome: 'denied',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: input.attemptedRoute,
payload: { rolesHeld: input.rolesHeld },
});
}
async recordEvent(input: AuditEventInput): Promise<void> {
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
const actorIdHash =
+18
View File
@@ -82,3 +82,21 @@ export interface SessionExpiredInput {
/** Age of the session at the moment of expiry, in milliseconds. */
ageMs: number;
}
export interface AdminAccessDeniedInput {
actor: { oid: string };
/**
* Canonical "{METHOD} {originalUrl}" of the rejected request, e.g.
* `GET /api/admin/audit`. Persisted into the audit row's `subject`
* column so auditors can pivot on it without joining anything.
*/
attemptedRoute: string;
/**
* The roles the user *did* hold at the moment of denial — empty
* array when no app role was assigned. Stored in the payload so
* an auditor can tell apart "tried admin while logged in as a
* regular user" from "tried admin with an unrelated role like
* `auditor` (future)" without parsing the deny pattern.
*/
rolesHeld: readonly string[];
}