feat(admin): add /admin/users/:oid/scopes screen + endpoints (ADR-0026 PR 2b) #236

Merged
julien merged 1 commits from feat/adr-0026-pr2b-admin-scopes-screen into main 2026-05-26 15:54:22 +02:00
Owner

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: 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

  • node scripts/check-catalogue-drift.mjs — clean (4 / 24 / 7 / 3).
  • pnpm exec nx lint portal-bff0 errors (13 warnings, all pre-existing).
  • pnpm exec nx lint portal-admin0 errors (3 warnings, all pre-existing).
  • pnpm exec nx test portal-bff — full suite passes (now includes the 2 new user-scopes spec files).
  • pnpm exec nx test portal-admin71 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.

## 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.
julien added 1 commit 2026-05-26 15:52:23 +02:00
feat(admin): add /admin/users/:oid/scopes screen + endpoints (ADR-0026 PR 2b)
CI / scan (pull_request) Successful in 4m41s
CI / commits (pull_request) Successful in 5m20s
CI / check (pull_request) Failing after 5m34s
CI / a11y (pull_request) Successful in 3m6s
CI / perf (pull_request) Successful in 9m11s
283c68d7f0
Backend
-------
New REST surface at /api/admin/users/:oid/scopes guarded by
@RequireAdmin (Portal.Admin privilege per ADR-0020). Three endpoints:

  GET    list a user's scopes with the linked User + Person info
         (no audit on reads)
  POST   grant a new scope - validates kind via SCOPE_KINDS catalogue,
         validates value against Structure.code / Delegation.code /
         Region.code for value-bearing kinds, rejects non-empty values
         for valueless kinds, translates Prisma P2002 to a 400 with
         "already granted"; emits admin.scope_granted (blocking
         audit per ADR-0013)
  DELETE :scopeId  404-protected against cross-user revocation;
         emits admin.scope_revoked with the resolved tuple at the
         moment of deletion

Two new audit types (AdminScopeGrantedInput / AdminScopeRevokedInput)
+ two AuditWriter methods. subject = "user:<oid>" so an auditor
pivots on the target of the change.

UserScope rows created here use source = "admin-ui" (distinct from
seed-written "seed" and Person-only "self-signin").

Frontend
--------
New Angular page at /admin/users/:oid/scopes (lazy-loaded). Lists
current scopes in a table with per-row Revoke buttons; "Grant a new
scope" form with kind dropdown + conditional value input + optional
expiresAt datetime. Friendly error mapping: 403 -> "no admin access",
404 -> "User not found, has this persona ever signed in or been
seeded?", server messages forwarded.

A11y per ADR-0016: aria-labelledby on form sections, role="status"
/ aria-live="polite" for load/error feedback, role="alert" for
submit errors, min-height 44px on every input/button, aria-label
on the Manage-scopes link carrying the user's displayName,
conditional aria-required + aria-describedby on the value input.

"Manage scopes" link added to each row of the existing /admin/users
list, navigating to the new page via the user's Entra oid.

Local: drift gate 4/24/7/3 clean; portal-bff lint 0 err; portal-admin
lint 0 err; both test suites green (portal-admin 71 tests, +8 for
user-scopes).

Closes the ADR-0026 trilogy alongside PRs #232 and #233. ADR-0025's
@RequireScope stack is now end-to-end live for the test tenant.
julien merged commit 928ed0cdc2 into main 2026-05-26 15:54:22 +02:00
julien deleted branch feat/adr-0026-pr2b-admin-scopes-screen 2026-05-26 15:54:24 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#236