feat(auth): @RequirePrivilege/@RequireRole/@RequireScope guards (ADR-0025) #208
@@ -1,5 +1,6 @@
|
|||||||
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
|
import type { Principal } from 'shared-auth';
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
import { ADMIN_ROLE, AdminRoleGuard } from './admin-role.guard';
|
import { ADMIN_ROLE, AdminRoleGuard } from './admin-role.guard';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ interface SessionStub {
|
|||||||
amr: readonly string[];
|
amr: readonly string[];
|
||||||
roles: readonly string[];
|
roles: readonly string[];
|
||||||
};
|
};
|
||||||
|
principal?: Principal;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRequest(opts: {
|
function makeRequest(opts: {
|
||||||
@@ -38,6 +40,22 @@ function makeAuditStub(): jest.Mocked<Pick<AuditWriter, 'adminAccessDenied'>> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function principalWithPrivileges(privileges: readonly string[]): Principal {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: 'user-oid',
|
||||||
|
personId: 'user-oid',
|
||||||
|
entraOid: 'user-oid',
|
||||||
|
tenantId: 't',
|
||||||
|
displayName: 'Jane',
|
||||||
|
},
|
||||||
|
privileges: privileges as Principal['privileges'],
|
||||||
|
roles: [],
|
||||||
|
scopes: [{ kind: 'unrestricted' }],
|
||||||
|
amr: ['pwd', 'mfa'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('AdminRoleGuard', () => {
|
describe('AdminRoleGuard', () => {
|
||||||
it('throws 401 when the request has no session at all', async () => {
|
it('throws 401 when the request has no session at all', async () => {
|
||||||
const audit = makeAuditStub();
|
const audit = makeAuditStub();
|
||||||
@@ -47,7 +65,7 @@ describe('AdminRoleGuard', () => {
|
|||||||
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws 401 when the session exists but no user is bound to it', async () => {
|
it('throws 401 when the session exists but no principal nor legacy user is bound', async () => {
|
||||||
const audit = makeAuditStub();
|
const audit = makeAuditStub();
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
const ctx = makeContext(makeRequest({ session: {} }));
|
const ctx = makeContext(makeRequest({ session: {} }));
|
||||||
@@ -55,21 +73,12 @@ describe('AdminRoleGuard', () => {
|
|||||||
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws 403 + records admin.access_denied when the user has no roles at all', async () => {
|
it('throws 403 + records admin.access_denied when the principal has no privileges', async () => {
|
||||||
const audit = makeAuditStub();
|
const audit = makeAuditStub();
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
const ctx = makeContext(
|
const ctx = makeContext(
|
||||||
makeRequest({
|
makeRequest({
|
||||||
session: {
|
session: { principal: principalWithPrivileges([]) },
|
||||||
user: {
|
|
||||||
oid: 'user-oid',
|
|
||||||
tid: 't',
|
|
||||||
username: 'jane@example',
|
|
||||||
displayName: 'Jane',
|
|
||||||
amr: ['pwd'],
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
originalUrl: '/api/admin/me',
|
originalUrl: '/api/admin/me',
|
||||||
}),
|
}),
|
||||||
@@ -82,7 +91,83 @@ describe('AdminRoleGuard', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws 403 + records admin.access_denied when the user has unrelated roles only', async () => {
|
it('throws 403 + records admin.access_denied when the principal has non-admin privileges only', async () => {
|
||||||
|
const audit = makeAuditStub();
|
||||||
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
|
const ctx = makeContext(
|
||||||
|
makeRequest({
|
||||||
|
session: { principal: principalWithPrivileges(['Portal.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: ['Portal.Auditor'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true and does not audit when the principal carries Portal.Admin', async () => {
|
||||||
|
const audit = makeAuditStub();
|
||||||
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
|
const ctx = makeContext(
|
||||||
|
makeRequest({ session: { principal: principalWithPrivileges([ADMIN_ROLE]) } }),
|
||||||
|
);
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when admin is one of several privileges', async () => {
|
||||||
|
const audit = makeAuditStub();
|
||||||
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
|
const ctx = makeContext(
|
||||||
|
makeRequest({
|
||||||
|
session: {
|
||||||
|
principal: principalWithPrivileges(['Portal.Auditor', ADMIN_ROLE, 'Portal.DPO']),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
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: { principal: principalWithPrivileges([]) } }));
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('legacy-session bridge (sessions persisted before ADR-0025 landed)', () => {
|
||||||
|
it('synthesises a principal from session.user when session.principal is absent', 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, 'Other.Role'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a legacy session whose roles claim carries no Portal.* value', async () => {
|
||||||
const audit = makeAuditStub();
|
const audit = makeAuditStub();
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
const ctx = makeContext(
|
const ctx = makeContext(
|
||||||
@@ -97,82 +182,18 @@ describe('AdminRoleGuard', () => {
|
|||||||
roles: ['editor', 'auditor'],
|
roles: ['editor', 'auditor'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
method: 'POST',
|
|
||||||
originalUrl: '/api/admin/cms/pages',
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
// The legacy bridge filters the raw `roles` claim down to
|
||||||
|
// `Portal.*` values for the audit row's `rolesHeld` field —
|
||||||
|
// `editor` and `auditor` are not privileges, so the held
|
||||||
|
// list is empty.
|
||||||
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
|
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
|
||||||
actor: { oid: 'user-oid' },
|
actor: { oid: 'user-oid' },
|
||||||
attemptedRoute: 'POST /api/admin/cms/pages',
|
attemptedRoute: 'GET /api/admin/me',
|
||||||
rolesHeld: ['editor', 'auditor'],
|
rolesHeld: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
|
import { readSessionPrincipal } from '../auth/principal-extractor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single Entra app role that gates the entire `/api/admin/*` surface
|
* Single Entra app role that gates the entire `/api/admin/*` surface
|
||||||
@@ -20,6 +21,11 @@ export const ADMIN_ROLE = 'Portal.Admin';
|
|||||||
/**
|
/**
|
||||||
* `AdminRoleGuard` — enforces ADR-0020's role-based admin gate.
|
* `AdminRoleGuard` — enforces ADR-0020's role-based admin gate.
|
||||||
*
|
*
|
||||||
|
* Reads from `principal.privileges` per [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md)
|
||||||
|
* — the migration thinned the implementation but kept the
|
||||||
|
* `@RequireAdmin()` public API and the `admin.access_denied`
|
||||||
|
* audit event type unchanged.
|
||||||
|
*
|
||||||
* Contract
|
* Contract
|
||||||
* --------
|
* --------
|
||||||
* - **No session → 401.** The user is not authenticated at all; the
|
* - **No session → 401.** The user is not authenticated at all; the
|
||||||
@@ -28,19 +34,21 @@ export const ADMIN_ROLE = 'Portal.Admin';
|
|||||||
* unauthenticated 401 is normal traffic the absolute-timeout
|
* unauthenticated 401 is normal traffic the absolute-timeout
|
||||||
* middleware would surface anyway.
|
* middleware would surface anyway.
|
||||||
*
|
*
|
||||||
* - **Session but missing `Portal.Admin` role → 403 + audit.** The user
|
* - **Session but missing `Portal.Admin` privilege → 403 + audit.**
|
||||||
* *is* authenticated; they just are not authorised for admin.
|
* The user *is* authenticated; they just are not authorised
|
||||||
* This is the privilege-escalation attempt audit signal — every
|
* for admin. This is the privilege-escalation attempt audit
|
||||||
* denial lands in `audit.events` with `outcome=denied`, the
|
* signal — every denial lands in `audit.events` with
|
||||||
* attempted route as `subject`, and the roles the user did hold
|
* `outcome=denied`, the attempted route as `subject`, and the
|
||||||
* in the payload. Per ADR-0013 §"Blocking writes": no audit ⇒ no
|
* `Portal.*` privileges the principal did hold in the payload's
|
||||||
* action — if the audit write fails, the request fails too
|
* `rolesHeld` field (kept named `rolesHeld` for backward
|
||||||
* (consistent with the existing audit call sites in
|
* compatibility with the existing audit shape; the values are
|
||||||
* `AuthController`).
|
* privileges per ADR-0025's renaming of the axis). Per
|
||||||
|
* ADR-0013 §"Blocking writes": no audit ⇒ no action.
|
||||||
*
|
*
|
||||||
* - **Session with `Portal.Admin` role → pass through.** Downstream
|
* - **Session with `Portal.Admin` privilege → pass through.**
|
||||||
* controllers see `req.session.user` populated and can rely on
|
* Downstream controllers see `req.session.user` *and*
|
||||||
* the role check having happened.
|
* `req.session.principal` populated and can rely on the gate
|
||||||
|
* having happened.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminRoleGuard implements CanActivate {
|
export class AdminRoleGuard implements CanActivate {
|
||||||
@@ -48,17 +56,17 @@ export class AdminRoleGuard implements CanActivate {
|
|||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const req = context.switchToHttp().getRequest<Request>();
|
const req = context.switchToHttp().getRequest<Request>();
|
||||||
const user = req.session?.user;
|
const principal = readSessionPrincipal(req);
|
||||||
|
|
||||||
if (!user) {
|
if (principal === null) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.roles.includes(ADMIN_ROLE)) {
|
if (!principal.privileges.includes(ADMIN_ROLE)) {
|
||||||
await this.audit.adminAccessDenied({
|
await this.audit.adminAccessDenied({
|
||||||
actor: { oid: user.oid },
|
actor: { oid: principal.user.entraOid },
|
||||||
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
||||||
rolesHeld: user.roles,
|
rolesHeld: [...principal.privileges],
|
||||||
});
|
});
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,4 +469,67 @@ describe('AuditWriter — typed event methods', () => {
|
|||||||
expect(extractInsertedRow(prisma).payloadJson).toBe(JSON.stringify({ rolesHeld: [] }));
|
expect(extractInsertedRow(prisma).payloadJson).toBe(JSON.stringify({ rolesHeld: [] }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('authorizationDenied()', () => {
|
||||||
|
it('records auth.authorization_denied with kind=privilege payload', async () => {
|
||||||
|
const { writer, prisma, hashUserId } = await createSubject();
|
||||||
|
await writer.authorizationDenied({
|
||||||
|
actor: { oid: 'user-oid' },
|
||||||
|
attemptedRoute: 'GET /api/audit',
|
||||||
|
kind: 'privilege',
|
||||||
|
required: ['Portal.Auditor'],
|
||||||
|
held: ['Portal.Admin'],
|
||||||
|
});
|
||||||
|
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
||||||
|
const row = extractInsertedRow(prisma);
|
||||||
|
expect(row.eventType).toBe('auth.authorization_denied');
|
||||||
|
expect(row.outcome).toBe('denied');
|
||||||
|
expect(row.subject).toBe('GET /api/audit');
|
||||||
|
expect(row.payloadJson).toBe(
|
||||||
|
JSON.stringify({
|
||||||
|
kind: 'privilege',
|
||||||
|
required: ['Portal.Auditor'],
|
||||||
|
held: ['Portal.Admin'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records kind=role with the role lists', async () => {
|
||||||
|
const { writer, prisma } = await createSubject();
|
||||||
|
await writer.authorizationDenied({
|
||||||
|
actor: { oid: 'user-oid' },
|
||||||
|
attemptedRoute: 'GET /api/hr/payroll',
|
||||||
|
kind: 'role',
|
||||||
|
required: ['rh', 'responsable-paie'],
|
||||||
|
held: ['collaborateur'],
|
||||||
|
});
|
||||||
|
const row = extractInsertedRow(prisma);
|
||||||
|
expect(row.payloadJson).toBe(
|
||||||
|
JSON.stringify({
|
||||||
|
kind: 'role',
|
||||||
|
required: ['rh', 'responsable-paie'],
|
||||||
|
held: ['collaborateur'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records kind=scope with the scope descriptors', async () => {
|
||||||
|
const { writer, prisma } = await createSubject();
|
||||||
|
await writer.authorizationDenied({
|
||||||
|
actor: { oid: 'user-oid' },
|
||||||
|
attemptedRoute: 'GET /api/etablissement/0330800021',
|
||||||
|
kind: 'scope',
|
||||||
|
required: ['etablissement:0330800021'],
|
||||||
|
held: ['etablissement:0330800013'],
|
||||||
|
});
|
||||||
|
const row = extractInsertedRow(prisma);
|
||||||
|
expect(row.payloadJson).toBe(
|
||||||
|
JSON.stringify({
|
||||||
|
kind: 'scope',
|
||||||
|
required: ['etablissement:0330800021'],
|
||||||
|
held: ['etablissement:0330800013'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
AdminAuditStatsQueryInput,
|
AdminAuditStatsQueryInput,
|
||||||
AdminUsersQueryInput,
|
AdminUsersQueryInput,
|
||||||
AuditEventInput,
|
AuditEventInput,
|
||||||
|
AuthorizationDeniedInput,
|
||||||
MfaRequiredInput,
|
MfaRequiredInput,
|
||||||
SignInActor,
|
SignInActor,
|
||||||
SignInFailedInput,
|
SignInFailedInput,
|
||||||
@@ -239,6 +240,34 @@ export class AuditWriter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed event: a request was rejected by one of the three new
|
||||||
|
* ADR-0025 guards (`RequirePrivilegeGuard`, `RequireRoleGuard`,
|
||||||
|
* `RequireScopeGuard`). Distinct from `admin.access_denied` so
|
||||||
|
* an auditor can keep the existing admin-surface signal clean
|
||||||
|
* while tracking the broader role/scope denial surface as it
|
||||||
|
* grows.
|
||||||
|
*
|
||||||
|
* `outcome=denied` matches the existing posture: the user *is*
|
||||||
|
* authenticated, the action is just refused. The `required` and
|
||||||
|
* `held` payload arrays let an auditor pivot on "tried admin
|
||||||
|
* while logged in as RH" without joining anything.
|
||||||
|
*/
|
||||||
|
async authorizationDenied(input: AuthorizationDeniedInput): Promise<void> {
|
||||||
|
await this.recordEvent({
|
||||||
|
eventType: 'auth.authorization_denied',
|
||||||
|
audience: 'workforce',
|
||||||
|
outcome: 'denied',
|
||||||
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
||||||
|
subject: input.attemptedRoute,
|
||||||
|
payload: {
|
||||||
|
kind: input.kind,
|
||||||
|
required: input.required,
|
||||||
|
held: input.held,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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 =
|
||||||
|
|||||||
@@ -147,6 +147,42 @@ export interface AdminAuditStatsQueryInput {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminator on `AuthorizationDeniedInput.kind` — which of the
|
||||||
|
* three new ADR-0025 guards rejected the request. Stored in the
|
||||||
|
* payload so an auditor can spot "what kind of authorization
|
||||||
|
* failed" without parsing the route. The existing
|
||||||
|
* `admin.access_denied` event keeps its own type for backwards
|
||||||
|
* compatibility with the legacy `AdminRoleGuard` audit posture.
|
||||||
|
*/
|
||||||
|
export type AuthorizationDeniedKind = 'privilege' | 'role' | 'scope';
|
||||||
|
|
||||||
|
export interface AuthorizationDeniedInput {
|
||||||
|
actor: { oid: string };
|
||||||
|
/** Canonical "{METHOD} {originalUrl}" of the rejected request. */
|
||||||
|
attemptedRoute: string;
|
||||||
|
/** Which guard fired the rejection. */
|
||||||
|
kind: AuthorizationDeniedKind;
|
||||||
|
/**
|
||||||
|
* For `kind: 'privilege' | 'role'`: the catalogue values the
|
||||||
|
* guard required. For `kind: 'scope'`: the route's resource
|
||||||
|
* descriptor serialised into a `{kind, value}` map. Stored as
|
||||||
|
* a generic `string[]` rather than the union of `Privilege` /
|
||||||
|
* `FunctionalRole` so the audit module stays decoupled from the
|
||||||
|
* shared catalogue.
|
||||||
|
*/
|
||||||
|
required: readonly string[];
|
||||||
|
/**
|
||||||
|
* What the principal actually held on the matching axis at the
|
||||||
|
* moment of denial — `privileges[]` for privilege/admin denials,
|
||||||
|
* `roles[]` for role denials, `scopes[]` (serialised as
|
||||||
|
* `kind` or `kind:value` strings) for scope denials. Lets an
|
||||||
|
* auditor spot "tried admin while logged in as RH" patterns
|
||||||
|
* without joining anything.
|
||||||
|
*/
|
||||||
|
held: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Reason `RequireMfaGuard` rejected a request — fed into the audit payload. */
|
/** Reason `RequireMfaGuard` rejected a request — fed into the audit payload. */
|
||||||
export type MfaRequiredReason = 'no-mfa-in-amr' | 'no-mfa-verified-at' | 'mfa-stale';
|
export type MfaRequiredReason = 'no-mfa-in-amr' | 'no-mfa-verified-at' | 'mfa-stale';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* Persona-matrix integration tests per ADR-0025 §"More Information"
|
||||||
|
* phasing step 2: the three new guards exercised against every
|
||||||
|
* persona provisioned in the `apfrd.onmicrosoft.com` test tenant on
|
||||||
|
* 2026-05-20. Each test instantiates the guard with the persona's
|
||||||
|
* resolved `Principal` (the shape `PrincipalBuilder` would have
|
||||||
|
* produced at sign-in) and asserts the guard either passes or
|
||||||
|
* denies — the matrix proves the contracts hold against the real
|
||||||
|
* provisioning matrix, not just synthesised toy principals.
|
||||||
|
*
|
||||||
|
* Scopes are stubbed `unrestricted` for every persona in v1 per
|
||||||
|
* ADR-0025 §331 (the per-persona scope values land with the
|
||||||
|
* Prisma `user_scopes` table in a later PR). The scope guard is
|
||||||
|
* therefore exercised here against synthesised varied principals,
|
||||||
|
* not against the persona matrix.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
|
import type { Reflector } from '@nestjs/core';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import type { FunctionalRole, Principal, Privilege } from 'shared-auth';
|
||||||
|
import type { AuditWriter } from '../audit/audit.service';
|
||||||
|
import { RequirePrivilegeGuard } from './require-privilege.guard';
|
||||||
|
import { RequireRoleGuard } from './require-role.guard';
|
||||||
|
|
||||||
|
interface Persona {
|
||||||
|
readonly label: string;
|
||||||
|
readonly privileges: readonly Privilege[];
|
||||||
|
readonly roles: readonly FunctionalRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbatim copy of the persona matrix from
|
||||||
|
// `principal-builder.spec.ts`. Kept here as a local constant so a
|
||||||
|
// reviewer sees the same 19-row table both files assert against;
|
||||||
|
// the two specs cover different layers (builder, then guards).
|
||||||
|
const PERSONAS: readonly Persona[] = [
|
||||||
|
{ label: 'admin', privileges: ['Portal.Admin'], roles: ['collaborateur', 'rh'] },
|
||||||
|
{
|
||||||
|
label: 'directeur-bordeaux',
|
||||||
|
privileges: [],
|
||||||
|
roles: ['collaborateur', 'directeur-etablissement'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'directeur-complexe',
|
||||||
|
privileges: [],
|
||||||
|
roles: ['collaborateur', 'directeur-etablissement'],
|
||||||
|
},
|
||||||
|
{ label: 'rh-aquitaine', privileges: [], roles: ['collaborateur', 'rh', 'formation'] },
|
||||||
|
{
|
||||||
|
label: 'rh-siege',
|
||||||
|
privileges: [],
|
||||||
|
roles: ['collaborateur', 'rh', 'responsable-paie', 'comptable'],
|
||||||
|
},
|
||||||
|
{ label: 'collaborateur-simple', privileges: [], roles: ['collaborateur'] },
|
||||||
|
{ label: 'tresorier-bordeaux', privileges: [], roles: ['elu-cd', 'elu-cd-tresorier'] },
|
||||||
|
{
|
||||||
|
label: 'dpo',
|
||||||
|
privileges: ['Portal.DPO', 'Portal.Auditor'],
|
||||||
|
roles: ['collaborateur', 'dpo', 'qualite'],
|
||||||
|
},
|
||||||
|
{ label: 'it', privileges: [], roles: ['collaborateur', 'it'] },
|
||||||
|
{
|
||||||
|
label: 'benevole-aquitaine',
|
||||||
|
privileges: [],
|
||||||
|
roles: ['delegue', 'benevole', 'benevole-responsable'],
|
||||||
|
},
|
||||||
|
{ label: 'chef-equipe-bordeaux', privileges: [], roles: ['collaborateur', 'chef-equipe'] },
|
||||||
|
{ label: 'chef-service-bordeaux', privileges: [], roles: ['collaborateur', 'chef-service'] },
|
||||||
|
{
|
||||||
|
label: 'directeur-territorial-aquitaine',
|
||||||
|
privileges: [],
|
||||||
|
roles: ['collaborateur', 'directeur-territorial'],
|
||||||
|
},
|
||||||
|
{ label: 'juriste-siege', privileges: [], roles: ['collaborateur', 'juriste'] },
|
||||||
|
{ label: 'rssi', privileges: ['Portal.SecurityOfficer'], roles: ['collaborateur', 'rssi'] },
|
||||||
|
{ label: 'communication-siege', privileges: [], roles: ['collaborateur', 'communication'] },
|
||||||
|
{ label: 'elu-ca-national', privileges: [], roles: ['elu-ca'] },
|
||||||
|
{ label: 'president-cd-aquitaine', privileges: [], roles: ['elu-cd', 'elu-cd-president'] },
|
||||||
|
{ label: 'secretaire-cd-aquitaine', privileges: [], roles: ['elu-cd', 'elu-cd-secretaire'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function principalOf(persona: Persona): Principal {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: `oid-${persona.label}`,
|
||||||
|
personId: `oid-${persona.label}`,
|
||||||
|
entraOid: `oid-${persona.label}`,
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
displayName: persona.label,
|
||||||
|
},
|
||||||
|
privileges: persona.privileges,
|
||||||
|
roles: persona.roles,
|
||||||
|
scopes: [{ kind: 'unrestricted' }],
|
||||||
|
amr: ['pwd', 'mfa'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtxFor(principal: Principal): ExecutionContext {
|
||||||
|
const req = {
|
||||||
|
session: { principal },
|
||||||
|
method: 'GET',
|
||||||
|
originalUrl: '/api/test',
|
||||||
|
} as unknown as Request;
|
||||||
|
return {
|
||||||
|
switchToHttp: () => ({ getRequest: <T>() => req as T }),
|
||||||
|
getHandler: () => 'handler',
|
||||||
|
getClass: () => 'TestController',
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function privilegeGuardFor(metadata: readonly Privilege[]) {
|
||||||
|
const reflector = {
|
||||||
|
getAllAndOverride: jest.fn().mockReturnValue(metadata),
|
||||||
|
} as unknown as Reflector;
|
||||||
|
const audit = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
|
||||||
|
return { guard: new RequirePrivilegeGuard(reflector, writer), audit };
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleGuardFor(metadata: readonly FunctionalRole[]) {
|
||||||
|
const reflector = {
|
||||||
|
getAllAndOverride: jest.fn().mockReturnValue(metadata),
|
||||||
|
} as unknown as Reflector;
|
||||||
|
const audit = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
|
||||||
|
return { guard: new RequireRoleGuard(reflector, writer), audit };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each row asserts which personas should pass a given guard. The
|
||||||
|
* complement (everyone NOT in `allowed`) must be denied — the
|
||||||
|
* `expect denied` block at the bottom of each describe catches a
|
||||||
|
* regression that would silently let extra personas through.
|
||||||
|
*/
|
||||||
|
interface Matrix {
|
||||||
|
readonly title: string;
|
||||||
|
readonly allowed: ReadonlyArray<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIVILEGE_MATRIX: ReadonlyArray<{
|
||||||
|
metadata: readonly Privilege[];
|
||||||
|
matrix: Matrix;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
metadata: ['Portal.Admin'],
|
||||||
|
matrix: { title: '@RequirePrivilege(Portal.Admin)', allowed: ['admin'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: ['Portal.DPO'],
|
||||||
|
matrix: { title: '@RequirePrivilege(Portal.DPO)', allowed: ['dpo'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: ['Portal.Auditor'],
|
||||||
|
matrix: { title: '@RequirePrivilege(Portal.Auditor)', allowed: ['dpo'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: ['Portal.SecurityOfficer'],
|
||||||
|
matrix: { title: '@RequirePrivilege(Portal.SecurityOfficer)', allowed: ['rssi'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: ['Portal.Admin', 'Portal.DPO'],
|
||||||
|
matrix: {
|
||||||
|
title: '@RequirePrivilege(Portal.Admin, Portal.DPO) — OR composition',
|
||||||
|
allowed: ['admin', 'dpo'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROLE_MATRIX: ReadonlyArray<{
|
||||||
|
metadata: readonly FunctionalRole[];
|
||||||
|
matrix: Matrix;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
metadata: ['rh'],
|
||||||
|
matrix: { title: '@RequireRole(rh)', allowed: ['admin', 'rh-aquitaine', 'rh-siege'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: ['directeur-etablissement'],
|
||||||
|
matrix: {
|
||||||
|
title: '@RequireRole(directeur-etablissement)',
|
||||||
|
allowed: ['directeur-bordeaux', 'directeur-complexe'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: ['rh', 'comptable', 'responsable-paie'],
|
||||||
|
matrix: {
|
||||||
|
title: '@RequireRole(rh, comptable, responsable-paie) — OR composition',
|
||||||
|
allowed: ['admin', 'rh-aquitaine', 'rh-siege'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: ['elu-cd'],
|
||||||
|
matrix: {
|
||||||
|
title: '@RequireRole(elu-cd) — every CD member',
|
||||||
|
allowed: ['tresorier-bordeaux', 'president-cd-aquitaine', 'secretaire-cd-aquitaine'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: ['benevole-responsable'],
|
||||||
|
matrix: {
|
||||||
|
title: '@RequireRole(benevole-responsable)',
|
||||||
|
allowed: ['benevole-aquitaine'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: ['collaborateur'],
|
||||||
|
matrix: {
|
||||||
|
title: '@RequireRole(collaborateur) — every workforce persona',
|
||||||
|
allowed: [
|
||||||
|
'admin',
|
||||||
|
'directeur-bordeaux',
|
||||||
|
'directeur-complexe',
|
||||||
|
'rh-aquitaine',
|
||||||
|
'rh-siege',
|
||||||
|
'collaborateur-simple',
|
||||||
|
'dpo',
|
||||||
|
'it',
|
||||||
|
'chef-equipe-bordeaux',
|
||||||
|
'chef-service-bordeaux',
|
||||||
|
'directeur-territorial-aquitaine',
|
||||||
|
'juriste-siege',
|
||||||
|
'rssi',
|
||||||
|
'communication-siege',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Privilege guard — persona matrix', () => {
|
||||||
|
for (const { metadata, matrix } of PRIVILEGE_MATRIX) {
|
||||||
|
describe(matrix.title, () => {
|
||||||
|
for (const persona of PERSONAS) {
|
||||||
|
const shouldPass = matrix.allowed.includes(persona.label);
|
||||||
|
const verb = shouldPass ? 'allows' : 'denies';
|
||||||
|
it(`${verb} ${persona.label}`, async () => {
|
||||||
|
const { guard } = privilegeGuardFor(metadata);
|
||||||
|
const ctx = makeCtxFor(principalOf(persona));
|
||||||
|
if (shouldPass) {
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
} else {
|
||||||
|
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Role guard — persona matrix', () => {
|
||||||
|
for (const { metadata, matrix } of ROLE_MATRIX) {
|
||||||
|
describe(matrix.title, () => {
|
||||||
|
for (const persona of PERSONAS) {
|
||||||
|
const shouldPass = matrix.allowed.includes(persona.label);
|
||||||
|
const verb = shouldPass ? 'allows' : 'denies';
|
||||||
|
it(`${verb} ${persona.label}`, async () => {
|
||||||
|
const { guard } = roleGuardFor(metadata);
|
||||||
|
const ctx = makeCtxFor(principalOf(persona));
|
||||||
|
if (shouldPass) {
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
} else {
|
||||||
|
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -11,6 +11,9 @@ import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token';
|
|||||||
import { MSAL_CLIENT } from './msal-client.token';
|
import { MSAL_CLIENT } from './msal-client.token';
|
||||||
import { PrincipalBuilder } from './principal-builder';
|
import { PrincipalBuilder } from './principal-builder';
|
||||||
import { RequireMfaGuard } from './require-mfa.guard';
|
import { RequireMfaGuard } from './require-mfa.guard';
|
||||||
|
import { RequirePrivilegeGuard } from './require-privilege.guard';
|
||||||
|
import { RequireRoleGuard } from './require-role.guard';
|
||||||
|
import { RequireScopeGuard } from './require-scope.guard';
|
||||||
import { ScopeResolver, StubScopeResolver } from './scope-resolver';
|
import { ScopeResolver, StubScopeResolver } from './scope-resolver';
|
||||||
import { SessionEstablisher } from './session-establisher.service';
|
import { SessionEstablisher } from './session-establisher.service';
|
||||||
|
|
||||||
@@ -50,6 +53,9 @@ import { SessionEstablisher } from './session-establisher.service';
|
|||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
RequireMfaGuard,
|
RequireMfaGuard,
|
||||||
|
RequirePrivilegeGuard,
|
||||||
|
RequireRoleGuard,
|
||||||
|
RequireScopeGuard,
|
||||||
SessionEstablisher,
|
SessionEstablisher,
|
||||||
PrincipalBuilder,
|
PrincipalBuilder,
|
||||||
{ provide: ScopeResolver, useClass: StubScopeResolver },
|
{ provide: ScopeResolver, useClass: StubScopeResolver },
|
||||||
@@ -126,6 +132,9 @@ import { SessionEstablisher } from './session-establisher.service';
|
|||||||
ENTRA_GROUP_TO_ROLE_RESOLVER,
|
ENTRA_GROUP_TO_ROLE_RESOLVER,
|
||||||
MSAL_CLIENT,
|
MSAL_CLIENT,
|
||||||
RequireMfaGuard,
|
RequireMfaGuard,
|
||||||
|
RequirePrivilegeGuard,
|
||||||
|
RequireRoleGuard,
|
||||||
|
RequireScopeGuard,
|
||||||
AuthService,
|
AuthService,
|
||||||
PrincipalBuilder,
|
PrincipalBuilder,
|
||||||
ScopeResolver,
|
ScopeResolver,
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
import type { Principal, Scope } from 'shared-auth';
|
||||||
|
import { readSessionPrincipal, scopesToAuditStrings } from './principal-extractor';
|
||||||
|
|
||||||
|
const PRINCIPAL: Principal = {
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
personId: 'person-1',
|
||||||
|
entraOid: 'oid-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
},
|
||||||
|
privileges: ['Portal.Admin'],
|
||||||
|
roles: ['rh'],
|
||||||
|
scopes: [{ kind: 'unrestricted' }],
|
||||||
|
amr: ['pwd', 'mfa'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeReq(session: Record<string, unknown> | undefined): Request {
|
||||||
|
return { session } as unknown as Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('readSessionPrincipal', () => {
|
||||||
|
it('returns null when the request has no session at all', () => {
|
||||||
|
expect(readSessionPrincipal(makeReq(undefined))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the session has neither principal nor legacy user', () => {
|
||||||
|
expect(readSessionPrincipal(makeReq({}))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the principal verbatim when session.principal is set', () => {
|
||||||
|
const out = readSessionPrincipal(makeReq({ principal: PRINCIPAL }));
|
||||||
|
expect(out).toBe(PRINCIPAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('legacy bridge — session.user only (pre-ADR-0025 sessions)', () => {
|
||||||
|
it('synthesises a principal carrying the Portal.* values as privileges', () => {
|
||||||
|
const req = makeReq({
|
||||||
|
user: {
|
||||||
|
oid: 'legacy-oid',
|
||||||
|
tid: 'tenant-7',
|
||||||
|
username: 'legacy@example',
|
||||||
|
displayName: 'Legacy Joe',
|
||||||
|
amr: ['pwd', 'mfa'],
|
||||||
|
roles: ['Portal.Admin', 'Portal.DPO', 'editor', 'auditor'],
|
||||||
|
groups: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = readSessionPrincipal(req);
|
||||||
|
expect(out).not.toBeNull();
|
||||||
|
expect(out!.privileges).toEqual(['Portal.Admin', 'Portal.DPO']);
|
||||||
|
expect(out!.user.entraOid).toBe('legacy-oid');
|
||||||
|
expect(out!.user.tenantId).toBe('tenant-7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mirrors entraOid into user.id and user.personId (ADR-0026 placeholder)', () => {
|
||||||
|
const req = makeReq({
|
||||||
|
user: {
|
||||||
|
oid: 'legacy-oid',
|
||||||
|
tid: 'tenant-7',
|
||||||
|
username: 'legacy@example',
|
||||||
|
displayName: 'Legacy Joe',
|
||||||
|
amr: [],
|
||||||
|
roles: [],
|
||||||
|
groups: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = readSessionPrincipal(req);
|
||||||
|
expect(out!.user.id).toBe('legacy-oid');
|
||||||
|
expect(out!.user.personId).toBe('legacy-oid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legacy sessions get no functional roles and a coarse unrestricted scope', () => {
|
||||||
|
const req = makeReq({
|
||||||
|
user: {
|
||||||
|
oid: 'legacy-oid',
|
||||||
|
tid: 'tenant-7',
|
||||||
|
username: 'legacy@example',
|
||||||
|
displayName: 'Legacy Joe',
|
||||||
|
amr: [],
|
||||||
|
roles: ['Portal.Admin'],
|
||||||
|
groups: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = readSessionPrincipal(req);
|
||||||
|
expect(out!.roles).toEqual([]);
|
||||||
|
expect(out!.scopes).toEqual([{ kind: 'unrestricted' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scopesToAuditStrings', () => {
|
||||||
|
it('renders implicit scopes as the kind verbatim', () => {
|
||||||
|
const scopes: Scope[] = [{ kind: 'self' }, { kind: 'siege' }, { kind: 'unrestricted' }];
|
||||||
|
expect(scopesToAuditStrings(scopes)).toEqual(['self', 'siege', 'unrestricted']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders value-bearing scopes as kind:value', () => {
|
||||||
|
const scopes: Scope[] = [
|
||||||
|
{ kind: 'etablissement', value: '0330800013' },
|
||||||
|
{ kind: 'delegation', value: '33' },
|
||||||
|
{ kind: 'region', value: '75' },
|
||||||
|
];
|
||||||
|
expect(scopesToAuditStrings(scopes)).toEqual([
|
||||||
|
'etablissement:0330800013',
|
||||||
|
'delegation:33',
|
||||||
|
'region:75',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array on an empty scope list', () => {
|
||||||
|
expect(scopesToAuditStrings([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
import type { Principal, Scope } from 'shared-auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the authorization `Principal` off the express session,
|
||||||
|
* coalescing the legacy session-shape carryover so a session that
|
||||||
|
* was minted before the ADR-0025 implementation landed still works
|
||||||
|
* for the duration of its 12 h absolute TTL.
|
||||||
|
*
|
||||||
|
* Returns `null` when no session is present at all (the guard
|
||||||
|
* should answer 401 — the user is anonymous). Returns a
|
||||||
|
* synthesised `Principal` derived from `session.user` when the
|
||||||
|
* session is authenticated but predates the `principal` field —
|
||||||
|
* the synthesised principal carries the `Portal.*` privileges
|
||||||
|
* from the legacy `roles` claim, no functional roles (the
|
||||||
|
* `groups` claim was unconfigured at the time), and an
|
||||||
|
* `unrestricted` scope. After every persisted session has cycled
|
||||||
|
* through the absolute-timeout window post-deploy, this fallback
|
||||||
|
* is dead code.
|
||||||
|
*/
|
||||||
|
export function readSessionPrincipal(req: Request): Principal | null {
|
||||||
|
const session = req.session;
|
||||||
|
if (session === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.principal !== undefined) {
|
||||||
|
return session.principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = session.user;
|
||||||
|
if (user === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy session bridge. Filter `user.roles` through a generic
|
||||||
|
// `Portal.*` heuristic rather than re-importing `isPrivilege`
|
||||||
|
// here — the file's whole purpose is graceful degradation; if
|
||||||
|
// the legacy claim carries a non-catalogue Portal.X the matcher
|
||||||
|
// downstream just returns false on it.
|
||||||
|
const portalPrivileges = user.roles.filter((r) => r.startsWith('Portal.'));
|
||||||
|
const fallback: Principal = {
|
||||||
|
user: {
|
||||||
|
id: user.oid,
|
||||||
|
personId: user.oid,
|
||||||
|
entraOid: user.oid,
|
||||||
|
tenantId: user.tid,
|
||||||
|
displayName: user.displayName,
|
||||||
|
},
|
||||||
|
privileges: portalPrivileges as Principal['privileges'],
|
||||||
|
roles: [],
|
||||||
|
scopes: [{ kind: 'unrestricted' } satisfies Scope],
|
||||||
|
amr: user.amr,
|
||||||
|
};
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a scope list as the `string[]` shape the
|
||||||
|
* `AuthorizationDeniedInput.held` audit field expects:
|
||||||
|
* `unrestricted` / `self` / `siege` ride as-is;
|
||||||
|
* `etablissement:<finess>` / `delegation:<dept>` / `region:<insee>`
|
||||||
|
* concatenate kind and value. Mirrors `PrincipalProjector` in
|
||||||
|
* spirit but is independent (the audit module must not depend on
|
||||||
|
* the AI-bridge projector — different surfaces).
|
||||||
|
*/
|
||||||
|
export function scopesToAuditStrings(scopes: ReadonlyArray<Scope>): string[] {
|
||||||
|
return scopes.map((s) => {
|
||||||
|
if (s.kind === 'etablissement' || s.kind === 'delegation' || s.kind === 'region') {
|
||||||
|
return `${s.kind}:${s.value}`;
|
||||||
|
}
|
||||||
|
return s.kind;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
|
||||||
|
import type { Privilege } from 'shared-auth';
|
||||||
|
import { RequirePrivilegeGuard } from './require-privilege.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reflector metadata key carrying the privilege list a route
|
||||||
|
* requires. Re-exported by the guard module so the two stay in
|
||||||
|
* lockstep.
|
||||||
|
*/
|
||||||
|
export const REQUIRE_PRIVILEGE_METADATA = 'auth:require-privilege';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `@RequirePrivilege('Portal.X', ...)` — gates a route on
|
||||||
|
* `principal.privileges` containing at least one of the listed
|
||||||
|
* `Portal.*` values per [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
|
||||||
|
*
|
||||||
|
* Multiple slugs compose as **OR** within the decorator. Stacking
|
||||||
|
* `@RequirePrivilege` with another guard (`@RequireRole`,
|
||||||
|
* `@RequireMfa`) **AND**-combines at the Nest level — each guard
|
||||||
|
* runs separately and a single denial stops the request.
|
||||||
|
*
|
||||||
|
* Calling with an empty privilege list is rejected at the type
|
||||||
|
* level by the `[Privilege, ...Privilege[]]` tuple — a route can
|
||||||
|
* not opt out of authorization by passing zero values.
|
||||||
|
*/
|
||||||
|
export function RequirePrivilege(
|
||||||
|
...privileges: [Privilege, ...Privilege[]]
|
||||||
|
): ClassDecorator & MethodDecorator {
|
||||||
|
return applyDecorators(
|
||||||
|
SetMetadata(REQUIRE_PRIVILEGE_METADATA, privileges),
|
||||||
|
UseGuards(RequirePrivilegeGuard),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import type { Reflector } from '@nestjs/core';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import type { Principal, Privilege } from 'shared-auth';
|
||||||
|
import type { AuditWriter } from '../audit/audit.service';
|
||||||
|
import { RequirePrivilegeGuard } from './require-privilege.guard';
|
||||||
|
|
||||||
|
function principalFor(privileges: readonly Privilege[]): Principal {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
personId: 'user-1',
|
||||||
|
entraOid: 'oid-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
},
|
||||||
|
privileges,
|
||||||
|
roles: [],
|
||||||
|
scopes: [{ kind: 'unrestricted' }],
|
||||||
|
amr: ['pwd', 'mfa'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeContext(opts: {
|
||||||
|
metadata: readonly Privilege[] | undefined;
|
||||||
|
session?: { principal?: Principal };
|
||||||
|
method?: string;
|
||||||
|
url?: string;
|
||||||
|
}): { ctx: ExecutionContext; req: Request } {
|
||||||
|
const req = {
|
||||||
|
session: opts.session,
|
||||||
|
method: opts.method ?? 'GET',
|
||||||
|
originalUrl: opts.url ?? '/api/audit',
|
||||||
|
} as unknown as Request;
|
||||||
|
const ctx = {
|
||||||
|
switchToHttp: () => ({ getRequest: <T>() => req as T }),
|
||||||
|
getHandler: () => 'handler',
|
||||||
|
getClass: () => 'AuditController',
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
return { ctx, req };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGuard(opts: { metadata: readonly Privilege[] | undefined; audit?: jest.Mock }): {
|
||||||
|
guard: RequirePrivilegeGuard;
|
||||||
|
audit: jest.Mock;
|
||||||
|
} {
|
||||||
|
const audit = opts.audit ?? jest.fn().mockResolvedValue(undefined);
|
||||||
|
const reflector = {
|
||||||
|
getAllAndOverride: jest.fn().mockReturnValue(opts.metadata),
|
||||||
|
} as unknown as Reflector;
|
||||||
|
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
|
||||||
|
return { guard: new RequirePrivilegeGuard(reflector, writer), audit };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RequirePrivilegeGuard', () => {
|
||||||
|
it('passes when no metadata is set (decorator absent — guard wired but route unprotected)', async () => {
|
||||||
|
const { guard } = makeGuard({ metadata: undefined });
|
||||||
|
const { ctx } = makeContext({ metadata: undefined });
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 401 when no session is present', async () => {
|
||||||
|
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
|
||||||
|
const { ctx } = makeContext({ metadata: ['Portal.Admin'], session: undefined });
|
||||||
|
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
expect(audit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when the principal carries the required privilege', async () => {
|
||||||
|
const { guard } = makeGuard({ metadata: ['Portal.Admin'] });
|
||||||
|
const { ctx } = makeContext({
|
||||||
|
metadata: ['Portal.Admin'],
|
||||||
|
session: { principal: principalFor(['Portal.Admin']) },
|
||||||
|
});
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OR-combines multiple required privileges (matches any one)', async () => {
|
||||||
|
const { guard } = makeGuard({
|
||||||
|
metadata: ['Portal.Auditor', 'Portal.Admin'],
|
||||||
|
});
|
||||||
|
const { ctx } = makeContext({
|
||||||
|
metadata: ['Portal.Auditor', 'Portal.Admin'],
|
||||||
|
session: { principal: principalFor(['Portal.Auditor']) },
|
||||||
|
});
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 403 + audits when the principal lacks every required privilege', async () => {
|
||||||
|
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
|
||||||
|
const { ctx } = makeContext({
|
||||||
|
metadata: ['Portal.Admin'],
|
||||||
|
session: { principal: principalFor(['Portal.Auditor']) },
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/admin/cms/pages',
|
||||||
|
});
|
||||||
|
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(audit).toHaveBeenCalledWith({
|
||||||
|
actor: { oid: 'oid-1' },
|
||||||
|
attemptedRoute: 'POST /api/admin/cms/pages',
|
||||||
|
kind: 'privilege',
|
||||||
|
required: ['Portal.Admin'],
|
||||||
|
held: ['Portal.Auditor'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('audits the held privileges as the principal saw them, not the legacy roles claim', async () => {
|
||||||
|
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
|
||||||
|
const { ctx } = makeContext({
|
||||||
|
metadata: ['Portal.Admin'],
|
||||||
|
session: { principal: principalFor([]) },
|
||||||
|
});
|
||||||
|
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ held: [] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a generic forbidden body (no privilege hint leaks)', async () => {
|
||||||
|
const { guard } = makeGuard({ metadata: ['Portal.Admin'] });
|
||||||
|
const { ctx } = makeContext({
|
||||||
|
metadata: ['Portal.Admin'],
|
||||||
|
session: { principal: principalFor([]) },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await guard.canActivate(ctx);
|
||||||
|
throw new Error('guard did not throw');
|
||||||
|
} catch (err) {
|
||||||
|
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
|
||||||
|
expect(body['code']).toBe('forbidden');
|
||||||
|
expect(body['message']).toBe('Forbidden');
|
||||||
|
expect(JSON.stringify(body)).not.toContain('Portal.Admin');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { type Privilege, principalHasAnyPrivilege } from 'shared-auth';
|
||||||
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
|
import { readSessionPrincipal } from './principal-extractor';
|
||||||
|
import { REQUIRE_PRIVILEGE_METADATA } from './require-privilege.decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `RequirePrivilegeGuard` — enforces ADR-0025 privilege gates.
|
||||||
|
*
|
||||||
|
* Contract (matches `AdminRoleGuard` for symmetry):
|
||||||
|
*
|
||||||
|
* - **No session → 401 `unauthenticated`.** Anonymous traffic is
|
||||||
|
* noise; no audit row, the SPA kicks off the login flow.
|
||||||
|
*
|
||||||
|
* - **Session but missing every required privilege → 403 + audit.**
|
||||||
|
* The user *is* authenticated; they just are not authorised.
|
||||||
|
* Audit row: `auth.authorization_denied`, `kind=privilege`,
|
||||||
|
* `required` = what the route asked for, `held` = what the
|
||||||
|
* principal actually carries.
|
||||||
|
*
|
||||||
|
* - **Session with at least one required privilege → pass.**
|
||||||
|
*
|
||||||
|
* The structured error envelope per [ADR-0021](../../../../docs/decisions/0021-phase-2-security-baseline.md):
|
||||||
|
* the body is `{ error: { code: 'forbidden', message, traceId } }`
|
||||||
|
* with a generic message — no role / privilege hint, so an
|
||||||
|
* attacker cannot use 403 responses to enumerate which privilege
|
||||||
|
* a route gates.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RequirePrivilegeGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly audit: AuditWriter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const required = this.reflector.getAllAndOverride<readonly Privilege[]>(
|
||||||
|
REQUIRE_PRIVILEGE_METADATA,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
if (required === undefined || required.length === 0) {
|
||||||
|
// No metadata = guard wired but route un-annotated. Treat as
|
||||||
|
// pass-through rather than guess at the intent — the decorator
|
||||||
|
// is what carries the privilege list.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = context.switchToHttp().getRequest<Request>();
|
||||||
|
const principal = readSessionPrincipal(req);
|
||||||
|
if (principal === null) {
|
||||||
|
throw new UnauthorizedException({
|
||||||
|
code: 'unauthenticated',
|
||||||
|
message: 'Unauthenticated',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (principalHasAnyPrivilege(principal, required)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.audit.authorizationDenied({
|
||||||
|
actor: { oid: principal.user.entraOid },
|
||||||
|
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
||||||
|
kind: 'privilege',
|
||||||
|
required,
|
||||||
|
held: [...principal.privileges],
|
||||||
|
});
|
||||||
|
throw new ForbiddenException({
|
||||||
|
code: 'forbidden',
|
||||||
|
message: 'Forbidden',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
|
||||||
|
import type { FunctionalRole } from 'shared-auth';
|
||||||
|
import { RequireRoleGuard } from './require-role.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reflector metadata key carrying the functional-role list a
|
||||||
|
* route requires.
|
||||||
|
*/
|
||||||
|
export const REQUIRE_ROLE_METADATA = 'auth:require-role';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `@RequireRole('rh', ...)` — gates a route on `principal.roles`
|
||||||
|
* containing at least one of the listed `apf-role-*` slugs per
|
||||||
|
* [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
|
||||||
|
*
|
||||||
|
* Multiple slugs compose as **OR** within the decorator. Stacking
|
||||||
|
* `@RequireRole` with another guard (`@RequirePrivilege`,
|
||||||
|
* `@RequireScope`, `@RequireMfa`) **AND**-combines at the Nest
|
||||||
|
* level.
|
||||||
|
*
|
||||||
|
* The empty-list call is rejected at the type level by the
|
||||||
|
* `[FunctionalRole, ...FunctionalRole[]]` tuple — a route can
|
||||||
|
* not opt out of authorization by passing zero values.
|
||||||
|
*/
|
||||||
|
export function RequireRole(
|
||||||
|
...roles: [FunctionalRole, ...FunctionalRole[]]
|
||||||
|
): ClassDecorator & MethodDecorator {
|
||||||
|
return applyDecorators(SetMetadata(REQUIRE_ROLE_METADATA, roles), UseGuards(RequireRoleGuard));
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import type { Reflector } from '@nestjs/core';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import type { FunctionalRole, Principal } from 'shared-auth';
|
||||||
|
import type { AuditWriter } from '../audit/audit.service';
|
||||||
|
import { RequireRoleGuard } from './require-role.guard';
|
||||||
|
|
||||||
|
function principalFor(roles: readonly FunctionalRole[]): Principal {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
personId: 'user-1',
|
||||||
|
entraOid: 'oid-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
},
|
||||||
|
privileges: [],
|
||||||
|
roles,
|
||||||
|
scopes: [{ kind: 'unrestricted' }],
|
||||||
|
amr: ['pwd', 'mfa'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeContext(opts: {
|
||||||
|
session?: { principal?: Principal };
|
||||||
|
method?: string;
|
||||||
|
url?: string;
|
||||||
|
}): { ctx: ExecutionContext; req: Request } {
|
||||||
|
const req = {
|
||||||
|
session: opts.session,
|
||||||
|
method: opts.method ?? 'GET',
|
||||||
|
originalUrl: opts.url ?? '/api/hr/payroll',
|
||||||
|
} as unknown as Request;
|
||||||
|
const ctx = {
|
||||||
|
switchToHttp: () => ({ getRequest: <T>() => req as T }),
|
||||||
|
getHandler: () => 'handler',
|
||||||
|
getClass: () => 'HrController',
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
return { ctx, req };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGuard(opts: { metadata: readonly FunctionalRole[] | undefined }): {
|
||||||
|
guard: RequireRoleGuard;
|
||||||
|
audit: jest.Mock;
|
||||||
|
} {
|
||||||
|
const audit = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const reflector = {
|
||||||
|
getAllAndOverride: jest.fn().mockReturnValue(opts.metadata),
|
||||||
|
} as unknown as Reflector;
|
||||||
|
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
|
||||||
|
return { guard: new RequireRoleGuard(reflector, writer), audit };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RequireRoleGuard', () => {
|
||||||
|
it('passes when no metadata is set', async () => {
|
||||||
|
const { guard } = makeGuard({ metadata: undefined });
|
||||||
|
const { ctx } = makeContext({});
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 401 when no session is present', async () => {
|
||||||
|
const { guard, audit } = makeGuard({ metadata: ['rh'] });
|
||||||
|
const { ctx } = makeContext({ session: undefined });
|
||||||
|
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
expect(audit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when the principal carries the required role', async () => {
|
||||||
|
const { guard } = makeGuard({ metadata: ['rh'] });
|
||||||
|
const { ctx } = makeContext({ session: { principal: principalFor(['rh']) } });
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OR-combines multiple required roles', async () => {
|
||||||
|
const { guard } = makeGuard({ metadata: ['rh', 'responsable-paie', 'comptable'] });
|
||||||
|
const { ctx } = makeContext({
|
||||||
|
session: { principal: principalFor(['comptable']) },
|
||||||
|
});
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 403 + audits when the principal lacks every required role', async () => {
|
||||||
|
const { guard, audit } = makeGuard({ metadata: ['rh'] });
|
||||||
|
const { ctx } = makeContext({
|
||||||
|
session: { principal: principalFor(['collaborateur']) },
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/hr/payroll',
|
||||||
|
});
|
||||||
|
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(audit).toHaveBeenCalledWith({
|
||||||
|
actor: { oid: 'oid-1' },
|
||||||
|
attemptedRoute: 'GET /api/hr/payroll',
|
||||||
|
kind: 'role',
|
||||||
|
required: ['rh'],
|
||||||
|
held: ['collaborateur'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a generic forbidden body (no role hint leaks)', async () => {
|
||||||
|
const { guard } = makeGuard({ metadata: ['rh'] });
|
||||||
|
const { ctx } = makeContext({ session: { principal: principalFor([]) } });
|
||||||
|
try {
|
||||||
|
await guard.canActivate(ctx);
|
||||||
|
throw new Error('guard did not throw');
|
||||||
|
} catch (err) {
|
||||||
|
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
|
||||||
|
expect(body['code']).toBe('forbidden');
|
||||||
|
expect(JSON.stringify(body)).not.toContain('rh');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { type FunctionalRole, principalHasAnyRole } from 'shared-auth';
|
||||||
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
|
import { readSessionPrincipal } from './principal-extractor';
|
||||||
|
import { REQUIRE_ROLE_METADATA } from './require-role.decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `RequireRoleGuard` — enforces ADR-0025 functional-role gates.
|
||||||
|
*
|
||||||
|
* Same contract as `RequirePrivilegeGuard` with the role axis as
|
||||||
|
* the source of truth: 401 if anonymous, 403 + audit if
|
||||||
|
* authenticated but missing every required role, pass otherwise.
|
||||||
|
* Multiple roles on the decorator are OR-combined; stacking with
|
||||||
|
* other decorators is AND-combined at the Nest layer.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RequireRoleGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly audit: AuditWriter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const required = this.reflector.getAllAndOverride<readonly FunctionalRole[]>(
|
||||||
|
REQUIRE_ROLE_METADATA,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
if (required === undefined || required.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = context.switchToHttp().getRequest<Request>();
|
||||||
|
const principal = readSessionPrincipal(req);
|
||||||
|
if (principal === null) {
|
||||||
|
throw new UnauthorizedException({
|
||||||
|
code: 'unauthenticated',
|
||||||
|
message: 'Unauthenticated',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (principalHasAnyRole(principal, required)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.audit.authorizationDenied({
|
||||||
|
actor: { oid: principal.user.entraOid },
|
||||||
|
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
||||||
|
kind: 'role',
|
||||||
|
required,
|
||||||
|
held: [...principal.roles],
|
||||||
|
});
|
||||||
|
throw new ForbiddenException({
|
||||||
|
code: 'forbidden',
|
||||||
|
message: 'Forbidden',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import type { ScopableResource } from 'shared-auth';
|
||||||
|
import { RequireScopeGuard } from './require-scope.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reflector metadata key carrying the per-route resource
|
||||||
|
* extractor. The metadata value is the extractor function
|
||||||
|
* itself — `Reflect.metadata` happily stores callables and the
|
||||||
|
* guard invokes it on every request.
|
||||||
|
*/
|
||||||
|
export const REQUIRE_SCOPE_METADATA = 'auth:require-scope';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extractor signature consumed by `@RequireScope`. Receives the
|
||||||
|
* incoming Express request, returns the resource descriptor the
|
||||||
|
* matcher will compare against the principal's scopes. May be
|
||||||
|
* async — a typical extractor loads the resource by
|
||||||
|
* `@Param('id')` from Prisma, denormalises its parentage chain
|
||||||
|
* (`etablissementFiness` / `delegationCode` / `regionCode`), and
|
||||||
|
* returns the descriptor.
|
||||||
|
*
|
||||||
|
* Returning `null` from the extractor means "the resource the
|
||||||
|
* route protects could not be identified" (a 404 in disguise) —
|
||||||
|
* the guard treats it as a denial and emits the audit row with
|
||||||
|
* `required: []`. The route handler still runs if the guard
|
||||||
|
* lets it through; routes that want a true 404 should surface
|
||||||
|
* it from the handler.
|
||||||
|
*/
|
||||||
|
export type RequireScopeExtractor = (
|
||||||
|
req: Request,
|
||||||
|
) => ScopableResource | null | Promise<ScopableResource | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `@RequireScope(req => …)` — gates a route on
|
||||||
|
* `principalCoversResource(principal, extractor(req))` per
|
||||||
|
* [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
|
||||||
|
*
|
||||||
|
* The extractor receives the Express request — typically pulls
|
||||||
|
* an id from `req.params`, loads the resource, returns its
|
||||||
|
* scope-relevant chain (FINESS / delegation / region). The guard
|
||||||
|
* does not assume the extractor is cheap — routes that load the
|
||||||
|
* resource a second time inside the handler should pass the
|
||||||
|
* Prisma instance via a request-scoped CLS or DI, not via the
|
||||||
|
* extractor.
|
||||||
|
*/
|
||||||
|
export function RequireScope(extractor: RequireScopeExtractor): ClassDecorator & MethodDecorator {
|
||||||
|
return applyDecorators(
|
||||||
|
SetMetadata(REQUIRE_SCOPE_METADATA, extractor),
|
||||||
|
UseGuards(RequireScopeGuard),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import type { Reflector } from '@nestjs/core';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import type { Principal, ScopableResource, Scope } from 'shared-auth';
|
||||||
|
import type { AuditWriter } from '../audit/audit.service';
|
||||||
|
import type { RequireScopeExtractor } from './require-scope.decorator';
|
||||||
|
import { RequireScopeGuard } from './require-scope.guard';
|
||||||
|
|
||||||
|
function principalFor(scopes: readonly Scope[]): Principal {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
personId: 'person-1',
|
||||||
|
entraOid: 'oid-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
},
|
||||||
|
privileges: [],
|
||||||
|
roles: [],
|
||||||
|
scopes,
|
||||||
|
amr: ['pwd', 'mfa'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeContext(opts: {
|
||||||
|
session?: { principal?: Principal };
|
||||||
|
method?: string;
|
||||||
|
url?: string;
|
||||||
|
}): { ctx: ExecutionContext; req: Request } {
|
||||||
|
const req = {
|
||||||
|
session: opts.session,
|
||||||
|
method: opts.method ?? 'GET',
|
||||||
|
originalUrl: opts.url ?? '/api/etablissements/0330800013',
|
||||||
|
} as unknown as Request;
|
||||||
|
const ctx = {
|
||||||
|
switchToHttp: () => ({ getRequest: <T>() => req as T }),
|
||||||
|
getHandler: () => 'handler',
|
||||||
|
getClass: () => 'EtablissementController',
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
return { ctx, req };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGuard(opts: { extractor: RequireScopeExtractor | undefined }): {
|
||||||
|
guard: RequireScopeGuard;
|
||||||
|
audit: jest.Mock;
|
||||||
|
} {
|
||||||
|
const audit = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const reflector = {
|
||||||
|
getAllAndOverride: jest.fn().mockReturnValue(opts.extractor),
|
||||||
|
} as unknown as Reflector;
|
||||||
|
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
|
||||||
|
return { guard: new RequireScopeGuard(reflector, writer), audit };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RequireScopeGuard', () => {
|
||||||
|
it('passes when no extractor metadata is set', async () => {
|
||||||
|
const { guard } = makeGuard({ extractor: undefined });
|
||||||
|
const { ctx } = makeContext({});
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 401 when no session is present', async () => {
|
||||||
|
const { guard, audit } = makeGuard({
|
||||||
|
extractor: () => ({ etablissementFiness: '0330800013' }),
|
||||||
|
});
|
||||||
|
const { ctx } = makeContext({ session: undefined });
|
||||||
|
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
expect(audit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when the principal scope covers the resource', async () => {
|
||||||
|
const { guard } = makeGuard({
|
||||||
|
extractor: () => ({ etablissementFiness: '0330800013' }),
|
||||||
|
});
|
||||||
|
const { ctx } = makeContext({
|
||||||
|
session: {
|
||||||
|
principal: principalFor([{ kind: 'etablissement', value: '0330800013' }]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours async extractors (Prisma lookup pattern)', async () => {
|
||||||
|
const { guard } = makeGuard({
|
||||||
|
extractor: async () => Promise.resolve({ delegationCode: '33' }),
|
||||||
|
});
|
||||||
|
const { ctx } = makeContext({
|
||||||
|
session: { principal: principalFor([{ kind: 'delegation', value: '33' }]) },
|
||||||
|
});
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 403 + audits when the resource is outside the principal scopes', async () => {
|
||||||
|
const { guard, audit } = makeGuard({
|
||||||
|
extractor: () => ({ etablissementFiness: '0330800021' }),
|
||||||
|
});
|
||||||
|
const { ctx } = makeContext({
|
||||||
|
session: {
|
||||||
|
principal: principalFor([{ kind: 'etablissement', value: '0330800013' }]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(audit).toHaveBeenCalledWith({
|
||||||
|
actor: { oid: 'oid-1' },
|
||||||
|
attemptedRoute: 'GET /api/etablissements/0330800013',
|
||||||
|
kind: 'scope',
|
||||||
|
required: ['etablissement:0330800021'],
|
||||||
|
held: ['etablissement:0330800013'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 403 + audits with empty required when the extractor returns null', async () => {
|
||||||
|
const { guard, audit } = makeGuard({ extractor: () => null });
|
||||||
|
const { ctx } = makeContext({
|
||||||
|
session: { principal: principalFor([{ kind: 'unrestricted' }]) },
|
||||||
|
});
|
||||||
|
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ kind: 'scope', required: [] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes the resource correctly when it carries the full parentage chain', async () => {
|
||||||
|
const resource: ScopableResource = {
|
||||||
|
etablissementFiness: '0330800021',
|
||||||
|
delegationCode: '33',
|
||||||
|
regionCode: '75',
|
||||||
|
};
|
||||||
|
const { guard, audit } = makeGuard({ extractor: () => resource });
|
||||||
|
const { ctx } = makeContext({
|
||||||
|
session: { principal: principalFor([{ kind: 'region', value: '11' }]) },
|
||||||
|
});
|
||||||
|
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(audit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
required: ['etablissement:0330800021', 'delegation:33', 'region:75'],
|
||||||
|
held: ['region:11'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a generic forbidden body (no resource fingerprint leaks)', async () => {
|
||||||
|
const { guard } = makeGuard({
|
||||||
|
extractor: () => ({ etablissementFiness: '0330800021' }),
|
||||||
|
});
|
||||||
|
const { ctx } = makeContext({
|
||||||
|
session: { principal: principalFor([]) },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await guard.canActivate(ctx);
|
||||||
|
throw new Error('guard did not throw');
|
||||||
|
} catch (err) {
|
||||||
|
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
|
||||||
|
expect(body['code']).toBe('forbidden');
|
||||||
|
expect(JSON.stringify(body)).not.toContain('0330800021');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { principalCoversResource, type ScopableResource } from 'shared-auth';
|
||||||
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
|
import { readSessionPrincipal, scopesToAuditStrings } from './principal-extractor';
|
||||||
|
import { REQUIRE_SCOPE_METADATA, type RequireScopeExtractor } from './require-scope.decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `RequireScopeGuard` — enforces ADR-0025 scope gates.
|
||||||
|
*
|
||||||
|
* Contract
|
||||||
|
* --------
|
||||||
|
* - **No session → 401.** Same posture as the privilege / role
|
||||||
|
* guards.
|
||||||
|
* - **Extractor returned `null` → 403 + audit.** The route's
|
||||||
|
* "what resource am I protecting?" question has no answer for
|
||||||
|
* this request; deny is the safe direction. Audit row carries
|
||||||
|
* `required: []` so an auditor can spot the missing-resource
|
||||||
|
* pattern.
|
||||||
|
* - **No matching scope → 403 + audit.** Standard denial; audit
|
||||||
|
* captures the resource descriptor as `required` and the
|
||||||
|
* principal's actual scope list as `held`.
|
||||||
|
* - **Matching scope → pass.**
|
||||||
|
*
|
||||||
|
* Like the other two new guards, the response body is the
|
||||||
|
* ADR-0021 structured-error envelope with a generic `forbidden`
|
||||||
|
* code — no resource fingerprint leaks back to the caller.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RequireScopeGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly audit: AuditWriter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const extractor = this.reflector.getAllAndOverride<RequireScopeExtractor>(
|
||||||
|
REQUIRE_SCOPE_METADATA,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
if (extractor === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = context.switchToHttp().getRequest<Request>();
|
||||||
|
const principal = readSessionPrincipal(req);
|
||||||
|
if (principal === null) {
|
||||||
|
throw new UnauthorizedException({
|
||||||
|
code: 'unauthenticated',
|
||||||
|
message: 'Unauthenticated',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = await extractor(req);
|
||||||
|
if (resource === null) {
|
||||||
|
await this.audit.authorizationDenied({
|
||||||
|
actor: { oid: principal.user.entraOid },
|
||||||
|
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
||||||
|
kind: 'scope',
|
||||||
|
required: [],
|
||||||
|
held: scopesToAuditStrings(principal.scopes),
|
||||||
|
});
|
||||||
|
throw new ForbiddenException({
|
||||||
|
code: 'forbidden',
|
||||||
|
message: 'Forbidden',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (principalCoversResource(principal, resource)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.audit.authorizationDenied({
|
||||||
|
actor: { oid: principal.user.entraOid },
|
||||||
|
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
||||||
|
kind: 'scope',
|
||||||
|
required: describeResource(resource),
|
||||||
|
held: scopesToAuditStrings(principal.scopes),
|
||||||
|
});
|
||||||
|
throw new ForbiddenException({
|
||||||
|
code: 'forbidden',
|
||||||
|
message: 'Forbidden',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise the resource descriptor into the `string[]` shape the
|
||||||
|
* audit row's `required` field expects. Mirrors the `scope:value`
|
||||||
|
* convention of {@link scopesToAuditStrings} so the two sides of
|
||||||
|
* the audit row read consistently — easy `requiredVs.held` diff
|
||||||
|
* in a log dashboard.
|
||||||
|
*/
|
||||||
|
function describeResource(resource: ScopableResource): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
if (resource.personId !== undefined) {
|
||||||
|
out.push(`self:${resource.personId}`);
|
||||||
|
}
|
||||||
|
if (resource.etablissementFiness !== undefined) {
|
||||||
|
out.push(`etablissement:${resource.etablissementFiness}`);
|
||||||
|
}
|
||||||
|
if (resource.delegationCode !== undefined) {
|
||||||
|
out.push(`delegation:${resource.delegationCode}`);
|
||||||
|
}
|
||||||
|
if (resource.regionCode !== undefined) {
|
||||||
|
out.push(`region:${resource.regionCode}`);
|
||||||
|
}
|
||||||
|
if (resource.isSiege === true) {
|
||||||
|
out.push('siege');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,82 +1,57 @@
|
|||||||
import { UnauthorizedException } from '@nestjs/common';
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
|
import type { Principal, Privilege } from 'shared-auth';
|
||||||
import { MeController } from './me.controller';
|
import { MeController } from './me.controller';
|
||||||
|
|
||||||
function makeReq(
|
function makeReq(
|
||||||
user: {
|
privileges: readonly Privilege[] | null,
|
||||||
oid: string;
|
overrides: { displayName?: string } = {},
|
||||||
tid: string;
|
|
||||||
username: string;
|
|
||||||
displayName: string;
|
|
||||||
amr: readonly string[];
|
|
||||||
roles: readonly string[];
|
|
||||||
} | null,
|
|
||||||
): Request {
|
): Request {
|
||||||
return { session: { user } } as unknown as Request;
|
if (privileges === null) {
|
||||||
|
return { session: {} } as unknown as Request;
|
||||||
|
}
|
||||||
|
const principal: Principal = {
|
||||||
|
user: {
|
||||||
|
id: 'user-oid',
|
||||||
|
personId: 'user-oid',
|
||||||
|
entraOid: 'user-oid',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
displayName: overrides.displayName ?? 'Jane',
|
||||||
|
},
|
||||||
|
privileges,
|
||||||
|
roles: [],
|
||||||
|
scopes: [{ kind: 'unrestricted' }],
|
||||||
|
amr: ['pwd', 'mfa'],
|
||||||
|
};
|
||||||
|
return { session: { principal } } as unknown as Request;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('MeController', () => {
|
describe('MeController', () => {
|
||||||
const controller = new MeController();
|
const controller = new MeController();
|
||||||
|
|
||||||
it('returns canAccessAdmin: true when the session carries Portal.Admin', () => {
|
it('returns canAccessAdmin: true when the principal carries Portal.Admin', () => {
|
||||||
const result = controller.capabilities(
|
const result = controller.capabilities(makeReq(['Portal.Admin', 'Portal.DPO']));
|
||||||
makeReq({
|
|
||||||
oid: 'oid-1',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
username: 'admin@apf.example',
|
|
||||||
displayName: 'Admin Smith',
|
|
||||||
amr: ['pwd', 'mfa'],
|
|
||||||
roles: ['Portal.Admin', 'Other.Role'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result).toEqual({ canAccessAdmin: true });
|
expect(result).toEqual({ canAccessAdmin: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns canAccessAdmin: false when the session lacks Portal.Admin', () => {
|
it('returns canAccessAdmin: false when the principal carries other privileges only', () => {
|
||||||
const result = controller.capabilities(
|
const result = controller.capabilities(makeReq(['Portal.Auditor']));
|
||||||
makeReq({
|
|
||||||
oid: 'oid-2',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
username: 'jane.doe@apf.example',
|
|
||||||
displayName: 'Jane Doe',
|
|
||||||
amr: ['pwd'],
|
|
||||||
roles: ['Other.Role'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result).toEqual({ canAccessAdmin: false });
|
expect(result).toEqual({ canAccessAdmin: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns canAccessAdmin: false when the session carries no roles', () => {
|
it('returns canAccessAdmin: false when the principal has no privileges', () => {
|
||||||
const result = controller.capabilities(
|
const result = controller.capabilities(makeReq([]));
|
||||||
makeReq({
|
|
||||||
oid: 'oid-3',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
username: 'guest@apf.example',
|
|
||||||
displayName: 'Guest',
|
|
||||||
amr: [],
|
|
||||||
roles: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result).toEqual({ canAccessAdmin: false });
|
expect(result).toEqual({ canAccessAdmin: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws Unauthorized when no session is present', () => {
|
it('throws Unauthorized when no session principal is present', () => {
|
||||||
expect(() => controller.capabilities(makeReq(null))).toThrow(UnauthorizedException);
|
expect(() => controller.capabilities(makeReq(null))).toThrow(UnauthorizedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does NOT echo the raw roles array in the response (curated view contract)', () => {
|
it('does NOT echo the raw privileges array in the response (curated view contract)', () => {
|
||||||
const result = controller.capabilities(
|
const result = controller.capabilities(makeReq(['Portal.Admin', 'Portal.DPO']));
|
||||||
makeReq({
|
|
||||||
oid: 'oid-4',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
username: 'admin@apf.example',
|
|
||||||
displayName: 'Admin',
|
|
||||||
amr: ['pwd', 'mfa'],
|
|
||||||
roles: ['Portal.Admin', 'Confidential.Role'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(Object.keys(result)).toEqual(['canAccessAdmin']);
|
expect(Object.keys(result)).toEqual(['canAccessAdmin']);
|
||||||
expect(JSON.stringify(result)).not.toContain('Confidential.Role');
|
expect(JSON.stringify(result)).not.toContain('Portal.DPO');
|
||||||
expect(JSON.stringify(result)).not.toContain('Portal.Admin');
|
expect(JSON.stringify(result)).not.toContain('Portal.Admin');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Req, UnauthorizedException } from '@nestjs/common';
|
import { Controller, Get, Req, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
|
import { readSessionPrincipal } from '../auth/principal-extractor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated capabilities derived from the active user-portal session,
|
* Curated capabilities derived from the active user-portal session,
|
||||||
@@ -61,8 +62,8 @@ export class MeController {
|
|||||||
})
|
})
|
||||||
@Get('capabilities')
|
@Get('capabilities')
|
||||||
capabilities(@Req() req: Request): CapabilitiesView {
|
capabilities(@Req() req: Request): CapabilitiesView {
|
||||||
const user = req.session.user;
|
const principal = readSessionPrincipal(req);
|
||||||
if (!user) {
|
if (principal === null) {
|
||||||
// Same posture as `/api/auth/me`: 401 (rather than 200 with
|
// Same posture as `/api/auth/me`: 401 (rather than 200 with
|
||||||
// every-flag-false) so the SPA can distinguish "no session"
|
// every-flag-false) so the SPA can distinguish "no session"
|
||||||
// from "session exists but no flags". Keeps the consumer code
|
// from "session exists but no flags". Keeps the consumer code
|
||||||
@@ -73,7 +74,7 @@ export class MeController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
canAccessAdmin: user.roles.includes(ADMIN_ROLE),
|
canAccessAdmin: principal.privileges.includes(ADMIN_ROLE),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,3 +19,9 @@ export {
|
|||||||
EntraGroupMapError,
|
EntraGroupMapError,
|
||||||
} from './lib/entra-group-to-role';
|
} from './lib/entra-group-to-role';
|
||||||
export type { EntraGroupMap, EntraGroupMapEntry } from './lib/entra-group-to-role';
|
export type { EntraGroupMap, EntraGroupMapEntry } from './lib/entra-group-to-role';
|
||||||
|
export {
|
||||||
|
principalHasAnyPrivilege,
|
||||||
|
principalHasAnyRole,
|
||||||
|
principalCoversResource,
|
||||||
|
} from './lib/principal-matchers';
|
||||||
|
export type { ScopableResource } from './lib/principal-matchers';
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Principal, Scope } from './authorization.types';
|
||||||
|
import {
|
||||||
|
principalCoversResource,
|
||||||
|
principalHasAnyPrivilege,
|
||||||
|
principalHasAnyRole,
|
||||||
|
} from './principal-matchers';
|
||||||
|
|
||||||
|
function makePrincipal(overrides: Partial<Principal> = {}): Principal {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
personId: 'person-1',
|
||||||
|
entraOid: 'oid-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
},
|
||||||
|
privileges: [],
|
||||||
|
roles: [],
|
||||||
|
scopes: [],
|
||||||
|
amr: ['pwd', 'mfa'],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('principalHasAnyPrivilege', () => {
|
||||||
|
it('returns true when the principal carries one of the required privileges', () => {
|
||||||
|
const p = makePrincipal({ privileges: ['Portal.Admin'] });
|
||||||
|
expect(principalHasAnyPrivilege(p, ['Portal.Admin'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OR-combines multiple required privileges', () => {
|
||||||
|
const p = makePrincipal({ privileges: ['Portal.Auditor'] });
|
||||||
|
expect(principalHasAnyPrivilege(p, ['Portal.Admin', 'Portal.Auditor'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when none of the required privileges is held', () => {
|
||||||
|
const p = makePrincipal({ privileges: ['Portal.DPO'] });
|
||||||
|
expect(principalHasAnyPrivilege(p, ['Portal.Admin'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the principal has no privileges at all', () => {
|
||||||
|
const p = makePrincipal();
|
||||||
|
expect(principalHasAnyPrivilege(p, ['Portal.Admin'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// An empty requirement list is a degenerate case: a guard
|
||||||
|
// configured with no privilege constraint is equivalent to no
|
||||||
|
// guard at all. Document the contract so a caller cannot
|
||||||
|
// accidentally lock everyone out by passing an empty array.
|
||||||
|
it('returns true on an empty requirement list (no constraint)', () => {
|
||||||
|
const p = makePrincipal();
|
||||||
|
expect(principalHasAnyPrivilege(p, [])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('principalHasAnyRole', () => {
|
||||||
|
it('returns true when the principal carries one of the required roles', () => {
|
||||||
|
const p = makePrincipal({ roles: ['rh'] });
|
||||||
|
expect(principalHasAnyRole(p, ['rh'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OR-combines multiple required roles', () => {
|
||||||
|
const p = makePrincipal({ roles: ['comptable'] });
|
||||||
|
expect(principalHasAnyRole(p, ['rh', 'comptable', 'responsable-paie'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when none of the required roles is held', () => {
|
||||||
|
const p = makePrincipal({ roles: ['collaborateur'] });
|
||||||
|
expect(principalHasAnyRole(p, ['directeur-etablissement'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true on an empty requirement list (no constraint)', () => {
|
||||||
|
const p = makePrincipal({ roles: ['collaborateur'] });
|
||||||
|
expect(principalHasAnyRole(p, [])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('principalCoversResource — unrestricted', () => {
|
||||||
|
it('covers every resource regardless of its fields', () => {
|
||||||
|
const p = makePrincipal({ scopes: [{ kind: 'unrestricted' }] });
|
||||||
|
expect(principalCoversResource(p, {})).toBe(true);
|
||||||
|
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(true);
|
||||||
|
expect(principalCoversResource(p, { isSiege: true })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('principalCoversResource — self', () => {
|
||||||
|
it('covers a resource whose personId matches the principal user', () => {
|
||||||
|
const p = makePrincipal({ scopes: [{ kind: 'self' }] });
|
||||||
|
expect(principalCoversResource(p, { personId: 'person-1' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cover a resource owned by a different Person', () => {
|
||||||
|
const p = makePrincipal({ scopes: [{ kind: 'self' }] });
|
||||||
|
expect(principalCoversResource(p, { personId: 'person-2' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cover a resource that has no personId (collective resource)', () => {
|
||||||
|
const p = makePrincipal({ scopes: [{ kind: 'self' }] });
|
||||||
|
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('principalCoversResource — etablissement / delegation / region', () => {
|
||||||
|
it('etablissement scope covers a resource with the matching FINESS', () => {
|
||||||
|
const p = makePrincipal({
|
||||||
|
scopes: [{ kind: 'etablissement', value: '0330800013' }],
|
||||||
|
});
|
||||||
|
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('etablissement scope does not cover a different FINESS', () => {
|
||||||
|
const p = makePrincipal({
|
||||||
|
scopes: [{ kind: 'etablissement', value: '0330800013' }],
|
||||||
|
});
|
||||||
|
expect(principalCoversResource(p, { etablissementFiness: '0330800021' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegation scope covers a resource in the same department', () => {
|
||||||
|
const p = makePrincipal({
|
||||||
|
scopes: [{ kind: 'delegation', value: '33' }],
|
||||||
|
});
|
||||||
|
expect(principalCoversResource(p, { delegationCode: '33' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('region scope covers a resource in the same region', () => {
|
||||||
|
const p = makePrincipal({
|
||||||
|
scopes: [{ kind: 'region', value: '75' }],
|
||||||
|
});
|
||||||
|
expect(principalCoversResource(p, { regionCode: '75' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The matcher is intentionally non-transitive on the parentage
|
||||||
|
// chain: a `delegation:33` scope does NOT cover an etablissement
|
||||||
|
// unless the etablissement's `delegationCode` is supplied. The
|
||||||
|
// expansion lives in the caller (which knows the parentage of
|
||||||
|
// the resource it is protecting); the matcher just compares
|
||||||
|
// what the caller supplies.
|
||||||
|
it('delegation scope covers an etablissement when the etablissement carries delegationCode', () => {
|
||||||
|
const p = makePrincipal({
|
||||||
|
scopes: [{ kind: 'delegation', value: '33' }],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
principalCoversResource(p, {
|
||||||
|
etablissementFiness: '0330800013',
|
||||||
|
delegationCode: '33',
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegation scope does NOT cover an etablissement that omits delegationCode', () => {
|
||||||
|
const p = makePrincipal({
|
||||||
|
scopes: [{ kind: 'delegation', value: '33' }],
|
||||||
|
});
|
||||||
|
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('principalCoversResource — siege', () => {
|
||||||
|
it('covers a resource flagged isSiege: true', () => {
|
||||||
|
const p = makePrincipal({ scopes: [{ kind: 'siege' }] });
|
||||||
|
expect(principalCoversResource(p, { isSiege: true })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cover a non-siege resource', () => {
|
||||||
|
const p = makePrincipal({ scopes: [{ kind: 'siege' }] });
|
||||||
|
expect(principalCoversResource(p, { delegationCode: '75' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('principalCoversResource — composition', () => {
|
||||||
|
it('returns true on the first matching scope (OR-combine)', () => {
|
||||||
|
const p = makePrincipal({
|
||||||
|
scopes: [
|
||||||
|
{ kind: 'etablissement', value: '0330800013' },
|
||||||
|
{ kind: 'delegation', value: '75' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(principalCoversResource(p, { delegationCode: '75' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when an empty scope list — guards must deny safely on no coverage', () => {
|
||||||
|
const p = makePrincipal({ scopes: [] });
|
||||||
|
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exhaustive switch — every scope kind is handled (compile-time check)', () => {
|
||||||
|
// Iterate every kind by constructing one scope of each, with
|
||||||
|
// matching resource data. The matcher must return true for
|
||||||
|
// each; a missing case in the switch would fail at least one.
|
||||||
|
const scopes: Scope[] = [
|
||||||
|
{ kind: 'unrestricted' },
|
||||||
|
{ kind: 'self' },
|
||||||
|
{ kind: 'etablissement', value: 'f1' },
|
||||||
|
{ kind: 'delegation', value: '33' },
|
||||||
|
{ kind: 'region', value: '75' },
|
||||||
|
{ kind: 'siege' },
|
||||||
|
];
|
||||||
|
for (const scope of scopes) {
|
||||||
|
const p = makePrincipal({ scopes: [scope] });
|
||||||
|
const resource = {
|
||||||
|
personId: 'person-1',
|
||||||
|
etablissementFiness: 'f1',
|
||||||
|
delegationCode: '33',
|
||||||
|
regionCode: '75',
|
||||||
|
isSiege: true,
|
||||||
|
};
|
||||||
|
expect(principalCoversResource(p, resource)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import type { FunctionalRole, Principal, Privilege } from './authorization.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure matchers used by the BFF route guards (and, in the future,
|
||||||
|
* by SPA-side UI predicates) to evaluate a `Principal` against an
|
||||||
|
* authorization requirement per [ADR-0025 §"Guard surface"](../../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
|
||||||
|
*
|
||||||
|
* The matchers live in the shared lib so the projection contract
|
||||||
|
* between BFF and SPA stays single-sourced — the SPA can pre-check
|
||||||
|
* a privilege/role to decide whether to render a UI element with
|
||||||
|
* the exact same logic the BFF guards apply server-side. The SPA
|
||||||
|
* check is a UX optimisation (avoid rendering a button that 403s);
|
||||||
|
* the BFF guard is the authoritative gate.
|
||||||
|
*
|
||||||
|
* **Composition.** Each matcher takes a list of required values;
|
||||||
|
* a `Principal` matches when it holds **any** of them (OR). Decorator
|
||||||
|
* stacking is AND-combined at the Nest level (separate guards run
|
||||||
|
* sequentially), so the BFF can express
|
||||||
|
* `@RequireRole('rh') @RequirePrivilege('Portal.Admin')` to mean
|
||||||
|
* "RH **and** Admin"; within a single decorator the values are OR.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function principalHasAnyPrivilege(
|
||||||
|
principal: Principal,
|
||||||
|
required: ReadonlyArray<Privilege>,
|
||||||
|
): boolean {
|
||||||
|
if (required.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const want of required) {
|
||||||
|
if (principal.privileges.includes(want)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function principalHasAnyRole(
|
||||||
|
principal: Principal,
|
||||||
|
required: ReadonlyArray<FunctionalRole>,
|
||||||
|
): boolean {
|
||||||
|
if (required.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const want of required) {
|
||||||
|
if (principal.roles.includes(want)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape an `@RequireScope` handler hands to the matcher. Mirrors
|
||||||
|
* the parentage chain of a `Person`-scoped resource without
|
||||||
|
* pulling Prisma types into the shared lib — guards extract the
|
||||||
|
* subset their resource exposes (a route operating on an
|
||||||
|
* `Etablissement` populates `etablissementFiness`,
|
||||||
|
* `delegationCode`, `regionCode`; one operating on a `Dossier`
|
||||||
|
* populates the etablissement chain via the dossier's
|
||||||
|
* `etablissement` parent; one operating on a `Person` populates
|
||||||
|
* `personId` for the `self` rule).
|
||||||
|
*
|
||||||
|
* Every field is optional — the matcher iterates over the
|
||||||
|
* principal's scopes and returns true on the first scope that
|
||||||
|
* matches a present field. Missing parentage simply makes
|
||||||
|
* coarser-grained scopes fail to match, which is the safe
|
||||||
|
* direction (deny on doubt).
|
||||||
|
*/
|
||||||
|
export interface ScopableResource {
|
||||||
|
/**
|
||||||
|
* The resource's owner-`Person` id, matched against `self` scope.
|
||||||
|
* Present only on resources owned by a single Person (a `Dossier`
|
||||||
|
* the Person is the beneficiary of, a `NoteDeFrais` they
|
||||||
|
* submitted). Absent on collective resources (an `Etablissement`,
|
||||||
|
* a `Delegation`).
|
||||||
|
*/
|
||||||
|
readonly personId?: string;
|
||||||
|
/** FINESS code, matched against `etablissement:<finess>` scope. */
|
||||||
|
readonly etablissementFiness?: string;
|
||||||
|
/** French department code, matched against `delegation:<dept>` scope. */
|
||||||
|
readonly delegationCode?: string;
|
||||||
|
/** INSEE region code, matched against `region:<insee>` scope. */
|
||||||
|
readonly regionCode?: string;
|
||||||
|
/**
|
||||||
|
* True for resources that belong to the national head office.
|
||||||
|
* Matched against `siege` scope. Distinct from "lives in Paris" —
|
||||||
|
* a Paris-based délégation is `delegation:75`, not `siege`.
|
||||||
|
*/
|
||||||
|
readonly isSiege?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for `@RequireScope`. Returns `true` if at least one of
|
||||||
|
* the principal's scopes covers the resource per ADR-0025 §"Scope
|
||||||
|
* expansion at check time". Containment rules:
|
||||||
|
*
|
||||||
|
* - `unrestricted` covers every resource.
|
||||||
|
* - `self` covers a resource whose `personId` matches the
|
||||||
|
* principal's user — caller supplies `personId` when this
|
||||||
|
* comparison is meaningful for the route.
|
||||||
|
* - `etablissement:<finess>` covers a resource whose
|
||||||
|
* `etablissementFiness` equals the scope value.
|
||||||
|
* - `delegation:<dept>` covers a resource whose
|
||||||
|
* `delegationCode` equals the scope value.
|
||||||
|
* - `region:<insee>` covers a resource whose `regionCode`
|
||||||
|
* equals the scope value.
|
||||||
|
* - `siege` covers resources flagged `isSiege: true`.
|
||||||
|
*
|
||||||
|
* Empty scope list ⇒ no coverage (returns false). The BFF's
|
||||||
|
* `PrincipalBuilder` always populates *something* (in v1, every
|
||||||
|
* principal carries `[{ kind: 'unrestricted' }]` via the stub
|
||||||
|
* resolver), but the matcher does not assume that — a future
|
||||||
|
* Prisma-backed resolver returning an empty list for a user must
|
||||||
|
* deny safely.
|
||||||
|
*/
|
||||||
|
export function principalCoversResource(principal: Principal, resource: ScopableResource): boolean {
|
||||||
|
for (const scope of principal.scopes) {
|
||||||
|
switch (scope.kind) {
|
||||||
|
case 'unrestricted':
|
||||||
|
return true;
|
||||||
|
case 'self':
|
||||||
|
if (resource.personId !== undefined && resource.personId === principal.user.personId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'etablissement':
|
||||||
|
if (
|
||||||
|
resource.etablissementFiness !== undefined &&
|
||||||
|
scope.value === resource.etablissementFiness
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'delegation':
|
||||||
|
if (resource.delegationCode !== undefined && scope.value === resource.delegationCode) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'region':
|
||||||
|
if (resource.regionCode !== undefined && scope.value === resource.regionCode) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'siege':
|
||||||
|
if (resource.isSiege === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user