feat(admin): add /admin/users/:oid/scopes screen + endpoints (ADR-0026 PR 2b) (#236)
## 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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user