feat(site-call-audit): relay Retry/Discard emit operator-identity audit rows

Additive RequestedBy on Retry/DiscardSiteCallRequest, plumbed from the Central UI.
On an Applied relay, SiteCallAuditActor emits one best-effort CachedResolve central
direct-write row (Submitted/Discarded) with the operator as Actor. Gated on an
injected ICentralAuditWriter (null in existing tests → no mirror read, unchanged);
the site remains the source of truth for the state change.
This commit is contained in:
Joseph Doherty
2026-07-09 08:21:41 -04:00
parent 50bb1ef8ab
commit 2c45c3238b
5 changed files with 319 additions and 15 deletions
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
@@ -48,6 +49,7 @@ public partial class SiteCallsReport
[Inject] private NavigationManager Navigation { get; set; } = null!;
[Inject] private SiteScopeService SiteScope { get; set; } = null!;
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = null!;
// The Status filter <select> options — the exact strings the dropdown binds and
// the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
@@ -296,8 +298,9 @@ public partial class SiteCallsReport
_actionInProgress = true;
try
{
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
var response = await CommunicationService.RetrySiteCallAsync(
new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite));
new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite, requestedBy));
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
appliedMessage: $"Retry of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
if (response.Success)
@@ -330,8 +333,9 @@ public partial class SiteCallsReport
_actionInProgress = true;
try
{
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
var response = await CommunicationService.DiscardSiteCallAsync(
new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite));
new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite, requestedBy));
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
appliedMessage: $"Discard of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
if (response.Success)
@@ -58,10 +58,16 @@ public enum SiteCallRelayOutcome
/// <param name="SourceSite">
/// The owning site (<c>SiteCall.SourceSite</c>) the relay is routed to.
/// </param>
/// <param name="RequestedBy">
/// The authenticated operator who initiated the retry, stamped as the <c>Actor</c>
/// on the central direct-write audit row the relay emits — recording *who asked*
/// (Task 14). Additive; null where no identity is available.
/// </param>
public sealed record RetrySiteCallRequest(
string CorrelationId,
Guid TrackedOperationId,
string SourceSite);
string SourceSite,
string? RequestedBy = null);
/// <summary>
/// Site Call Audit → Central UI: result of a <see cref="RetrySiteCallRequest"/>.
@@ -99,7 +105,8 @@ public sealed record RetrySiteCallResponse(
public sealed record DiscardSiteCallRequest(
string CorrelationId,
Guid TrackedOperationId,
string SourceSite);
string SourceSite,
string? RequestedBy = null);
/// <summary>
/// Site Call Audit → Central UI: result of a <see cref="DiscardSiteCallRequest"/>.
@@ -4,10 +4,12 @@ using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit;
@@ -90,6 +92,17 @@ public class SiteCallAuditActor : ReceiveActor
private readonly SiteCallAuditOptions _options;
private readonly ILogger<SiteCallAuditActor> _logger;
/// <summary>
/// Central direct-write audit sink for operator Retry/Discard relay actions
/// (Task 14). Null when no writer is registered (minimal hosts / most unit
/// tests) — in that case the relay emits no audit row and never reads the
/// <c>SiteCalls</c> mirror, preserving the "central touches no mirror row on
/// the relay path" behaviour. When present, a successful relay emits ONE
/// best-effort <c>CachedResolve</c> row recording the operator identity; the
/// site remains the source of truth for the state change itself.
/// </summary>
private readonly ICentralAuditWriter? _auditWriter;
/// <summary>
/// Reconciliation collaborators (Piece A). The per-site self-heal pull
/// (<see cref="IPullSiteCallsClient"/>) and the site list
@@ -166,7 +179,8 @@ public class SiteCallAuditActor : ReceiveActor
public SiteCallAuditActor(
ISiteCallAuditRepository repository,
ILogger<SiteCallAuditActor> logger,
SiteCallAuditOptions? options = null)
SiteCallAuditOptions? options = null,
ICentralAuditWriter? auditWriter = null)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
@@ -174,6 +188,7 @@ public class SiteCallAuditActor : ReceiveActor
_injectedRepository = repository;
_logger = logger;
_options = options ?? new SiteCallAuditOptions();
_auditWriter = auditWriter;
// Repo-only MSSQL test ctor: keep BOTH background timers off so the
// read/upsert tests see no scheduled side effects.
@@ -253,6 +268,12 @@ public class SiteCallAuditActor : ReceiveActor
_options = options;
_logger = logger;
// Central direct-write audit sink for operator relay actions (Task 14).
// Resolved once from the root provider — ICentralAuditWriter is a central
// singleton (registered by AuditLog). Null-safe: a host that omits it just
// relays without emitting the operator-identity row.
_auditWriter = serviceProvider.GetService<ICentralAuditWriter>();
// Reconciliation collaborators (Piece A) are central-only singletons
// registered by AddAuditLogCentralReconciliationClient — always on the
// central composition root (Program.cs). Resolve them once here (the
@@ -1026,11 +1047,37 @@ public class SiteCallAuditActor : ReceiveActor
request.CorrelationId, new TrackedOperationId(request.TrackedOperationId));
var envelope = new SiteEnvelope(request.SourceSite, relay);
_centralCommunication.Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
.PipeTo(
sender,
success: ack => MapRetryResponse(request.CorrelationId, ack),
failure: ex => MapRetryFailure(request.CorrelationId, request.SourceSite, ex));
RelayRetryAsync(request, envelope).PipeTo(sender);
}
/// <summary>
/// Awaits the site's ack for a Retry relay, maps it onto the response, and —
/// on an <see cref="SiteCallRelayOutcome.Applied"/> outcome with an audit sink
/// present — emits ONE best-effort operator-identity audit row (Task 14). The
/// relay outcome is authoritative; the audit write never changes it.
/// </summary>
private async Task<RetrySiteCallResponse> RelayRetryAsync(RetrySiteCallRequest request, SiteEnvelope envelope)
{
ParkedOperationActionAck ack;
try
{
ack = await _centralCommunication!
.Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
.ConfigureAwait(false);
}
catch (Exception ex)
{
return MapRetryFailure(request.CorrelationId, request.SourceSite, ex);
}
var response = MapRetryResponse(request.CorrelationId, ack);
if (response.Outcome == SiteCallRelayOutcome.Applied)
{
await EmitRelayAuditAsync(
request.TrackedOperationId, request.RequestedBy, AuditStatus.Submitted).ConfigureAwait(false);
}
return response;
}
/// <summary>
@@ -1056,11 +1103,91 @@ public class SiteCallAuditActor : ReceiveActor
request.CorrelationId, new TrackedOperationId(request.TrackedOperationId));
var envelope = new SiteEnvelope(request.SourceSite, relay);
_centralCommunication.Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
.PipeTo(
sender,
success: ack => MapDiscardResponse(request.CorrelationId, ack),
failure: ex => MapDiscardFailure(request.CorrelationId, request.SourceSite, ex));
RelayDiscardAsync(request, envelope).PipeTo(sender);
}
/// <summary>
/// Awaits the site's ack for a Discard relay, maps it onto the response, and —
/// on an <see cref="SiteCallRelayOutcome.Applied"/> outcome with an audit sink
/// present — emits ONE best-effort operator-identity audit row (Task 14).
/// </summary>
private async Task<DiscardSiteCallResponse> RelayDiscardAsync(DiscardSiteCallRequest request, SiteEnvelope envelope)
{
ParkedOperationActionAck ack;
try
{
ack = await _centralCommunication!
.Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
.ConfigureAwait(false);
}
catch (Exception ex)
{
return MapDiscardFailure(request.CorrelationId, request.SourceSite, ex);
}
var response = MapDiscardResponse(request.CorrelationId, ack);
if (response.Outcome == SiteCallRelayOutcome.Applied)
{
await EmitRelayAuditAsync(
request.TrackedOperationId, request.RequestedBy, AuditStatus.Discarded).ConfigureAwait(false);
}
return response;
}
/// <summary>
/// Emits one best-effort central direct-write <see cref="AuditKind.CachedResolve"/>
/// row attributing an operator Retry/Discard relay to <paramref name="actor"/>. The
/// audited <see cref="AuditChannel"/>, target, source site and node are read from the
/// stored <c>SiteCalls</c> mirror row (a benign READ — central still never mutates the
/// mirror). No-op when no <see cref="ICentralAuditWriter"/> is registered. A missing
/// row or a writer fault is swallowed and logged — the relay outcome is authoritative.
/// </summary>
private async Task EmitRelayAuditAsync(Guid trackedOperationId, string? actor, AuditStatus status)
{
if (_auditWriter is null)
{
return;
}
var (scope, repository) = ResolveRepository();
try
{
var row = await repository
.GetAsync(new TrackedOperationId(trackedOperationId))
.ConfigureAwait(false);
// SiteCall.Channel is the string form of an AuditChannel ("ApiOutbound"/
// "DbOutbound"); fall back to ApiOutbound if the row is missing or the
// string is unrecognised so the row still records who asked.
var channel = row is not null && Enum.TryParse<AuditChannel>(row.Channel, out var parsed)
? parsed
: AuditChannel.ApiOutbound;
var evt = ScadaBridgeAuditEventFactory.Create(
channel: channel,
kind: AuditKind.CachedResolve,
status: status,
occurredAtUtc: DateTime.UtcNow,
actor: actor,
target: row?.Target,
sourceNode: row?.SourceNode,
correlationId: trackedOperationId,
sourceSiteId: row?.SourceSite);
await _auditWriter.WriteAsync(evt).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to emit operator relay audit row for {TrackedOperationId}.",
trackedOperationId);
}
finally
{
scope?.Dispose();
}
}
/// <summary>