feat(audit): ScadaBridge IAuditActorAccessor + wire audit Actor from Auth principal at authenticated emit sites (Phase 3)

This commit is contained in:
Joseph Doherty
2026-06-02 15:33:01 -04:00
parent bc0e5bfd37
commit b3de8408fa
9 changed files with 463 additions and 30 deletions
@@ -85,12 +85,26 @@ public class AuditWriteMiddlewareTests
private static AuditWriteMiddleware CreateMiddleware(
RequestDelegate next,
ICentralAuditWriter writer,
AuditLogOptions? options = null) =>
AuditLogOptions? options = null,
IAuditActorAccessor? actorAccessor = null) =>
new(
next,
writer,
NullLogger<AuditWriteMiddleware>.Instance,
new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()));
new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()),
actorAccessor);
/// <summary>
/// File-local <see cref="IAuditActorAccessor"/> test double returning a fixed
/// actor string — stands in for the HTTP-backed accessor that reads the
/// authenticated principal off the ambient request (Phase 3).
/// </summary>
private sealed class StubAuditActorAccessor : IAuditActorAccessor
{
public StubAuditActorAccessor(string? currentActor) => CurrentActor = currentActor;
public string? CurrentActor { get; }
}
/// <summary>
/// File-local <see cref="IOptionsMonitor{TOptions}"/> test double — returns the
@@ -278,6 +292,105 @@ public class AuditWriteMiddlewareTests
Assert.Equal("integration-svc", evt.Actor);
}
// ---------------------------------------------------------------------
// 5b. Phase 3 — Actor from the authenticated principal. When no API-key
// name was stashed, the actor is sourced from IAuditActorAccessor
// (the authenticated interactive cookie/LDAP user). The API-key stash
// still takes precedence, and auth-failure / no-principal paths stay
// null — never echo an unauthenticated principal back.
// ---------------------------------------------------------------------
[Fact]
public async Task AuthenticatedUser_FromAccessor_RecordedAsActor_WhenNoApiKeyStash()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(
_ =>
{
// No API-key name stashed — this is an interactive cookie/LDAP
// authenticated inbound user, surfaced via the accessor.
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
},
writer,
actorAccessor: new StubAuditActorAccessor("alice"));
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Equal("alice", evt.Actor);
}
[Fact]
public async Task ApiKeyStash_TakesPrecedence_OverAuthenticatedPrincipal()
{
// A key-authenticated caller: the endpoint handler stashed the API key
// name. Even if an accessor would resolve a principal, the API-key
// identity is the canonical actor for the key-authenticated path.
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(
_ =>
{
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
},
writer,
actorAccessor: new StubAuditActorAccessor("should-not-win"));
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Equal("integration-svc", evt.Actor);
}
[Fact]
public async Task AuthFailure_KeepsActorNull_EvenWhenAccessorResolvesPrincipal()
{
// 401/403 force the actor null BEFORE resolution — an auth failure must
// never echo a principal back, even one the accessor could produce.
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(
_ =>
{
ctx.Response.StatusCode = 401;
return Task.CompletedTask;
},
writer,
actorAccessor: new StubAuditActorAccessor("attacker"));
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
Assert.Null(evt.Actor);
}
[Fact]
public async Task NoApiKey_NoAuthenticatedPrincipal_LeavesActorNull()
{
// Accessor present but returns null (no authenticated interactive user)
// and no API-key stash — the actor stays null rather than empty/echoed.
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(
_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
},
writer,
actorAccessor: new StubAuditActorAccessor(currentActor: null));
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Null(evt.Actor);
}
// ---------------------------------------------------------------------
// 6. Writer failure must NEVER alter the HTTP response
// ---------------------------------------------------------------------