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