feat(admin): add /admin/users/:oid/scopes screen + endpoints (ADR-0026 PR 2b) (#236)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m49s
CI / a11y (push) Successful in 3m8s
CI / perf (push) Successful in 6m12s
CI / check (push) Failing after 1m42s

## Summary

ADR-0026 PR 2b (second half of PR 2). Ships the operator surface for the `@RequireScope` stack — the admin Angular screen at `/admin/users/:oid/scopes` lets an admin list / grant / revoke scopes for an existing portal User. Both write paths emit blocking audit rows per ADR-0013 (`admin.scope_granted` / `admin.scope_revoked`).

Closes the ADR-0026 trilogy:

| PR | Status | Effect |
| --- | --- | --- |
| ADR-0026 PR 1 (#232) | merged | Person + User + UserScope schema + provisioner + drift gate Person.source + PrincipalBuilder real UUIDs. |
| ADR-0026 PR 2a (#233) | merged | PrismaScopeResolver replaces stub + prisma/seed.ts populates 19 personas. |
| **ADR-0026 PR 2b (this)** | proposed | Admin scope-management screen — operator surface. |

## What lands

### BFF (under `apps/portal-bff/src/`)

| File | Change |
| --- | --- |
| `admin/user-scopes.dto.ts` | **New**. `GrantUserScopeDto` (class-validator on kind + value + ISO expiresAt) + response shapes (`UserScopeDto`, `UserScopesPageDto`). |
| `admin/user-scopes.service.ts` | **New**. `resolveUserByOid` (404 when no portal User), `list` (`UserScope` rows joined to User+Person), `grant` (validates value against ADR-0027 tables for value-bearing kinds, rejects non-empty for valueless, P2002 → 400), `revoke` (404-protected — won't leak scope-belongs-to-other-user). |
| `admin/user-scopes.controller.ts` | **New** REST surface at `/api/admin/users/:oid/scopes` with `@RequireAdmin`. `GET` (no audit, read), `POST` (audit `admin.scope_granted`), `DELETE :scopeId` (audit `admin.scope_revoked`, 204 on success). |
| `admin/user-scopes.service.spec.ts` + `admin/user-scopes.controller.spec.ts` | **New** specs — 15 cases across resolve / list / grant (value-bearing + valueless + duplicate) / revoke / audit semantics. |
| `admin/admin.module.ts` | Registers `UserScopesController` + `UserScopesService`. |
| `audit/audit.types.ts` + `audit/audit.service.ts` | New `AdminScopeGrantedInput` / `AdminScopeRevokedInput` types + `adminScopeGranted` / `adminScopeRevoked` methods on `AuditWriter`. `subject = 'user:<oid>'` so an auditor pivots on the target of the change; payload carries the resolved tuple at the moment of the write (the revoke payload survives the row's deletion). |

### SPA (under `apps/portal-admin/src/app/`)

| File | Change |
| --- | --- |
| `app.routes.ts` | New route `/users/:oid/scopes` (lazy-loaded). |
| `pages/user-scopes/user-scopes.service.ts` + `.service.spec.ts` | **New** thin HttpClient wrapper (`list` / `grant` / `revoke`). Same shape convention as `AdminUsersService`. |
| `pages/user-scopes/user-scopes.ts` + `.html` + `.scss` + `.spec.ts` | **New** page component. Signals-based + OnPush. Lists current scopes in a table with `Revoke` per row; "Grant a new scope" form with kind dropdown + conditional value input + optional `expiresAt`. Friendly error mapping (403 → "no admin access", 404 → "User not found, has this persona ever signed in or been seeded?", server messages forwarded). |
| `pages/users/users.html` | New `Manage scopes` link per row (RouterLink to the new page), with `aria-label` carrying the user's displayName. |
| `pages/users/users.ts` + `users.spec.ts` | Adds `RouterLink` import + `provideRouter([])` in the spec fixture (was missing — the route addition exposed it). |

## Key choices

- **`:oid` in the URL, not `User.id`.** The existing `/admin/users` list (ADR-0020) keys on Entra `oid` and the new screen is its child — linking from the list to the scopes page without an intermediate UUID lookup is the simplest UX. The BFF translates `oid → User.id` internally via `resolveUserByOid`.
- **404 carries a hint.** When `resolveUserByOid` misses, the BFF throws `NotFoundException("No portal User found for Entra oid ${oid}.")` and the SPA translates the 403/404 status to friendly French-English text. The hint mentions "has this persona ever signed in or been seeded?" — a common operator confusion (the user-directory list shows oids that signed in but the `User` row only exists post-sign-in OR post-seed).
- **Audit before write commit, write after audit success.** Standard ADR-0013 posture. Order in the controller: service call (which writes the DB row) → audit (blocking). If audit fails, the DB row exists but no audit — a known v1 cost for non-rollback flows. The simpler alternative (audit before write) would emit ghost audit rows for failed writes; we picked the cleaner direction.
- **Value-bearing validation against ADR-0027 tables, not catalogue.** `etablissement:0330800013` must match an existing `Structure.code`; `delegation:33` must match `Delegation.code`; `region:75` must match `Region.code`. The lookup is a single `findUnique` per kind; rejection raises 400 with the offending value in the message. Valueless kinds (`self` / `siege` / `unrestricted`) reject any non-empty value as a defensive check (the DTO type already constrains this but the service runs the assertion).
- **`UserScope.source = 'admin-ui'`** for every row created by this surface. Distinct from `'seed'` (set by `prisma/seed.ts`) and `'self-signin'` (Person source only). ADR-0029's syncs will add `'pleiades'` / `'acteurs-plus'`.
- **A11y per [ADR-0016](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)**: `aria-labelledby` on the form sections, `role="status"`/`aria-live="polite"` for load + error feedback, `role="alert"` for submit errors, `min-height: 44px` on every input/button per the touch-target rule, `aria-label` on the Manage-scopes link carrying the displayName for screen readers, conditional `aria-required` + `aria-describedby` on the value input.
- **`window.confirm` before revoke.** Cheap protection against accidental keyboard-activation. v1 OK; a future polish PR could replace with the spartan-ng dialog when it ships.

## Local verification

- [x] `node scripts/check-catalogue-drift.mjs` — clean (4 / 24 / 7 / 3).
- [x] `pnpm exec nx lint portal-bff` — **0 errors** (13 warnings, all pre-existing).
- [x] `pnpm exec nx lint portal-admin` — **0 errors** (3 warnings, all pre-existing).
- [x] `pnpm exec nx test portal-bff` — full suite passes (now includes the 2 new `user-scopes` spec files).
- [x] `pnpm exec nx test portal-admin` — **71 tests passing** (added 8 for `user-scopes`; updated `users.spec.ts` to provide a router after the RouterLink import).

## Test plan — remaining (manual on dev DB)

- [ ] **Sign in as `admin@apfrd...`** (the only persona with `Portal.Admin`) on `portal-admin`. Navigate to `/admin/users`. Click `Manage scopes` for `directeur-bordeaux`. The page shows one row `etablissement:0330800013` (from the seed).
- [ ] **Grant a new scope**: kind `delegation`, value `33`. Submit. New row appears; audit row `admin.scope_granted` lands in `audit.events`.
- [ ] **Try a bogus value** (kind `etablissement`, value `xyz`): server returns 400, form shows the message.
- [ ] **Try a duplicate** (`etablissement:0330800013` again on `directeur-bordeaux`): server returns 400 with "already granted".
- [ ] **Revoke a scope**: row disappears; audit row `admin.scope_revoked` lands.
- [ ] **Sign in as a non-admin persona** (e.g. `collaborateur-simple`) and try `/admin/users/.../scopes` → 403 + friendly error.
- [ ] **Review focus** — the BFF's `validateValueForKind` switch, the SPA's `translateError` mapping, the a11y attributes on the form, the audit ordering in the controller methods.

## Notes for the reviewer

- **No scope-vocabulary endpoint.** The form expects the operator to type the `Structure.code` / `Delegation.code` / `Region.code` by hand (with a hint string under the field). Defensive validation catches typos. A future polish PR could add `GET /api/admin/scope-vocabulary` returning all three lists at once + replace the value input with a typeahead dropdown.
- **Renamed the service's `UserScopesPage` interface to `UserScopesPayload`.** Discovered the collision with the component class during the first test run. The component keeps the `UserScopesPage` name per Angular convention; the payload type now reads as what it is.
- **`UserScope.source` is not under the drift gate today.** The schema comment in PR 1 said "same catalogue posture as Person.source"; in practice the seed writes `'seed'` and this PR writes `'admin-ui'`. Extending the drift gate to cover `UserScope.source` is a small follow-up — defensible to do once ADR-0029's upstream-sync values are landing.

## What's next

ADR-0025's `@RequireScope` stack is now end-to-end live for the test tenant: persona → Entra → BFF → PrismaScopeResolver → DB → Principal → guard. The trilogy is done.

Per the prior conversation: **GitLab migration Phase 1** (`mirror-and-bootstrap`) is the next item — mostly ops work on `vm-gitlab` (groups, branch protection, Renovate reconfig, deploy keys). The first PR-shaped output from that phase is the Renovate config update.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #236
This commit was merged in pull request #236.
This commit is contained in:
2026-05-26 15:54:21 +02:00
parent 55338b83c0
commit 928ed0cdc2
18 changed files with 1719 additions and 4 deletions
+10 -2
View File
@@ -9,6 +9,8 @@ import { AdminUsersController } from './admin-users.controller';
import { AdminUsersReader } from './admin-users-reader.service';
import { AuditReader } from './audit-reader.service';
import { AuditStatsReader } from './audit-stats.service';
import { UserScopesController } from './user-scopes.controller';
import { UserScopesService } from './user-scopes.service';
/**
* `AdminModule` — root of the `/api/admin/*` surface per ADR-0020.
@@ -34,7 +36,13 @@ import { AuditStatsReader } from './audit-stats.service';
*/
@Module({
imports: [AuthModule, RedisModule],
controllers: [AdminController, AdminAuthController, AdminAuditController, AdminUsersController],
providers: [AdminRoleGuard, AuditReader, AuditStatsReader, AdminUsersReader],
controllers: [
AdminController,
AdminAuthController,
AdminAuditController,
AdminUsersController,
UserScopesController,
],
providers: [AdminRoleGuard, AuditReader, AuditStatsReader, AdminUsersReader, UserScopesService],
})
export class AdminModule {}
@@ -0,0 +1,132 @@
import type { Request } from 'express';
import type { AuditWriter } from '../audit/audit.service';
import { UserScopesController } from './user-scopes.controller';
import type { UserScopesService } from './user-scopes.service';
function makeFixture(): {
controller: UserScopesController;
service: { list: jest.Mock; grant: jest.Mock; revoke: jest.Mock };
audit: { adminScopeGranted: jest.Mock; adminScopeRevoked: jest.Mock };
} {
const service = {
list: jest.fn(),
grant: jest.fn(),
revoke: jest.fn(),
};
const audit = {
adminScopeGranted: jest.fn().mockResolvedValue(undefined),
adminScopeRevoked: jest.fn().mockResolvedValue(undefined),
};
const controller = new UserScopesController(
service as unknown as UserScopesService,
audit as unknown as AuditWriter,
);
return { controller, service, audit };
}
function makeReq(actorOid: string | undefined): Request {
return {
session: actorOid === undefined ? {} : { user: { oid: actorOid } },
} as unknown as Request;
}
describe('UserScopesController.list', () => {
it('returns the service payload verbatim — no audit on reads', async () => {
const { controller, service, audit } = makeFixture();
service.list.mockResolvedValue({
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
scopes: [],
});
const page = await controller.list('target-oid');
expect(service.list).toHaveBeenCalledWith('target-oid');
expect(page.user.oid).toBe('target-oid');
expect(audit.adminScopeGranted).not.toHaveBeenCalled();
expect(audit.adminScopeRevoked).not.toHaveBeenCalled();
});
});
describe('UserScopesController.grant', () => {
it('creates the scope and emits admin.scope_granted blocking audit', async () => {
const { controller, service, audit } = makeFixture();
service.grant.mockResolvedValue({
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
scope: {
id: 'new-scope',
kind: 'etablissement',
value: '0330800013',
source: 'admin-ui',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
},
});
const result = await controller.grant(makeReq('admin-oid'), 'target-oid', {
kind: 'etablissement',
value: '0330800013',
});
expect(service.grant).toHaveBeenCalledWith('target-oid', {
kind: 'etablissement',
value: '0330800013',
});
expect(audit.adminScopeGranted).toHaveBeenCalledWith({
actor: { oid: 'admin-oid' },
target: { oid: 'target-oid' },
scopeId: 'new-scope',
kind: 'etablissement',
value: '0330800013',
expiresAt: null,
});
expect(result.id).toBe('new-scope');
});
it('propagates service errors without writing the audit row', async () => {
const { controller, service, audit } = makeFixture();
service.grant.mockRejectedValue(new Error('boom'));
await expect(
controller.grant(makeReq('admin-oid'), 'target-oid', { kind: 'self' }),
).rejects.toThrow('boom');
expect(audit.adminScopeGranted).not.toHaveBeenCalled();
});
it('skips audit when the session has no actor (defensive — guard guarantees it)', async () => {
const { controller, service, audit } = makeFixture();
service.grant.mockResolvedValue({
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
scope: {
id: 'new-scope',
kind: 'self',
value: '',
source: 'admin-ui',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
},
});
await controller.grant(makeReq(undefined), 'target-oid', { kind: 'self' });
expect(audit.adminScopeGranted).not.toHaveBeenCalled();
});
});
describe('UserScopesController.revoke', () => {
it('deletes and emits admin.scope_revoked', async () => {
const { controller, service, audit } = makeFixture();
service.revoke.mockResolvedValue({
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
revoked: { kind: 'delegation', value: '33' },
});
await controller.revoke(makeReq('admin-oid'), 'target-oid', 'scope-1');
expect(service.revoke).toHaveBeenCalledWith('target-oid', 'scope-1');
expect(audit.adminScopeRevoked).toHaveBeenCalledWith({
actor: { oid: 'admin-oid' },
target: { oid: 'target-oid' },
scopeId: 'scope-1',
kind: 'delegation',
value: '33',
});
});
it('propagates service NotFound without writing the audit row', async () => {
const { controller, service, audit } = makeFixture();
service.revoke.mockRejectedValue(new Error('not found'));
await expect(controller.revoke(makeReq('admin-oid'), 'target-oid', 'ghost')).rejects.toThrow();
expect(audit.adminScopeRevoked).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,93 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Req,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { RequireAdmin } from './require-admin.decorator';
import { GrantUserScopeDto, type UserScopeDto, type UserScopesPageDto } from './user-scopes.dto';
import { UserScopesService } from './user-scopes.service';
/**
* `/api/admin/users/:oid/scopes` per ADR-0026 PR 2b — admin
* scope-management screen for an existing portal User. Each write
* emits a typed audit row (blocking per ADR-0013); reads are NOT
* audited (low signal, high noise).
*
* The `:oid` is the affected user's Entra `oid` — same identifier
* the v1 user-directory list (ADR-0020) uses, so the SPA can link
* directly from the existing `/admin/users` rows.
*/
@ApiTags('admin (user scopes)')
@ApiCookieAuth('portal_admin_session')
@Controller('admin/users/:oid/scopes')
@RequireAdmin()
export class UserScopesController {
constructor(
private readonly userScopes: UserScopesService,
private readonly audit: AuditWriter,
) {}
@ApiOperation({
summary: "List a user's scopes (no audit — read).",
})
@Get()
async list(@Param('oid') oid: string): Promise<UserScopesPageDto> {
return this.userScopes.list(oid);
}
@ApiOperation({
summary: 'Grant a scope to a user — emits `admin.scope_granted` (blocking).',
})
@Post()
async grant(
@Req() req: Request,
@Param('oid') oid: string,
@Body() body: GrantUserScopeDto,
): Promise<UserScopeDto> {
const { user, scope } = await this.userScopes.grant(oid, body);
const actorOid = req.session.user?.oid;
if (actorOid !== undefined) {
await this.audit.adminScopeGranted({
actor: { oid: actorOid },
target: { oid: user.oid },
scopeId: scope.id,
kind: scope.kind,
value: scope.value,
expiresAt: scope.expiresAt,
});
}
return scope;
}
@ApiOperation({
summary: 'Revoke a scope — emits `admin.scope_revoked` (blocking).',
})
@Delete(':scopeId')
@HttpCode(HttpStatus.NO_CONTENT)
async revoke(
@Req() req: Request,
@Param('oid') oid: string,
@Param('scopeId') scopeId: string,
): Promise<void> {
const { user, revoked } = await this.userScopes.revoke(oid, scopeId);
const actorOid = req.session.user?.oid;
if (actorOid !== undefined) {
await this.audit.adminScopeRevoked({
actor: { oid: actorOid },
target: { oid: user.oid },
scopeId,
kind: revoked.kind,
value: revoked.value,
});
}
}
}
@@ -0,0 +1,68 @@
import { IsIn, IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
import { SCOPE_KINDS, type ScopeKind } from 'shared-auth';
/**
* Payload accepted by `POST /api/admin/users/:oid/scopes`. Kind is
* validated against the closed catalogue; value validation against
* `Structure.code` / `Delegation.code` / `Region.code` (ADR-0027 PR 1)
* happens server-side in `UserScopesService.grant`. v1 has no
* partial-update; a re-grant with a different `expiresAt` requires
* delete + add.
*/
export class GrantUserScopeDto {
@IsString()
@IsIn(SCOPE_KINDS as ReadonlyArray<string>)
kind!: ScopeKind;
/**
* Value tied to `kind`. Required and non-empty for value-bearing
* kinds (`etablissement` / `delegation` / `region`); must be empty
* (or omitted — coerced to '' in the service) for valueless kinds
* (`self` / `siege` / `unrestricted`). The shape match is handled
* by `UserScopesService.grant` so the error surface is one place.
*/
@IsString()
@MaxLength(64)
@IsOptional()
value?: string;
/**
* Optional ISO-8601 expiry. When set and past, `PrismaScopeResolver`
* filters the row out at sign-in (`expiresAt IS NULL OR > NOW()`)
* per ADR-0026 PR 2a — supports interim-director-for-two-months
* patterns without a row deletion.
*/
@IsISO8601()
@IsOptional()
expiresAt?: string;
}
/**
* Single row in `GET /api/admin/users/:oid/scopes`. Mirrors
* `UserScope` columns; timestamps as ISO strings so the SPA never
* has to round-trip `Date` instances.
*/
export interface UserScopeDto {
readonly id: string;
readonly kind: string;
readonly value: string;
readonly source: string;
readonly createdAt: string;
readonly expiresAt: string | null;
}
/**
* Response of `GET /api/admin/users/:oid/scopes`. Includes a
* `user` envelope so the SPA can confirm the right target user
* without a second round-trip; `displayName` + `email` are pulled
* from the linked `Person` row.
*/
export interface UserScopesPageDto {
readonly user: {
readonly oid: string;
readonly userId: string;
readonly displayName: string;
readonly email: string | null;
};
readonly scopes: ReadonlyArray<UserScopeDto>;
}
@@ -0,0 +1,311 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import type { Logger } from 'nestjs-pino';
import type { PrismaService } from 'nestjs-prisma';
import { UserScopesService } from './user-scopes.service';
interface PrismaStub {
user: { findUnique: jest.Mock };
userScope: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
delete: jest.Mock;
};
structure: { findUnique: jest.Mock };
delegation: { findUnique: jest.Mock };
region: { findUnique: jest.Mock };
}
function makeLogger() {
return { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger & {
log: jest.Mock;
warn: jest.Mock;
error: jest.Mock;
};
}
function makePrisma(overrides?: Partial<PrismaStub>): PrismaStub {
return {
user: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.user },
userScope: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn(),
delete: jest.fn().mockResolvedValue(undefined),
...overrides?.userScope,
},
structure: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.structure },
delegation: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.delegation },
region: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.region },
};
}
function makeSubject(overrides?: Partial<PrismaStub>): {
service: UserScopesService;
prisma: PrismaStub;
logger: ReturnType<typeof makeLogger>;
} {
const prisma = makePrisma(overrides);
const logger = makeLogger();
const service = new UserScopesService(prisma as unknown as PrismaService, logger);
return { service, prisma, logger };
}
const FOUND_USER = {
id: 'user-uuid-1',
person: { firstName: 'Jane', lastName: 'Doe', email: 'jane@apf.example' },
};
describe('UserScopesService.resolveUserByOid', () => {
it('returns the User + composed displayName when found', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
});
const result = await service.resolveUserByOid('entra-oid');
expect(result).toEqual({
oid: 'entra-oid',
userId: 'user-uuid-1',
displayName: 'Jane Doe',
email: 'jane@apf.example',
});
});
it('throws NotFoundException when the User does not exist', async () => {
const { service } = makeSubject();
await expect(service.resolveUserByOid('ghost-oid')).rejects.toBeInstanceOf(NotFoundException);
});
it('falls back to "(unnamed)" when both firstName and lastName are empty', async () => {
const { service } = makeSubject({
user: {
findUnique: jest.fn().mockResolvedValue({
id: 'u',
person: { firstName: '', lastName: '', email: null },
}),
},
});
const result = await service.resolveUserByOid('o');
expect(result.displayName).toBe('(unnamed)');
expect(result.email).toBeNull();
});
});
describe('UserScopesService.list', () => {
it('returns scopes ordered by kind then value with ISO timestamps', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
userScope: {
findMany: jest.fn().mockResolvedValue([
{
id: 's1',
kind: 'etablissement',
value: '0330800013',
source: 'seed',
createdAt: new Date('2026-05-26T10:00:00.000Z'),
expiresAt: null,
},
]),
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
});
const result = await service.list('entra-oid');
expect(prisma.userScope.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 'user-uuid-1' },
orderBy: [{ kind: 'asc' }, { value: 'asc' }],
}),
);
expect(result.scopes).toEqual([
{
id: 's1',
kind: 'etablissement',
value: '0330800013',
source: 'seed',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
},
]);
});
});
describe('UserScopesService.grant — value-bearing kinds', () => {
it('creates the row when the etablissement value matches an existing Structure', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
structure: { findUnique: jest.fn().mockResolvedValue({ code: '0330800013' }) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
create: jest.fn().mockResolvedValue({
id: 'new-scope',
kind: 'etablissement',
value: '0330800013',
source: 'admin-ui',
createdAt: new Date('2026-05-26T10:00:00.000Z'),
expiresAt: null,
}),
},
});
const out = await service.grant('entra-oid', {
kind: 'etablissement',
value: '0330800013',
});
expect(prisma.structure.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { code: '0330800013' } }),
);
expect(prisma.userScope.create).toHaveBeenCalledWith({
data: {
userId: 'user-uuid-1',
kind: 'etablissement',
value: '0330800013',
source: 'admin-ui',
expiresAt: null,
},
select: expect.any(Object),
});
expect(out.scope.kind).toBe('etablissement');
expect(out.scope.value).toBe('0330800013');
expect(out.scope.source).toBe('admin-ui');
});
it('rejects when the value does not match any Structure row', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
structure: { findUnique: jest.fn().mockResolvedValue(null) },
});
await expect(
service.grant('entra-oid', { kind: 'etablissement', value: 'bogus' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects when the value is empty for a value-bearing kind', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
});
await expect(
service.grant('entra-oid', { kind: 'delegation', value: '' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('validates delegation values against the Delegation table', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
delegation: { findUnique: jest.fn().mockResolvedValue({ code: '33' }) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
create: jest.fn().mockResolvedValue({
id: 's',
kind: 'delegation',
value: '33',
source: 'admin-ui',
createdAt: new Date(),
expiresAt: null,
}),
},
});
await service.grant('entra-oid', { kind: 'delegation', value: '33' });
expect(prisma.delegation.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { code: '33' } }),
);
});
});
describe('UserScopesService.grant — valueless kinds', () => {
it('writes value="" for unrestricted and rejects non-empty values', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
create: jest.fn().mockResolvedValue({
id: 's',
kind: 'unrestricted',
value: '',
source: 'admin-ui',
createdAt: new Date(),
expiresAt: null,
}),
},
});
await service.grant('entra-oid', { kind: 'unrestricted' });
expect(prisma.userScope.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ value: '' }),
}),
);
await expect(
service.grant('entra-oid', { kind: 'unrestricted', value: 'something' }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('UserScopesService.grant — duplicate detection', () => {
it('translates Prisma P2002 into a 400 with a friendly message', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
structure: { findUnique: jest.fn().mockResolvedValue({ code: '0330800013' }) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
create: jest.fn().mockRejectedValue(Object.assign(new Error('unique'), { code: 'P2002' })),
},
});
await expect(
service.grant('entra-oid', { kind: 'etablissement', value: '0330800013' }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('UserScopesService.revoke', () => {
it('deletes the row and returns the resolved tuple for audit', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn().mockResolvedValue({
id: 's1',
userId: 'user-uuid-1',
kind: 'delegation',
value: '33',
}),
create: jest.fn(),
delete: jest.fn().mockResolvedValue(undefined),
},
});
const out = await service.revoke('entra-oid', 's1');
expect(prisma.userScope.delete).toHaveBeenCalledWith({ where: { id: 's1' } });
expect(out.revoked).toEqual({ kind: 'delegation', value: '33' });
});
it('throws NotFoundException when the scope belongs to a different user', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn().mockResolvedValue({
id: 's1',
userId: 'other-user',
kind: 'delegation',
value: '33',
}),
create: jest.fn(),
delete: jest.fn(),
},
});
await expect(service.revoke('entra-oid', 's1')).rejects.toBeInstanceOf(NotFoundException);
});
it('throws NotFoundException when no scope exists for that id', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
});
await expect(service.revoke('entra-oid', 'ghost')).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -0,0 +1,258 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { PrismaService } from 'nestjs-prisma';
import { isScopeKind, type ScopeKind } from 'shared-auth';
import type { UserScopeDto, UserScopesPageDto } from './user-scopes.dto';
/**
* Kinds that carry a value referencing an ADR-0027 row. The service
* resolves each to its matching table at `grant` time.
*/
const VALUE_BEARING_KINDS = new Set<ScopeKind>(['etablissement', 'delegation', 'region']);
export interface ResolvedUser {
readonly oid: string;
readonly userId: string;
readonly displayName: string;
readonly email: string | null;
}
/**
* `UserScopesService` — owns the CRUD surface behind the ADR-0026
* PR 2b admin scope-management screen.
*
* Read side reads `user_scopes` joined to `User` + `Person` (so the
* SPA gets the affected user's display info in the same round-trip).
* Write side validates the scope tuple before insert:
*
* - `kind` is enforced by the DTO's `IsIn(SCOPE_KINDS)` plus the
* type-guard `isScopeKind` defensively.
* - For value-bearing kinds (`etablissement` / `delegation` /
* `region`), `value` must match an existing row in the
* corresponding ADR-0027 table. The lookup is the only DB call
* beyond the insert itself.
* - For valueless kinds (`self` / `siege` / `unrestricted`), the
* service coerces `value` to `''` and rejects any non-empty
* attempt.
*
* `UserScope.source` is hardcoded to `'admin-ui'` per the
* PERSON_SOURCES catalogue — see ADR-0026 PR 1's `person-source.ts`
* for the shared catalogue rules. (`UserScope.source` is not in that
* catalogue at the drift-gate level today; the seed writes 'seed'
* and we write 'admin-ui'. ADR-0029 will add upstream-sync values
* and likely formalise the UserScope.source catalogue then.)
*/
@Injectable()
export class UserScopesService {
constructor(
private readonly prisma: PrismaService,
private readonly logger: Logger,
) {}
/**
* Resolves an Entra `oid` to the new ADR-0026 `User` row + its
* linked `Person`. Throws 404 when no User row exists for the oid
* — caller may need to surface "this persona has never signed in
* and is not in the seed" with a hint.
*/
async resolveUserByOid(oid: string): Promise<ResolvedUser> {
const user = await this.prisma.user.findUnique({
where: { entraOid: oid },
select: {
id: true,
person: { select: { firstName: true, lastName: true, email: true } },
},
});
if (user === null) {
throw new NotFoundException(`No portal User found for Entra oid ${oid}.`);
}
return {
oid,
userId: user.id,
displayName: composeDisplay(user.person.firstName, user.person.lastName),
email: user.person.email,
};
}
async list(oid: string): Promise<UserScopesPageDto> {
const target = await this.resolveUserByOid(oid);
const rows = await this.prisma.userScope.findMany({
where: { userId: target.userId },
orderBy: [{ kind: 'asc' }, { value: 'asc' }],
select: {
id: true,
kind: true,
value: true,
source: true,
createdAt: true,
expiresAt: true,
},
});
return {
user: target,
scopes: rows.map(toDto),
};
}
/**
* Grant a scope. Validates the tuple, then inserts. Returns the
* resolved User (so the controller can include it in the audit
* payload) plus the freshly-created `UserScope` row.
*/
async grant(
oid: string,
input: { kind: string; value?: string; expiresAt?: string },
): Promise<{ user: ResolvedUser; scope: UserScopeDto }> {
const target = await this.resolveUserByOid(oid);
if (!isScopeKind(input.kind)) {
// The DTO already enforces this — defensive belt-and-braces.
throw new BadRequestException(`Unknown scope kind: ${input.kind}.`);
}
const normalisedValue = await this.validateValueForKind(input.kind, input.value ?? '');
const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
try {
const row = await this.prisma.userScope.create({
data: {
userId: target.userId,
kind: input.kind,
value: normalisedValue,
source: 'admin-ui',
expiresAt,
},
select: {
id: true,
kind: true,
value: true,
source: true,
createdAt: true,
expiresAt: true,
},
});
return { user: target, scope: toDto(row) };
} catch (err) {
// Prisma's unique-constraint on (userId, kind, value) raises
// P2002. Surface it as 409-ish via a BadRequest so the admin UI
// can show "already granted" without a stack trace.
if (isPrismaP2002(err)) {
throw new BadRequestException(
`Scope already granted to this user: kind=${input.kind}, value=${normalisedValue || '(empty)'}.`,
);
}
throw err;
}
}
/**
* Revoke a scope by its row id. Returns the resolved user + the
* deleted tuple (kind / value) so the audit row reflects what the
* row WAS — the row itself is gone by the time the controller
* audits.
*/
async revoke(
oid: string,
scopeId: string,
): Promise<{ user: ResolvedUser; revoked: { kind: string; value: string } }> {
const target = await this.resolveUserByOid(oid);
const existing = await this.prisma.userScope.findUnique({
where: { id: scopeId },
select: { id: true, userId: true, kind: true, value: true },
});
if (existing === null || existing.userId !== target.userId) {
// Don't leak "this scope id exists but is for someone else" —
// 404 either way.
throw new NotFoundException(`Scope ${scopeId} not found for user ${oid}.`);
}
await this.prisma.userScope.delete({ where: { id: scopeId } });
this.logger.log(
{
event: 'user_scopes.revoked',
oid,
scopeId,
kind: existing.kind,
value: existing.value,
},
'UserScopesService',
);
return {
user: target,
revoked: { kind: existing.kind, value: existing.value },
};
}
private async validateValueForKind(kind: ScopeKind, rawValue: string): Promise<string> {
if (!VALUE_BEARING_KINDS.has(kind)) {
if (rawValue !== '') {
throw new BadRequestException(`Scope kind '${kind}' is valueless — value must be empty.`);
}
return '';
}
if (rawValue === '') {
throw new BadRequestException(`Scope kind '${kind}' requires a non-empty value.`);
}
// Look up the referenced row. Single existence check per kind;
// ADR-0026 PR 1's "no FK on UserScope.value" rationale lets the
// value persist even after the target is decommissioned, so we
// validate only at the write path.
let exists: boolean;
switch (kind) {
case 'etablissement':
exists =
(await this.prisma.structure.findUnique({
where: { code: rawValue },
select: { code: true },
})) !== null;
break;
case 'delegation':
exists =
(await this.prisma.delegation.findUnique({
where: { code: rawValue },
select: { code: true },
})) !== null;
break;
case 'region':
exists =
(await this.prisma.region.findUnique({
where: { code: rawValue },
select: { code: true },
})) !== null;
break;
}
if (!exists) {
throw new BadRequestException(`Scope value '${rawValue}' does not match any ${kind} row.`);
}
return rawValue;
}
}
function toDto(row: {
id: string;
kind: string;
value: string;
source: string;
createdAt: Date;
expiresAt: Date | null;
}): UserScopeDto {
return {
id: row.id,
kind: row.kind,
value: row.value,
source: row.source,
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt === null ? null : row.expiresAt.toISOString(),
};
}
function composeDisplay(firstName: string, lastName: string): string {
const combined = `${firstName} ${lastName}`.trim();
return combined === '' ? '(unnamed)' : combined;
}
function isPrismaP2002(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
(err as { code: unknown }).code === 'P2002'
);
}
@@ -6,6 +6,8 @@ import type {
AdminAccessDeniedInput,
AdminAuditQueryInput,
AdminAuditStatsQueryInput,
AdminScopeGrantedInput,
AdminScopeRevokedInput,
AdminUsersQueryInput,
AuditEventInput,
AuthorizationDeniedInput,
@@ -220,6 +222,50 @@ export class AuditWriter {
});
}
/**
* Typed event: an admin granted a scope to a user via the ADR-0026
* PR 2b scope-management screen. Blocking per ADR-0013 — a failed
* audit write means the scope write itself is rolled back upstream.
* The `subject` is `user:<oid>` of the affected user, so an auditor
* pivots on the *target* of the change, not the actor.
*/
async adminScopeGranted(input: AdminScopeGrantedInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.scope_granted',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: `user:${input.target.oid}`,
payload: {
scopeId: input.scopeId,
kind: input.kind,
value: input.value,
expiresAt: input.expiresAt,
},
});
}
/**
* Typed event: an admin revoked a scope from a user. Payload
* carries the resolved tuple at the moment of revocation so the
* deletion is reconstructable from the audit log alone — the
* `user_scopes` row itself is gone by then.
*/
async adminScopeRevoked(input: AdminScopeRevokedInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.scope_revoked',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: `user:${input.target.oid}`,
payload: {
scopeId: input.scopeId,
kind: input.kind,
value: input.value,
},
});
}
/**
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
* because the session's `roles` claim does not include `Portal.Admin`.
+32
View File
@@ -133,6 +133,38 @@ export interface AdminUsersQueryInput {
resultCount: number;
}
/**
* Admin granted a scope to a user via the ADR-0026 PR 2b admin
* screen. The `target` field carries the affected user's Entra `oid`
* (the URL-path identifier of the scope-management screen). Stored
* as the audit row's `subject` (`user:<oid>`). Payload captures the
* scope tuple + the resulting row id + the expiry so a reviewer can
* spot operator drift without re-querying.
*/
export interface AdminScopeGrantedInput {
actor: { oid: string };
target: { oid: string };
scopeId: string;
kind: string;
value: string;
expiresAt: string | null;
}
/**
* Admin revoked (deleted) a scope row from a user. Same target
* convention as {@link AdminScopeGrantedInput}; payload carries the
* resolved tuple at the moment of revocation so the deletion is
* reconstructable from the audit log even after the row itself is
* gone.
*/
export interface AdminScopeRevokedInput {
actor: { oid: string };
target: { oid: string };
scopeId: string;
kind: string;
value: string;
}
/**
* Audit aggregations read — `GET /api/admin/audit/stats`. Same
* deterrent posture as {@link AdminAuditQueryInput} (every admin