feat(privacy): WP-41 — persisted, queryable authz/PII-reveal audit

Persist the security-relevant events (authz denials + BIG-nummer reveal/step-up) into a
data-minimised EF table (AuthzAuditEntry: At/Action/Resource/Decision/Role/CorrelationId —
never a name/BSN/value), extending the DocumentStore AuditEntry pattern (migration AuthzAudit).
AuditAuthz now persists via AuthzAuditStore.Record alongside its log line. GET /admin/audit
(admin-gated by the existing CasesAdmin) returns the trail newest-first. +3 backend tests incl.
a schema-carries-no-PII reflection test. Typed client regenerated (audit() + AuthzAuditDto);
no FE consumer yet (a future audit view must add the ROLE_AWARE prefix). Finishes WP-42's audit half.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 15:53:33 +02:00
co-authored by Claude Opus 4.8
parent 8c54ede6eb
commit 0f30143c5d
12 changed files with 590 additions and 5 deletions
@@ -0,0 +1,55 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.RegularExpressions;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
/// WP-41: the persisted authz/PII-reveal audit trail is queryable, data-minimised (no PII).
public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private HttpRequestMessage Admin(HttpMethod method, string path)
{
var req = new HttpRequestMessage(method, path);
req.Headers.Add("X-Role", "admin");
return req;
}
private async Task<List<AuthzAuditDto>> AuditLog()
{
var res = await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/audit"));
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<List<AuthzAuditDto>>())!;
}
[Fact]
public async Task A_denied_admin_action_is_recorded()
{
// No X-Role → drafter → 403 on an admin endpoint → a deny entry.
Assert.Equal(HttpStatusCode.Forbidden, (await _client.GetAsync("/api/v1/admin/cases")).StatusCode);
Assert.Contains(await AuditLog(), e => e.Action == "cases:manage" && e.Decision == "deny");
}
[Fact]
public async Task A_reveal_attempt_is_recorded()
{
// Drafter (capable role) without X-Step-Up → reveal denied → recorded.
var res = await _client.PostAsync("/api/v1/brief/reveal-bignummer", null);
Assert.Equal(HttpStatusCode.Forbidden, res.StatusCode);
Assert.Contains(await AuditLog(), e => e.Action == "brief:reveal-bignummer");
}
[Fact]
public void The_audit_schema_carries_no_pii()
{
var names = typeof(AuthzAuditEntry).GetProperties().Select(p => p.Name).ToArray();
Assert.Equal(
new[] { "At", "Action", "Resource", "Decision", "Role", "CorrelationId", "Id" }.OrderBy(x => x),
names.OrderBy(x => x));
Assert.DoesNotContain(names, n => Regex.IsMatch(n, "naam|name|bsn|value|waarde", RegexOptions.IgnoreCase));
}
}