All five authorization gates audited only their deny branch, so /beheer/audit could answer "who was turned away" but never "who changed this" — for a register whose integrity is the product, the wrong half. Nothing recorded the flag toggle, either org-template write, the admin case or upload delete, the three brief transitions, or the besluit; the comment claiming endpoints log their own effect held for two of the eight. Each gate now computes the decision once, audits it, and then acts. The row is written by the gate rather than the endpoint, so a new admin endpoint cannot be added that forgets to audit itself. Same reasoning for the brief: every transition already funnelled through LogBrief for its log line, so the audit row goes there too — submit/approve/reject/send in one place, with the transition's own outcome as the decision, so a 403 or 409 is as visible as a success. FlagsAdmin gained a per-call resource, the one deviation from BIO-007's minimal remediation: the toggle endpoint writes no log line of its own, so a constant "feature-flags" row would say a flag changed without saying which. It now records feature-flags/<key>=<value>. OrgAdmin and CasesAdmin keep coarse refs because those endpoints do log the specific object. The besluit gets a second row: the gate records that a behandelaar was allowed to act, aanvraag:besluit records what they decided. Row volume goes up — StamdataAdmin gates read endpoints, so admin page loads now write rows. That is what auditing the allow path means; it is also what would make retention on AuthzAuditStore necessary later. Closes CQ-004's outstanding half and unblocks signing ADR-C-009. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
5.0 KiB
C#
112 lines
5.0 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.RegularExpressions;
|
|
using BigRegister.Api.Contracts;
|
|
using BigRegister.Api.Data;
|
|
using BigRegister.Domain.Features;
|
|
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");
|
|
}
|
|
|
|
/// RB-07/BIO-007: the trail used to record only denials, so `/beheer/audit` could answer
|
|
/// "who was turned away" but not "who changed this" — for a register whose integrity is the
|
|
/// product, the wrong half. Every gate now audits the real decision.
|
|
[Fact]
|
|
public async Task An_allowed_admin_action_is_recorded()
|
|
{
|
|
(await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/cases"))).EnsureSuccessStatusCode();
|
|
Assert.Contains(await AuditLog(), e => e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin");
|
|
}
|
|
|
|
/// The flag toggle writes no log line of its own, so the audit row is the only record that
|
|
/// it happened — a bare "feature-flags" resource would not say which flag.
|
|
[Fact]
|
|
public async Task A_feature_flag_toggle_records_which_flag_changed()
|
|
{
|
|
var toggle = Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}");
|
|
toggle.Content = JsonContent.Create(new { enabled = false });
|
|
(await _client.SendAsync(toggle)).EnsureSuccessStatusCode();
|
|
|
|
Assert.Contains(await AuditLog(), e =>
|
|
e.Action == "flags:manage" && e.Decision == "allow" &&
|
|
e.Resource == $"feature-flags/{FeatureFlags.InschrijvingOpen}=False");
|
|
}
|
|
|
|
/// Every brief transition funnels through LogBrief, so all four are covered by the audit
|
|
/// call living there. The allow side is asserted in
|
|
/// <c>BriefEndpointTests.Submit_succeeds_when_required_sections_filled</c>, which already has
|
|
/// the fill-the-sections scaffolding; this is the refused side — a rejected transition must
|
|
/// leave a row rather than being dropped.
|
|
[Fact]
|
|
public async Task A_refused_brief_transition_is_recorded()
|
|
{
|
|
// No brief exists for this subject and nothing is filled in → illegal transition.
|
|
Assert.Equal(HttpStatusCode.Conflict, (await _client.PostAsync("/api/v1/brief/submit", null)).StatusCode);
|
|
Assert.Contains(await AuditLog(), e => e.Action == "brief:submit" && e.Decision == "deny");
|
|
}
|
|
|
|
/// RB-02/BIO-008: the schema test below asserts on **column names**, so a BSN inside a
|
|
/// column called `Resource` was invisible to it — and one was there, concatenated as
|
|
/// `"brief/" + Bsn`. This asserts on the stored **values** instead. Four documents
|
|
/// promise this trail holds no PII; this is the test that makes the promise checkable.
|
|
[Fact]
|
|
public async Task No_audit_row_carries_a_subjects_bsn()
|
|
{
|
|
const string subject = "999999990";
|
|
var reveal = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer");
|
|
reveal.Headers.Add("X-Subject", subject);
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(reveal)).StatusCode);
|
|
|
|
var bsns = new[] { subject, DocumentStore.DemoOwner };
|
|
foreach (var e in await AuditLog())
|
|
foreach (var field in new[] { e.Action, e.Resource, e.Decision, e.Role, e.At, e.CorrelationId })
|
|
Assert.DoesNotContain(bsns, bsn => field.Contains(bsn, StringComparison.Ordinal));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
}
|