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
@@ -33,14 +33,18 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
/// </para>
///
/// <para>
/// <b>Actor resolution.</b> Inbound API auth runs inside the endpoint handler
/// <b>Actor resolution.</b> The API-key auth path runs inside the endpoint handler
/// (no <c>UseAuthentication</c>-backed scheme populates <see cref="HttpContext.User"/>
/// for X-API-Key callers), so the handler stashes the resolved API key name on
/// for Bearer API-key callers), so the handler stashes the resolved API key name on
/// <see cref="HttpContext.Items"/> under <see cref="AuditActorItemKey"/> after
/// <c>IApiKeyVerifier.VerifyAsync</c> succeeds. The middleware reads it in
/// its <c>finally</c> block — on auth failures the key remains absent and
/// <see cref="AuditEvent.Actor"/> stays null (we never echo back an
/// unauthenticated principal).
/// its <c>finally</c> block. Phase 3: when no API-key name is stashed, the actor is
/// sourced from the authenticated <em>interactive</em> principal via
/// <see cref="IAuditActorAccessor"/> (a cookie/LDAP-authenticated inbound user,
/// keyed off the canonical username claim). On auth failures (401/403) the actor is
/// forced null before resolution runs, and the accessor itself returns null for an
/// unauthenticated principal — so <see cref="AuditEvent.Actor"/> stays null and we
/// never echo back an unauthenticated principal.
/// </para>
///
/// <para>
@@ -90,6 +94,7 @@ public sealed class AuditWriteMiddleware
private readonly ICentralAuditWriter _auditWriter;
private readonly ILogger<AuditWriteMiddleware> _logger;
private readonly IOptionsMonitor<AuditLogOptions> _options;
private readonly IAuditActorAccessor? _actorAccessor;
/// <summary>
/// Initializes the middleware with its required dependencies.
@@ -98,16 +103,25 @@ public sealed class AuditWriteMiddleware
/// <param name="auditWriter">Central audit writer used to persist inbound API audit events.</param>
/// <param name="logger">Logger for this middleware.</param>
/// <param name="options">Live-reloadable audit log options, read per-request.</param>
/// <param name="actorAccessor">
/// Phase 3 (optional): resolves the audit <see cref="AuditEvent.Actor"/> from the
/// authenticated principal on a cookie/LDAP-authenticated inbound request. Optional
/// so existing tests (and any composition without the accessor registered) still
/// construct the middleware; when absent, actor resolution falls back to the
/// stashed API-key name only.
/// </param>
public AuditWriteMiddleware(
RequestDelegate next,
ICentralAuditWriter auditWriter,
ILogger<AuditWriteMiddleware> logger,
IOptionsMonitor<AuditLogOptions> options)
IOptionsMonitor<AuditLogOptions> options,
IAuditActorAccessor? actorAccessor = null)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? throw new ArgumentNullException(nameof(options));
_actorAccessor = actorAccessor;
}
/// <summary>
@@ -472,13 +486,24 @@ public sealed class AuditWriteMiddleware
}
/// <summary>
/// Reads the API key name the endpoint handler stashed on
/// <see cref="HttpContext.Items"/> after successful auth. Falls back to
/// the authenticated user name when an ASP.NET scheme has populated
/// <see cref="HttpContext.User"/> (defensive — currently unused for inbound
/// API but cheap and forward-compatible).
/// Resolves the audit <see cref="AuditEvent.Actor"/> for a non-auth-failure
/// inbound request:
/// <list type="number">
/// <item><description>the API key name the endpoint handler stashed on
/// <see cref="HttpContext.Items"/> after successful key auth (the
/// key-authenticated path — the canonical identity of an API-key caller);</description></item>
/// <item><description>otherwise the authenticated <em>interactive</em> principal
/// resolved through <see cref="IAuditActorAccessor"/> (Phase 3 — a
/// cookie/LDAP-authenticated inbound user, sourced from the canonical username
/// claim). The accessor reads the ambient <see cref="HttpContext.User"/>, so the
/// fall-through here only fires when no API-key name was stashed;</description></item>
/// <item><description>otherwise <c>null</c> — never echo an unauthenticated
/// principal back as an actor.</description></item>
/// </list>
/// The accessor is optional (constructor default <c>null</c>); when absent only
/// the stashed API-key name is consulted, preserving the pre-Phase-3 behaviour.
/// </summary>
private static string? ResolveActor(HttpContext ctx)
private string? ResolveActor(HttpContext ctx)
{
if (ctx.Items.TryGetValue(AuditActorItemKey, out var stashed)
&& stashed is string name
@@ -487,13 +512,11 @@ public sealed class AuditWriteMiddleware
return name;
}
var user = ctx.User;
if (user?.Identity is { IsAuthenticated: true, Name: { Length: > 0 } userName })
{
return userName;
}
return null;
// Phase 3: an interactive cookie/LDAP-authenticated inbound user records
// their real identity as Actor. Returns null for the key-authenticated
// and auth-failure paths (no authenticated interactive principal), so the
// existing API-key/auth-failure behaviour is preserved.
return _actorAccessor?.CurrentActor;
}
/// <summary>