feat(portal-bff): admin module + role guard + /api/admin/me self-test #127

Merged
julien merged 1 commits from feat/portal-bff-admin-module-role-guard into main 2026-05-14 01:17:31 +02:00
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 { AppService } from './app.service';
import { ObservabilityModule } from '../observability/observability.module'; import { ObservabilityModule } from '../observability/observability.module';
import { HealthModule } from '../health/health.module'; import { HealthModule } from '../health/health.module';
import { AdminModule } from '../admin/admin.module';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { RedisModule } from '../redis/redis.module'; import { RedisModule } from '../redis/redis.module';
@@ -20,6 +21,7 @@ import { SessionModule } from '../session/session.module';
SessionModule, SessionModule,
SecurityModule, SecurityModule,
HealthModule, HealthModule,
AdminModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], 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 { ClsService } from 'nestjs-cls';
import { PrismaService } from 'nestjs-prisma'; import { PrismaService } from 'nestjs-prisma';
import type { import type {
AdminAccessDeniedInput,
AuditEventInput, AuditEventInput,
SignInActor, SignInActor,
SignInFailedInput, 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> { async recordEvent(input: AuditEventInput): Promise<void> {
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null; const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
const actorIdHash = const actorIdHash =
+18
View File
@@ -82,3 +82,21 @@ export interface SessionExpiredInput {
/** Age of the session at the moment of expiry, in milliseconds. */ /** Age of the session at the moment of expiry, in milliseconds. */
ageMs: number; 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[];
}