feat(portal): /api/me/capabilities + cross-app menu links + real role label
CI / scan (pull_request) Successful in 4m4s
CI / commits (pull_request) Successful in 4m4s
CI / check (pull_request) Successful in 4m59s
CI / a11y (pull_request) Successful in 1m31s
CI / perf (pull_request) Successful in 5m25s

PR 3 of 3 — final piece of the user-menu / profile / cross-app
chantier.

BFF
  * New `MeModule` ships `GET /api/me/capabilities`, a curated view
    derived from the active user-portal session. Returns
    `{ canAccessAdmin: boolean }` today; the shape is deliberately
    binary so the SPA cannot reconstruct the raw `roles` claim. 401
    on missing session, consistent posture with `/auth/me`.
  * ADR-0009 amended: new "Curated public view" section codifies
    the "no raw roles on `/auth/me`, capabilities is the release
    valve" stance; the routes table grows a row for the endpoint.

portal-shell
  * New `CapabilitiesService` (app-local) calls the BFF on auth-state
    flip and exposes `canAccessAdmin` as a signal. Anonymous
    sessions short-circuit to the all-false default without firing
    a request.
  * Header's `userMenuItems` is now a `computed` — the "Open Portal
    Admin" entry appears in the dropdown only when
    `canAccessAdmin()` is true, pointing at
    `environment.adminAppUrl`.
  * Sidebar role widget reads the auth + capabilities signals to
    render one of three labels ("Anonymous" / "User" /
    "Administrator") in place of the hardcoded "Anonymous".

portal-admin
  * Symmetric: unconditional "Open Portal Shell" entry in the admin
    user menu, pointing at `environment.shellAppUrl`. Anyone who
    can reach portal-admin can reach portal-shell, so no
    capabilities check needed.

i18n
  * `header.userMenu.openAdmin`, `sidebar.role.{administrator,user}`,
    `sidebar.role.aria` reshaped to take the role as a placeholder.
This commit is contained in:
Julien Gautier
2026-05-15 16:05:50 +02:00
parent 1160771061
commit 04138b435a
16 changed files with 520 additions and 52 deletions
+2
View File
@@ -8,6 +8,7 @@ import { AdminModule } from '../admin/admin.module';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { DownstreamModule } from '../downstream/downstream.module';
import { MeModule } from '../me/me.module';
import { RedisModule } from '../redis/redis.module';
import { SecurityModule } from '../security/security.module';
import { SessionModule } from '../session/session.module';
@@ -25,6 +26,7 @@ import { UsersModule } from '../users/users.module';
HealthModule,
AdminModule,
DownstreamModule,
MeModule,
UsersModule,
],
controllers: [AppController],
@@ -0,0 +1,82 @@
import { UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
import { MeController } from './me.controller';
function makeReq(
user: {
oid: string;
tid: string;
username: string;
displayName: string;
amr: readonly string[];
roles: readonly string[];
} | null,
): Request {
return { session: { user } } as unknown as Request;
}
describe('MeController', () => {
const controller = new MeController();
it('returns canAccessAdmin: true when the session carries Portal.Admin', () => {
const result = controller.capabilities(
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 });
});
it('returns canAccessAdmin: false when the session lacks Portal.Admin', () => {
const result = controller.capabilities(
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 });
});
it('returns canAccessAdmin: false when the session carries no roles', () => {
const result = controller.capabilities(
makeReq({
oid: 'oid-3',
tid: 'tenant-1',
username: 'guest@apf.example',
displayName: 'Guest',
amr: [],
roles: [],
}),
);
expect(result).toEqual({ canAccessAdmin: false });
});
it('throws Unauthorized when no session is present', () => {
expect(() => controller.capabilities(makeReq(null))).toThrow(UnauthorizedException);
});
it('does NOT echo the raw roles array in the response (curated view contract)', () => {
const result = controller.capabilities(
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(JSON.stringify(result)).not.toContain('Confidential.Role');
expect(JSON.stringify(result)).not.toContain('Portal.Admin');
});
});
+79
View File
@@ -0,0 +1,79 @@
import { Controller, Get, Req, UnauthorizedException } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
/**
* Curated capabilities derived from the active user-portal session,
* keyed by the binary access decisions the SPA needs to drive its
* own UI. Deliberately *not* a passthrough of `session.user.roles`:
* ADR-0009 §"curated public view" keeps the raw `roles` claim
* server-side; the SPA renders gates and cross-app links from the
* derived booleans this view exposes.
*
* Add a key when the SPA needs a new conditional rendering signal.
* Never add a key that hands back information not already deducible
* from the BFF's own server-side authorization decisions — the
* authoritative gate stays on the BFF guards (`@RequireAdmin`,
* `@RequireMfa`, …).
*/
export interface CapabilitiesView {
/**
* True when the session's roles include the `Portal.Admin` Entra
* app role — the SPA uses this to surface the "Open Portal Admin"
* entry in the user menu. The actual `/api/admin/*` access is
* gated server-side by `AdminRoleGuard`; this flag is UX only.
*/
readonly canAccessAdmin: boolean;
}
const ADMIN_ROLE = 'Portal.Admin';
/**
* `GET /api/me/capabilities` — projects the active session's roles
* onto a curated capabilities view consumed by `portal-shell`. The
* SPA calls this once per app boot (and on session refresh) to
* decide what to render: today, whether to expose the "Open Portal
* Admin" entry in the user menu; future keys land alongside as more
* conditional UI is required.
*
* Per ADR-0009 §"curated public view", the user-portal `/me` does
* NOT expose the raw `roles` claim — it stays server-side. This
* endpoint is the documented release-valve for binary UX hints,
* shaped so consumers can never reconstruct the raw role names.
*
* Mounted under `/api/me/*` rather than `/api/auth/*`: auth is the
* lifecycle (login, callback, /me identity, logout) — capabilities
* is a derived session view. Keeping the namespaces distinct gives
* the latter room to grow without bloating the auth surface.
*
* The path-routed session middleware in `main.ts` resolves this
* to the user-portal session (`portal_session`), so admin-only
* surfaces continue to be reached via `/api/admin/auth/me` for
* their identity payload and through the corresponding admin-side
* guards for authorization.
*/
@ApiTags('me')
@ApiCookieAuth('portal_session')
@Controller('me')
export class MeController {
@ApiOperation({
summary: 'Curated capabilities derived from the active user-portal session',
})
@Get('capabilities')
capabilities(@Req() req: Request): CapabilitiesView {
const user = req.session.user;
if (!user) {
// Same posture as `/api/auth/me`: 401 (rather than 200 with
// every-flag-false) so the SPA can distinguish "no session"
// from "session exists but no flags". Keeps the consumer code
// path identical between the two reads.
throw new UnauthorizedException({
code: 'unauthenticated',
message: 'Unauthenticated',
});
}
return {
canAccessAdmin: user.roles.includes(ADMIN_ROLE),
};
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { MeController } from './me.controller';
/**
* `MeModule` — owns `/api/me/*`, the namespace of session-derived
* curated views consumed by `portal-shell`. Currently ships the
* capabilities endpoint per ADR-0009 amendment; further projections
* (preferences, locale resolution, etc.) land under the same prefix.
*/
@Module({
controllers: [MeController],
})
export class MeModule {}