feat(beheer): admin audit view at /beheer/audit (finishes WP-42)

The WP-41 GET /admin/audit trail now has an FE view: a beheer audit page (domain
AuditEntry + adapter/parse + store) rendering the data-minimised trail as a read-only
table, capability-gated on cases:manage. Added to ADMIN_LINKS (header nav + dashboard
Beheer section) and to the role.interceptor ROLE_AWARE list so the admin-gated call
carries X-Role. Closes WP-42's audit half.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 16:05:36 +02:00
co-authored by Claude Opus 4.8
parent 0f30143c5d
commit 8cd925717f
11 changed files with 443 additions and 51 deletions
+2 -2
View File
@@ -44,7 +44,7 @@ Gates land before the work they cover; each lint rule lands in the same WP as th
for its existing violations, so every WP ends green.
| WP | Title | Phase | Status |
| ---------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------- | ------- |
| ---------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------- | ------ |
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
@@ -86,7 +86,7 @@ for its existing violations, so every WP ends green.
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done |
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done |
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | partial |
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done |
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine / bff-endpoint / ui-component | 8 · platform/DX/showcase | todo |
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | todo |
| [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo |
@@ -1,6 +1,15 @@
# WP-42 — Privacy & security showcase page
Status: partial — mask/parse showcase done; audit half pending WP-41
Status: done (optional Foundations MDX writeup left as a nice-to-have)
## Audit view (added after WP-41)
`/beheer/audit` — an admin page (`beheer/ui/audit.page.ts`) reading the WP-41 `GET /admin/audit`
trail through a `beheer` adapter/store (domain `AuditEntry` + trust-boundary parse), rendered as a
read-only table (time/action/resource/decision/role/correlation-id), capability-gated on
`cases:manage`. Added to `ADMIN_LINKS` (so it shows in the header nav + dashboard "Beheer" section)
and to the `role.interceptor` ROLE_AWARE list (else it silently 403s). This closes the audit half.
Phase: 8 — platform/DX/showcase
Priority: P2
Depends on: WP-40, WP-41
+7
View File
@@ -84,6 +84,13 @@ export const routes: Routes = [
loadComponent: () =>
import('@registratie/ui/admin-cases.page').then((m) => m.AdminCasesPage),
},
{
path: 'beheer/audit',
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
},
{
path: 'concepts',
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),
+32
View File
@@ -0,0 +1,32 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { AuditEntry } from '@beheer/domain/audit-entry';
import { AuditAdapter, parseAuditEntries } from '@beheer/infrastructure/audit.adapter';
type Err = Error | undefined;
/**
* Admin view of the persisted authz/PII-reveal audit trail (WP-41/42). One root singleton
* owning the list as a RemoteData signal, parsed at the trust boundary. Read-only.
*/
@Injectable({ providedIn: 'root' })
export class AuditStore {
private adapter = inject(AuditAdapter);
private state = signal<RemoteData<Err, AuditEntry[]>>({ tag: 'Loading' });
readonly entries = this.state.asReadonly();
async load() {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try {
const parsed = parseAuditEntries(await this.adapter.list());
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) {
this.state.set({ tag: 'Failure', error: e as Error });
}
}
}
+10
View File
@@ -0,0 +1,10 @@
/** One authz/PII-reveal audit row as the FE sees it (WP-41 backend → WP-42 view). Pure
type; data-minimised (no PII) by construction on the server. */
export interface AuditEntry {
at: string; // ISO timestamp
action: string;
resource: string;
decision: 'allow' | 'deny';
role: string;
correlationId: string;
}
@@ -0,0 +1,45 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { ApiClient } from '@shared/infrastructure/api-client';
import type { AuthzAuditDto } from '@shared/infrastructure/api-client';
import { AuditEntry } from '@beheer/domain/audit-entry';
/**
* Infrastructure adapter for the admin authz/PII-reveal audit trail (`GET /admin/audit`,
* WP-41). The single place the ApiClient lives for audit; the store parses at the boundary.
*/
@Injectable({ providedIn: 'root' })
export class AuditAdapter {
private client = inject(ApiClient);
list(): Promise<AuthzAuditDto[]> {
return this.client.audit();
}
}
/** Trust-boundary parse of the audit rows. */
export function parseAuditEntries(json: unknown): Result<string, AuditEntry[]> {
if (!Array.isArray(json)) return err('audit: not an array');
const out: AuditEntry[] = [];
for (const item of json) {
if (typeof item !== 'object' || item === null) return err('audit: row not an object');
const d = item as AuthzAuditDto;
if (
typeof d.at !== 'string' ||
typeof d.action !== 'string' ||
typeof d.resource !== 'string' ||
typeof d.role !== 'string' ||
typeof d.correlationId !== 'string'
)
return err('audit: missing fields');
out.push({
at: d.at,
action: d.action,
resource: d.resource,
decision: d.decision === 'allow' ? 'allow' : 'deny',
role: d.role,
correlationId: d.correlationId,
});
}
return ok(out);
}
+128
View File
@@ -0,0 +1,128 @@
import { Component, computed, effect, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { AuditStore } from '@beheer/application/audit.store';
/**
* Admin page: the persisted authz/PII-reveal audit trail (WP-41/42) — data-minimised, no PII.
* Deny-by-default capability gate (`cases:manage`, reused for admin audit read). Read-only table.
*/
@Component({
selector: 'app-audit-page',
imports: [PageShellComponent, AlertComponent, ButtonComponent, DatePipe, ...ASYNC],
styles: [
`
.scroll {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--rhc-text-font-size-sm);
}
th,
td {
text-align: left;
padding: var(--rhc-space-max-sm) var(--rhc-space-max-md);
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200);
white-space: nowrap;
}
th {
font-weight: var(--rhc-text-font-weight-semi-bold);
}
.deny {
color: var(--rhc-color-rood-600, #a30000);
font-weight: var(--rhc-text-font-weight-semi-bold);
}
`,
],
template: `
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
@if (!access.ready()) {
<!-- wait for /me before deciding — avoids flashing the denial to an admin -->
} @else if (!canRead()) {
<app-alert type="error">{{ deniedText }}</app-alert>
} @else {
<app-async [data]="store.entries()">
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
</ng-template>
<ng-template appAsyncLoaded>
@if (entries().length === 0) {
<app-alert type="info">{{ emptyText }}</app-alert>
} @else {
<div class="scroll">
<table>
<thead>
<tr>
<th>{{ colTijd }}</th>
<th>{{ colActie }}</th>
<th>{{ colResource }}</th>
<th>{{ colBesluit }}</th>
<th>{{ colRol }}</th>
<th>{{ colCid }}</th>
</tr>
</thead>
<tbody>
@for (e of entries(); track e.at + e.action + e.correlationId) {
<tr>
<td>{{ e.at | date: 'short' }}</td>
<td>{{ e.action }}</td>
<td>{{ e.resource }}</td>
<td [class.deny]="e.decision === 'deny'">{{ e.decision }}</td>
<td>{{ e.role }}</td>
<td>{{ e.correlationId }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</ng-template>
</app-async>
}
</app-page-shell>
`,
})
export class AuditPage {
protected store = inject(AuditStore);
protected access = inject(AccessStore);
protected canRead = computed(() => this.access.can('cases:manage'));
protected entries = computed(() => {
const rd = this.store.entries();
return rd.tag === 'Success' ? rd.value : [];
});
protected heading = $localize`:@@audit.heading:Auditlog`;
protected intro = $localize`:@@audit.intro:Toegangs- en inzagebeslissingen (autorisatie en het tonen van afgeschermde gegevens). Vastgelegd zonder persoonsgegevens.`;
protected deniedText = $localize`:@@audit.denied:U hebt geen rechten om de auditlog te bekijken.`;
protected failedText = $localize`:@@audit.failed:De auditlog kon niet worden geladen.`;
protected emptyText = $localize`:@@audit.empty:Nog geen auditregels.`;
protected retryText = $localize`:@@audit.retry:Opnieuw proberen`;
protected colTijd = $localize`:@@audit.col.tijd:Tijd`;
protected colActie = $localize`:@@audit.col.actie:Actie`;
protected colResource = $localize`:@@audit.col.resource:Resource`;
protected colBesluit = $localize`:@@audit.col.besluit:Besluit`;
protected colRol = $localize`:@@audit.col.rol:Rol`;
protected colCid = $localize`:@@audit.col.cid:Correlatie-id`;
private loadRequested = false;
constructor() {
effect(() => {
if (this.canRead() && !this.loadRequested) {
this.loadRequested = true;
void this.store.load();
}
});
}
protected reload() {
void this.store.load();
}
}
@@ -13,6 +13,7 @@ const ROLE_AWARE = [
'/api/v1/brief',
'/api/v1/admin/org-template',
'/api/v1/admin/cases',
'/api/v1/admin/audit',
'/api/v1/stamdata',
'/api/v1/me',
];
+6
View File
@@ -31,4 +31,10 @@ export const ADMIN_LINKS: readonly AdminLink[] = [
to: '/beheer/zaken',
cap: 'cases:manage',
},
{
label: $localize`:@@header.nav.audit:Auditlog`,
description: $localize`:@@admin.link.audit.desc:Toegangs- en inzagebeslissingen bekijken`,
to: '/beheer/audit',
cap: 'cases:manage',
},
];
+56
View File
@@ -3658,6 +3658,62 @@
<source>Aanvragen</source>
<target datatype="html">Cases</target>
</trans-unit>
<trans-unit id="header.nav.audit" datatype="html">
<source>Auditlog</source>
<target datatype="html">Audit log</target>
</trans-unit>
<trans-unit id="admin.link.audit.desc" datatype="html">
<source>Toegangs- en inzagebeslissingen bekijken</source>
<target datatype="html">View access and disclosure decisions</target>
</trans-unit>
<trans-unit id="audit.heading" datatype="html">
<source>Auditlog</source>
<target datatype="html">Audit log</target>
</trans-unit>
<trans-unit id="audit.intro" datatype="html">
<source>Toegangs- en inzagebeslissingen (autorisatie en het tonen van afgeschermde gegevens). Vastgelegd zonder persoonsgegevens.</source>
<target datatype="html">Access and disclosure decisions (authorization and revealing masked data). Recorded without personal data.</target>
</trans-unit>
<trans-unit id="audit.denied" datatype="html">
<source>U hebt geen rechten om de auditlog te bekijken.</source>
<target datatype="html">You do not have permission to view the audit log.</target>
</trans-unit>
<trans-unit id="audit.failed" datatype="html">
<source>De auditlog kon niet worden geladen.</source>
<target datatype="html">The audit log could not be loaded.</target>
</trans-unit>
<trans-unit id="audit.empty" datatype="html">
<source>Nog geen auditregels.</source>
<target datatype="html">No audit entries yet.</target>
</trans-unit>
<trans-unit id="audit.retry" datatype="html">
<source>Opnieuw proberen</source>
<target datatype="html">Try again</target>
</trans-unit>
<trans-unit id="audit.col.tijd" datatype="html">
<source>Tijd</source>
<target datatype="html">Time</target>
</trans-unit>
<trans-unit id="audit.col.actie" datatype="html">
<source>Actie</source>
<target datatype="html">Action</target>
</trans-unit>
<trans-unit id="audit.col.resource" datatype="html">
<source>Resource</source>
<target datatype="html">Resource</target>
</trans-unit>
<trans-unit id="audit.col.besluit" datatype="html">
<source>Besluit</source>
<target datatype="html">Decision</target>
</trans-unit>
<trans-unit id="audit.col.rol" datatype="html">
<source>Rol</source>
<target datatype="html">Role</target>
</trans-unit>
<trans-unit id="audit.col.cid" datatype="html">
<source>Correlatie-id</source>
<target datatype="html">Correlation id</target>
</trans-unit>
<trans-unit id="dashboard.beheer" datatype="html">
<source>Beheer</source>
<target datatype="html">Administration</target>
+100 -2
View File
@@ -94,6 +94,90 @@
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="audit.heading" datatype="html">
<source>Auditlog</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">102</context>
</context-group>
</trans-unit>
<trans-unit id="audit.intro" datatype="html">
<source>Toegangs- en inzagebeslissingen (autorisatie en het tonen van afgeschermde gegevens). Vastgelegd zonder persoonsgegevens.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">103</context>
</context-group>
</trans-unit>
<trans-unit id="audit.denied" datatype="html">
<source>U hebt geen rechten om de auditlog te bekijken.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">104</context>
</context-group>
</trans-unit>
<trans-unit id="audit.failed" datatype="html">
<source>De auditlog kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">105</context>
</context-group>
</trans-unit>
<trans-unit id="audit.empty" datatype="html">
<source>Nog geen auditregels.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">106</context>
</context-group>
</trans-unit>
<trans-unit id="audit.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">107</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.tijd" datatype="html">
<source>Tijd</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">108</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.actie" datatype="html">
<source>Actie</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">109</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.resource" datatype="html">
<source>Resource</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">110</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.besluit" datatype="html">
<source>Besluit</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">111</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.rol" datatype="html">
<source>Rol</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">112</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.cid" datatype="html">
<source>Correlatie-id</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/audit.page.ts</context>
<context context-type="linenumber">113</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.added" datatype="html">
<source>toegevoegd</source>
<context-group purpose="location">
@@ -2611,14 +2695,14 @@
<source>Voer een geldig BSN van 9 cijfers in.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/kernel/bsn.ts</context>
<context context-type="linenumber">17</context>
<context context-type="linenumber">18</context>
</context-group>
</trans-unit>
<trans-unit id="validation.bsnElfproef" datatype="html">
<source>Dit is geen geldig BSN (klopt niet met de elfproef).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/kernel/bsn.ts</context>
<context context-type="linenumber">21</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.huisstijl" datatype="html">
@@ -2663,6 +2747,20 @@
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.audit" datatype="html">
<source>Auditlog</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/admin-links.ts</context>
<context context-type="linenumber">35</context>
</context-group>
</trans-unit>
<trans-unit id="admin.link.audit.desc" datatype="html">
<source>Toegangs- en inzagebeslissingen bekijken</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/admin-links.ts</context>
<context context-type="linenumber">36</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.dashboard" datatype="html">
<source>Mijn overzicht</source>
<context-group purpose="location">