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:
@@ -4,6 +4,7 @@ import { authGuard } from 'feature-auth';
|
|||||||
const homeTitle = $localize`:@@route.home.title:APF Portal Admin`;
|
const homeTitle = $localize`:@@route.home.title:APF Portal Admin`;
|
||||||
const auditTitle = $localize`:@@route.audit.title:Audit log — APF Portal Admin`;
|
const auditTitle = $localize`:@@route.audit.title:Audit log — APF Portal Admin`;
|
||||||
const usersTitle = $localize`:@@route.users.title:Users — APF Portal Admin`;
|
const usersTitle = $localize`:@@route.users.title:Users — APF Portal Admin`;
|
||||||
|
const userScopesTitle = $localize`:@@route.user-scopes.title:User scopes — APF Portal Admin`;
|
||||||
const profileTitle = $localize`:@@route.profile.title:Profile — APF Portal Admin`;
|
const profileTitle = $localize`:@@route.profile.title:Profile — APF Portal Admin`;
|
||||||
|
|
||||||
export const appRoutes: Route[] = [
|
export const appRoutes: Route[] = [
|
||||||
@@ -23,6 +24,11 @@ export const appRoutes: Route[] = [
|
|||||||
loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage),
|
loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage),
|
||||||
title: usersTitle,
|
title: usersTitle,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'users/:oid/scopes',
|
||||||
|
loadComponent: () => import('./pages/user-scopes/user-scopes').then((m) => m.UserScopesPage),
|
||||||
|
title: userScopesTitle,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'profile',
|
path: 'profile',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<section class="user-scopes">
|
||||||
|
<header class="user-scopes-header">
|
||||||
|
<p class="crumbs">
|
||||||
|
<a routerLink="/users">← Back to users</a>
|
||||||
|
</p>
|
||||||
|
<h1 class="title">User scopes</h1>
|
||||||
|
@if (page(); as p) {
|
||||||
|
<p class="intro">
|
||||||
|
Managing scopes for <strong>{{ p.user.displayName }}</strong> ({{ p.user.email ?? '—' }},
|
||||||
|
<code>{{ p.user.oid }}</code>). Every grant emits <code>admin.scope_granted</code> and every
|
||||||
|
revoke emits <code>admin.scope_revoked</code> — scope-management is itself auditable per
|
||||||
|
ADR-0013.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="status-bar" role="status" aria-live="polite">
|
||||||
|
@if (loading()) {
|
||||||
|
<span class="status-line">Loading…</span>
|
||||||
|
} @else if (error(); as msg) {
|
||||||
|
<span class="status-line status-line--error">{{ msg }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (page(); as p) {
|
||||||
|
<section class="scopes-section" aria-labelledby="current-scopes-h">
|
||||||
|
<h2 id="current-scopes-h" class="section-heading">Current scopes</h2>
|
||||||
|
@if (p.scopes.length === 0) {
|
||||||
|
<p class="empty">No scopes granted to this user yet.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="scopes-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Scope</th>
|
||||||
|
<th scope="col">Source</th>
|
||||||
|
<th scope="col">Granted</th>
|
||||||
|
<th scope="col">Expires</th>
|
||||||
|
<th scope="col"><span class="sr-only">Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (scope of p.scopes; track scope.id) {
|
||||||
|
<tr>
|
||||||
|
<td class="cell-scope">
|
||||||
|
<code>{{ formatScope(scope.kind, scope.value) }}</code>
|
||||||
|
</td>
|
||||||
|
<td class="cell-source">{{ scope.source }}</td>
|
||||||
|
<td class="cell-timestamp">{{ formatTimestamp(scope.createdAt) }}</td>
|
||||||
|
<td class="cell-timestamp">{{ formatTimestamp(scope.expiresAt) }}</td>
|
||||||
|
<td class="cell-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn--danger"
|
||||||
|
(click)="revoke(scope.id, formatScope(scope.kind, scope.value))"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grant-section" aria-labelledby="grant-h">
|
||||||
|
<h2 id="grant-h" class="section-heading">Grant a new scope</h2>
|
||||||
|
<form class="grant-form" (submit)="$event.preventDefault(); grant()">
|
||||||
|
<div class="grant-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Kind</span>
|
||||||
|
<select
|
||||||
|
name="kind"
|
||||||
|
[ngModel]="newKind()"
|
||||||
|
(ngModelChange)="newKind.set($event)"
|
||||||
|
[disabled]="submitting()"
|
||||||
|
>
|
||||||
|
@for (kind of scopeKinds; track kind) {
|
||||||
|
<option [value]="kind">{{ kind }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field" [class.field--hidden]="!needsValue()">
|
||||||
|
<span class="field-label">Value</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="value"
|
||||||
|
[ngModel]="newValue()"
|
||||||
|
(ngModelChange)="newValue.set($event)"
|
||||||
|
[disabled]="submitting() || !needsValue()"
|
||||||
|
placeholder="0330800013 / 33 / 75"
|
||||||
|
[attr.aria-required]="needsValue() ? 'true' : 'false'"
|
||||||
|
[attr.aria-describedby]="needsValue() ? 'value-hint' : null"
|
||||||
|
/>
|
||||||
|
@if (needsValue()) {
|
||||||
|
<span id="value-hint" class="field-hint">
|
||||||
|
For etablissement: Structure.code. For delegation: dept code (33, 2A). For region: INSEE
|
||||||
|
region code (75).
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Expires at (optional)</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="expiresAt"
|
||||||
|
[ngModel]="newExpiresAt()"
|
||||||
|
(ngModelChange)="newExpiresAt.set($event)"
|
||||||
|
[disabled]="submitting()"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@if (submitError(); as msg) {
|
||||||
|
<p class="submit-error" role="alert">{{ msg }}</p>
|
||||||
|
}
|
||||||
|
<div class="grant-actions">
|
||||||
|
<button type="submit" class="btn btn--primary" [disabled]="submitting()">
|
||||||
|
@if (submitting()) { Granting… } @else { Grant }
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
.user-scopes {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-scopes-header {
|
||||||
|
.crumbs {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
.intro {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary, #555);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
min-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-line--error {
|
||||||
|
color: var(--color-danger, #b00020);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text-secondary, #555);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scopes-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border, #ddd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-scope code {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-actions {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grant-section {
|
||||||
|
background: var(--color-bg-surface, #fafafa);
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--color-border, #ddd);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grant-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
&.field--hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary, #555);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--color-border, #ccc);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
min-height: 44px; /* WCAG 2.2 AA touch target (2.5.5 / 2.5.8). */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-error {
|
||||||
|
color: var(--color-danger, #b00020);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grant-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--color-brand-accent-500, #de9415);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-danger, #b00020);
|
||||||
|
border-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { AUTH_BFF_BASE_URL } from 'feature-auth';
|
||||||
|
import { UserScopesService, type UserScope, type UserScopesPayload } from './user-scopes.service';
|
||||||
|
|
||||||
|
const BFF_BASE = 'http://bff.test/api';
|
||||||
|
|
||||||
|
const SCOPE: UserScope = {
|
||||||
|
id: 'scope-1',
|
||||||
|
kind: 'etablissement',
|
||||||
|
value: '0330800013',
|
||||||
|
source: 'seed',
|
||||||
|
createdAt: '2026-05-26T10:00:00.000Z',
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE: UserScopesPayload = {
|
||||||
|
user: {
|
||||||
|
oid: 'target-oid',
|
||||||
|
userId: 'user-uuid-1',
|
||||||
|
displayName: 'Jane Doe',
|
||||||
|
email: 'jane@apf.example',
|
||||||
|
},
|
||||||
|
scopes: [SCOPE],
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
service: TestBed.inject(UserScopesService),
|
||||||
|
http: TestBed.inject(HttpTestingController),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UserScopesService.list', () => {
|
||||||
|
it('GETs /admin/users/:oid/scopes and returns the page', async () => {
|
||||||
|
const { service, http } = setup();
|
||||||
|
const promise = firstValueFrom(service.list('target-oid'));
|
||||||
|
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush(PAGE);
|
||||||
|
await expect(promise).resolves.toEqual(PAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('URL-encodes the oid', async () => {
|
||||||
|
const { service, http } = setup();
|
||||||
|
void firstValueFrom(service.list('weird/oid'));
|
||||||
|
const req = http.expectOne(`${BFF_BASE}/admin/users/weird%2Foid/scopes`);
|
||||||
|
req.flush(PAGE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UserScopesService.grant', () => {
|
||||||
|
it('POSTs the input body and returns the created scope', async () => {
|
||||||
|
const { service, http } = setup();
|
||||||
|
const promise = firstValueFrom(
|
||||||
|
service.grant('target-oid', {
|
||||||
|
kind: 'etablissement',
|
||||||
|
value: '0330800013',
|
||||||
|
expiresAt: '2026-12-31T23:59:59.000Z',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`);
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
expect(req.request.body).toEqual({
|
||||||
|
kind: 'etablissement',
|
||||||
|
value: '0330800013',
|
||||||
|
expiresAt: '2026-12-31T23:59:59.000Z',
|
||||||
|
});
|
||||||
|
req.flush(SCOPE);
|
||||||
|
await expect(promise).resolves.toEqual(SCOPE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UserScopesService.revoke', () => {
|
||||||
|
it('DELETEs the scope by id', async () => {
|
||||||
|
const { service, http } = setup();
|
||||||
|
const promise = firstValueFrom(service.revoke('target-oid', 'scope-1'));
|
||||||
|
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes/scope-1`);
|
||||||
|
expect(req.request.method).toBe('DELETE');
|
||||||
|
req.flush(null);
|
||||||
|
await expect(promise).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { AUTH_BFF_BASE_URL } from 'feature-auth';
|
||||||
|
import type { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One scope row as the SPA sees it — mirror of `UserScopeDto` on
|
||||||
|
* the BFF side. ISO-string timestamps; the SPA never round-trips
|
||||||
|
* `Date` instances per the audit-page convention.
|
||||||
|
*/
|
||||||
|
export interface UserScope {
|
||||||
|
readonly id: string;
|
||||||
|
readonly kind: string;
|
||||||
|
readonly value: string;
|
||||||
|
readonly source: string;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserScopesPayload {
|
||||||
|
readonly user: {
|
||||||
|
readonly oid: string;
|
||||||
|
readonly userId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly email: string | null;
|
||||||
|
};
|
||||||
|
readonly scopes: ReadonlyArray<UserScope>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrantUserScopeInput {
|
||||||
|
readonly kind: string;
|
||||||
|
readonly value?: string;
|
||||||
|
readonly expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `UserScopesService` — Thin HttpClient wrapper around
|
||||||
|
* `/api/admin/users/:oid/scopes`. Same shape convention as
|
||||||
|
* `AdminUsersService` — `providedIn: 'root'` because the single
|
||||||
|
* consumer is the `UserScopesPayload`.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class UserScopesService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
|
||||||
|
|
||||||
|
list(oid: string): Observable<UserScopesPayload> {
|
||||||
|
return this.http.get<UserScopesPayload>(
|
||||||
|
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
grant(oid: string, input: GrantUserScopeInput): Observable<UserScope> {
|
||||||
|
return this.http.post<UserScope>(
|
||||||
|
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
revoke(oid: string, scopeId: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(
|
||||||
|
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes/${encodeURIComponent(scopeId)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { AUTH_BFF_BASE_URL } from 'feature-auth';
|
||||||
|
import {
|
||||||
|
UserScopesService,
|
||||||
|
type GrantUserScopeInput,
|
||||||
|
type UserScope,
|
||||||
|
type UserScopesPayload,
|
||||||
|
} from './user-scopes.service';
|
||||||
|
import { UserScopesPage } from './user-scopes';
|
||||||
|
|
||||||
|
const SCOPE: UserScope = {
|
||||||
|
id: 'scope-1',
|
||||||
|
kind: 'etablissement',
|
||||||
|
value: '0330800013',
|
||||||
|
source: 'seed',
|
||||||
|
createdAt: '2026-05-26T10:00:00.000Z',
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE: UserScopesPayload = {
|
||||||
|
user: {
|
||||||
|
oid: 'target-oid',
|
||||||
|
userId: 'user-uuid-1',
|
||||||
|
displayName: 'Jane Doe',
|
||||||
|
email: 'jane@apf.example',
|
||||||
|
},
|
||||||
|
scopes: [SCOPE],
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(opts?: {
|
||||||
|
list?: ReturnType<typeof vi.fn>;
|
||||||
|
grant?: ReturnType<typeof vi.fn>;
|
||||||
|
revoke?: ReturnType<typeof vi.fn>;
|
||||||
|
}) {
|
||||||
|
const list = opts?.list ?? vi.fn().mockReturnValue(of(PAGE));
|
||||||
|
const grant = opts?.grant ?? vi.fn().mockReturnValue(of(SCOPE));
|
||||||
|
const revoke = opts?.revoke ?? vi.fn().mockReturnValue(of(undefined));
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [UserScopesPage],
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: AUTH_BFF_BASE_URL, useValue: 'http://bff.test/api' },
|
||||||
|
{
|
||||||
|
provide: UserScopesService,
|
||||||
|
useValue: { list, grant, revoke },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: { snapshot: { paramMap: new Map([['oid', 'target-oid']]) } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fixture = TestBed.createComponent(UserScopesPage);
|
||||||
|
return { fixture, list, grant, revoke };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UserScopesPage', () => {
|
||||||
|
it('loads the scopes for the route param oid on init', async () => {
|
||||||
|
const { fixture, list } = setup();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(list).toHaveBeenCalledWith('target-oid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a 404 with the seed/sign-in hint', async () => {
|
||||||
|
const list = vi.fn().mockReturnValue(throwError(() => ({ status: 404 })));
|
||||||
|
const { fixture } = setup({ list });
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const html = (fixture.nativeElement as HTMLElement).innerHTML;
|
||||||
|
expect(html).toMatch(/User not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids a 403 with a friendly error', async () => {
|
||||||
|
const list = vi.fn().mockReturnValue(throwError(() => ({ status: 403 })));
|
||||||
|
const { fixture } = setup({ list });
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const html = (fixture.nativeElement as HTMLElement).innerHTML;
|
||||||
|
expect(html).toMatch(/admin access/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UserScopesPage.grant', () => {
|
||||||
|
it('sends value for value-bearing kinds + refreshes the list on success', async () => {
|
||||||
|
let grantPayload: GrantUserScopeInput | null = null;
|
||||||
|
const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => {
|
||||||
|
grantPayload = payload;
|
||||||
|
return of(SCOPE);
|
||||||
|
});
|
||||||
|
const list = vi.fn().mockReturnValue(of(PAGE));
|
||||||
|
const { fixture } = setup({ list, grant });
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
const component = fixture.componentInstance as unknown as {
|
||||||
|
newKind: { set: (v: string) => void };
|
||||||
|
newValue: { set: (v: string) => void };
|
||||||
|
grant: () => Promise<void>;
|
||||||
|
};
|
||||||
|
component.newKind.set('etablissement');
|
||||||
|
component.newValue.set('0330800013');
|
||||||
|
await component.grant();
|
||||||
|
expect(grant).toHaveBeenCalledTimes(1);
|
||||||
|
expect(grantPayload).toEqual({ kind: 'etablissement', value: '0330800013' });
|
||||||
|
// List re-fetched after the grant.
|
||||||
|
expect(list).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits value for valueless kinds', async () => {
|
||||||
|
let grantPayload: GrantUserScopeInput | null = null;
|
||||||
|
const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => {
|
||||||
|
grantPayload = payload;
|
||||||
|
return of(SCOPE);
|
||||||
|
});
|
||||||
|
const { fixture } = setup({ grant });
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
const component = fixture.componentInstance as unknown as {
|
||||||
|
newKind: { set: (v: string) => void };
|
||||||
|
grant: () => Promise<void>;
|
||||||
|
};
|
||||||
|
component.newKind.set('unrestricted');
|
||||||
|
await component.grant();
|
||||||
|
expect(grantPayload).toEqual({ kind: 'unrestricted' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
type OnInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import {
|
||||||
|
UserScopesService,
|
||||||
|
type GrantUserScopeInput,
|
||||||
|
type UserScopesPayload,
|
||||||
|
} from './user-scopes.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The six scope kinds from ADR-0025's catalogue. Hardcoded here so
|
||||||
|
* the SPA can dropdown them without importing from `shared-auth`
|
||||||
|
* (which would couple the admin app to the BFF lib path). Drift gate
|
||||||
|
* lives BFF-side; if the catalogue grows, this list grows in lockstep.
|
||||||
|
*/
|
||||||
|
const SCOPE_KINDS = [
|
||||||
|
'self',
|
||||||
|
'etablissement',
|
||||||
|
'delegation',
|
||||||
|
'region',
|
||||||
|
'siege',
|
||||||
|
'unrestricted',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const VALUE_BEARING = new Set<string>(['etablissement', 'delegation', 'region']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin scope-management screen per [ADR-0026 PR 2b](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0026-person-user-portal-data-model.md).
|
||||||
|
* Lists every UserScope row for the target user, lets the admin grant
|
||||||
|
* a new one (kind dropdown + value + optional expiresAt), and revoke
|
||||||
|
* existing ones inline.
|
||||||
|
*
|
||||||
|
* Reaches the BFF at `/api/admin/users/:oid/scopes`. The `:oid` URL
|
||||||
|
* param is the affected user's Entra `oid` — same identifier the
|
||||||
|
* `/admin/users` list page uses, so a "Manage scopes" link from that
|
||||||
|
* list lands here without a lookup.
|
||||||
|
*
|
||||||
|
* A11y posture (per [ADR-0016](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)):
|
||||||
|
* - Form labels associated via `<label>` wrapping or `for`.
|
||||||
|
* - Live region for status/error messages (`role="status"`).
|
||||||
|
* - Touch targets respect 44×44 min (button padding in the SCSS).
|
||||||
|
* - Revoke buttons confirm via `window.confirm` to avoid accidental
|
||||||
|
* destructive action on a row's keyboard activation.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-user-scopes-page',
|
||||||
|
imports: [FormsModule, RouterLink],
|
||||||
|
templateUrl: './user-scopes.html',
|
||||||
|
styleUrl: './user-scopes.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class UserScopesPage implements OnInit {
|
||||||
|
private readonly service = inject(UserScopesService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
protected readonly scopeKinds = SCOPE_KINDS;
|
||||||
|
protected readonly oid = signal('');
|
||||||
|
protected readonly page = signal<UserScopesPayload | null>(null);
|
||||||
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly error = signal<string | null>(null);
|
||||||
|
|
||||||
|
// Form state for the "grant new scope" row.
|
||||||
|
protected readonly newKind = signal<string>('self');
|
||||||
|
protected readonly newValue = signal('');
|
||||||
|
protected readonly newExpiresAt = signal('');
|
||||||
|
protected readonly submitting = signal(false);
|
||||||
|
protected readonly submitError = signal<string | null>(null);
|
||||||
|
|
||||||
|
protected readonly needsValue = computed(() => VALUE_BEARING.has(this.newKind()));
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const oid = this.route.snapshot.paramMap.get('oid') ?? '';
|
||||||
|
this.oid.set(oid);
|
||||||
|
void this.fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async fetch(): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
try {
|
||||||
|
const page = await firstValueFrom(this.service.list(this.oid()));
|
||||||
|
this.page.set(page);
|
||||||
|
} catch (err) {
|
||||||
|
this.error.set(this.translateError(err, 'Could not load scopes for this user.'));
|
||||||
|
this.page.set(null);
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async grant(): Promise<void> {
|
||||||
|
if (this.submitting()) return;
|
||||||
|
this.submitting.set(true);
|
||||||
|
this.submitError.set(null);
|
||||||
|
const payload: GrantUserScopeInput = {
|
||||||
|
kind: this.newKind(),
|
||||||
|
...(this.needsValue() ? { value: this.newValue().trim() } : {}),
|
||||||
|
...(this.newExpiresAt() ? { expiresAt: toIso(this.newExpiresAt()) } : {}),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await firstValueFrom(this.service.grant(this.oid(), payload));
|
||||||
|
// Reset the form (kind reverts to 'self' as the "least
|
||||||
|
// privileged" default), then refresh the list to show the new
|
||||||
|
// row.
|
||||||
|
this.newKind.set('self');
|
||||||
|
this.newValue.set('');
|
||||||
|
this.newExpiresAt.set('');
|
||||||
|
await this.fetch();
|
||||||
|
} catch (err) {
|
||||||
|
this.submitError.set(this.translateError(err, 'Could not grant the scope.'));
|
||||||
|
} finally {
|
||||||
|
this.submitting.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async revoke(scopeId: string, label: string): Promise<void> {
|
||||||
|
const ok = window.confirm(`Revoke scope "${label}"?`);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await firstValueFrom(this.service.revoke(this.oid(), scopeId));
|
||||||
|
await this.fetch();
|
||||||
|
} catch (err) {
|
||||||
|
this.error.set(this.translateError(err, 'Could not revoke the scope.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected formatScope(kind: string, value: string): string {
|
||||||
|
return value === '' ? kind : `${kind}:${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected formatTimestamp(iso: string | null): string {
|
||||||
|
if (iso === null) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private translateError(err: unknown, fallback: string): string {
|
||||||
|
if (
|
||||||
|
typeof err === 'object' &&
|
||||||
|
err !== null &&
|
||||||
|
'error' in err &&
|
||||||
|
typeof (err as { error?: unknown }).error === 'object' &&
|
||||||
|
(err as { error: { message?: unknown } }).error.message !== undefined
|
||||||
|
) {
|
||||||
|
const msg = (err as { error: { message?: unknown } }).error.message;
|
||||||
|
if (typeof msg === 'string') return msg;
|
||||||
|
if (Array.isArray(msg) && msg.every((m) => typeof m === 'string')) {
|
||||||
|
return msg.join(' • ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const status = errorStatus(err);
|
||||||
|
if (status === 403) return 'You do not have admin access on this session.';
|
||||||
|
if (status === 404) return 'User not found — has this persona ever signed in or been seeded?';
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(localValue: string): string {
|
||||||
|
const d = new Date(localValue);
|
||||||
|
return Number.isNaN(d.getTime()) ? localValue : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorStatus(err: unknown): number | null {
|
||||||
|
if (typeof err === 'object' && err !== null && 'status' in err) {
|
||||||
|
const s = (err as { status: unknown }).status;
|
||||||
|
return typeof s === 'number' ? s : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -107,6 +107,7 @@
|
|||||||
<th scope="col">First seen</th>
|
<th scope="col">First seen</th>
|
||||||
<th scope="col">Last seen</th>
|
<th scope="col">Last seen</th>
|
||||||
<th scope="col">OID</th>
|
<th scope="col">OID</th>
|
||||||
|
<th scope="col"><span class="sr-only">Actions</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -120,6 +121,15 @@
|
|||||||
<td class="cell-timestamp">{{ formatTimestamp(user.firstSeenAt) }}</td>
|
<td class="cell-timestamp">{{ formatTimestamp(user.firstSeenAt) }}</td>
|
||||||
<td class="cell-timestamp">{{ formatTimestamp(user.lastSeenAt) }}</td>
|
<td class="cell-timestamp">{{ formatTimestamp(user.lastSeenAt) }}</td>
|
||||||
<td class="cell-oid">{{ user.oid }}</td>
|
<td class="cell-oid">{{ user.oid }}</td>
|
||||||
|
<td class="cell-actions">
|
||||||
|
<a
|
||||||
|
class="btn btn--secondary"
|
||||||
|
[routerLink]="['/users', user.oid, 'scopes']"
|
||||||
|
[attr.aria-label]="'Manage scopes for ' + user.displayName"
|
||||||
|
>
|
||||||
|
Manage scopes
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
import { of, throwError } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
AdminUsersService,
|
AdminUsersService,
|
||||||
@@ -48,7 +49,7 @@ function setup(opts?: { initial?: AdminUsersPage | 'error'; status?: number }):
|
|||||||
});
|
});
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [UsersPage],
|
imports: [UsersPage],
|
||||||
providers: [{ provide: AdminUsersService, useValue: { query } }],
|
providers: [{ provide: AdminUsersService, useValue: { query } }, provideRouter([])],
|
||||||
});
|
});
|
||||||
const fixture = TestBed.createComponent(UsersPage);
|
const fixture = TestBed.createComponent(UsersPage);
|
||||||
return { fixture, query };
|
return { fixture, query };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
AdminUsersService,
|
AdminUsersService,
|
||||||
@@ -26,7 +27,7 @@ const PAGE_SIZES = [25, 50, 100, 200] as const;
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-users-page',
|
selector: 'app-users-page',
|
||||||
imports: [FormsModule],
|
imports: [FormsModule, RouterLink],
|
||||||
templateUrl: './users.html',
|
templateUrl: './users.html',
|
||||||
styleUrl: './users.scss',
|
styleUrl: './users.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { AdminUsersController } from './admin-users.controller';
|
|||||||
import { AdminUsersReader } from './admin-users-reader.service';
|
import { AdminUsersReader } from './admin-users-reader.service';
|
||||||
import { AuditReader } from './audit-reader.service';
|
import { AuditReader } from './audit-reader.service';
|
||||||
import { AuditStatsReader } from './audit-stats.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.
|
* `AdminModule` — root of the `/api/admin/*` surface per ADR-0020.
|
||||||
@@ -34,7 +36,13 @@ import { AuditStatsReader } from './audit-stats.service';
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, RedisModule],
|
imports: [AuthModule, RedisModule],
|
||||||
controllers: [AdminController, AdminAuthController, AdminAuditController, AdminUsersController],
|
controllers: [
|
||||||
providers: [AdminRoleGuard, AuditReader, AuditStatsReader, AdminUsersReader],
|
AdminController,
|
||||||
|
AdminAuthController,
|
||||||
|
AdminAuditController,
|
||||||
|
AdminUsersController,
|
||||||
|
UserScopesController,
|
||||||
|
],
|
||||||
|
providers: [AdminRoleGuard, AuditReader, AuditStatsReader, AdminUsersReader, UserScopesService],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
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,
|
AdminAccessDeniedInput,
|
||||||
AdminAuditQueryInput,
|
AdminAuditQueryInput,
|
||||||
AdminAuditStatsQueryInput,
|
AdminAuditStatsQueryInput,
|
||||||
|
AdminScopeGrantedInput,
|
||||||
|
AdminScopeRevokedInput,
|
||||||
AdminUsersQueryInput,
|
AdminUsersQueryInput,
|
||||||
AuditEventInput,
|
AuditEventInput,
|
||||||
AuthorizationDeniedInput,
|
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`
|
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
|
||||||
* because the session's `roles` claim does not include `Portal.Admin`.
|
* because the session's `roles` claim does not include `Portal.Admin`.
|
||||||
|
|||||||
@@ -133,6 +133,38 @@ export interface AdminUsersQueryInput {
|
|||||||
resultCount: number;
|
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
|
* Audit aggregations read — `GET /api/admin/audit/stats`. Same
|
||||||
* deterrent posture as {@link AdminAuditQueryInput} (every admin
|
* deterrent posture as {@link AdminAuditQueryInput} (every admin
|
||||||
|
|||||||
Reference in New Issue
Block a user