fix(security): audit actor falls back to HttpContext when the server auth-state provider has no circuit

ServerAuthenticationStateProvider throws InvalidOperationException from GetAuthenticationStateAsync in any
scope where no circuit set its state (minimal-API requests, hosted-service scopes). The accessor now treats
that as "no circuit identity" and falls back to the HTTP principal. Pinned with the real provider on both
sides (unset -> HTTP fallback; set -> circuit principal wins).

Refs #733
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 13:36:51 -04:00
parent 3ddb6876db
commit c0a0e7aa81
2 changed files with 62 additions and 6 deletions
@@ -47,17 +47,37 @@ public sealed class AuthenticationStateAuditActorAccessor : IAuditActorAccessor
{
if (_authenticationState is not null)
{
var task = _authenticationState.GetAuthenticationStateAsync();
if (task.IsCompletedSuccessfully)
{
var actor = ResolveActor(task.Result.User);
if (actor is not null) return actor;
}
var actor = TryResolveCircuitActor(_authenticationState);
if (actor is not null) return actor;
}
return ResolveActor(_httpContextAccessor.HttpContext?.User);
}
}
/// <summary>
/// The circuit's actor, or null when this scope has no circuit identity. The server-side provider
/// is registered for EVERY scope on an AdminUI node, but only a circuit calls
/// <c>SetAuthenticationState</c>; anywhere else (a minimal-API request, a hosted service's scope)
/// <c>GetAuthenticationStateAsync</c> throws <see cref="InvalidOperationException"/> synchronously
/// (fix round 1 / I5). That is "no circuit", never an audit failure — the caller falls back to
/// the HTTP context.
/// </summary>
/// <param name="provider">The scoped provider.</param>
/// <returns>The actor, or null.</returns>
private static string? TryResolveCircuitActor(AuthenticationStateProvider provider)
{
Task<AuthenticationState> task;
try
{
task = provider.GetAuthenticationStateAsync();
}
catch (InvalidOperationException)
{
return null;
}
return task.IsCompletedSuccessfully ? ResolveActor(task.Result.User) : null;
}
/// <summary>The claim-resolution rule shared with <see cref="HttpAuditActorAccessor"/>.</summary>
/// <param name="user">The principal, or null.</param>
/// <returns>The actor string, or null when unauthenticated.</returns>