feat(portal-bff): admin module + role guard + /api/admin/me self-test
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user