feat(audit): SecuredWrite audit kinds + best-effort per-lifecycle central direct-write; guard approve Decode (T14b)

This commit is contained in:
Joseph Doherty
2026-06-18 03:17:56 -04:00
parent c8d9303031
commit b08bfae329
4 changed files with 322 additions and 4 deletions
@@ -1,3 +1,4 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -21,6 +22,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -893,6 +896,74 @@ public class ManagementActor : ReceiveActor
e.Status, e.OperatorUser, e.OperatorComment, e.SubmittedAtUtc,
e.VerifierUser, e.VerifierComment, e.DecidedAtUtc, e.ExecutedAtUtc, e.ExecutionError);
/// <summary>
/// Deterministic, reversible map from a <see cref="PendingSecuredWrite.Id"/> (a
/// store-assigned <see cref="long"/>) to the canonical AuditLog
/// <c>CorrelationId</c> (a <see cref="Guid"/>): the 8-byte big-endian id occupies
/// the final 8 bytes of an otherwise-zero Guid. Every row across one secured-write
/// lifecycle (submit → approve → execute, or submit → reject) shares this value so
/// they join into one operation; the encoding is stable (same id ⇒ same Guid).
/// </summary>
private static Guid SecuredWriteCorrelation(long id)
{
Span<byte> bytes = stackalloc byte[16];
BinaryPrimitives.WriteInt64BigEndian(bytes[8..], id);
return new Guid(bytes);
}
/// <summary>
/// Best-effort emission of ONE secured-write AuditLog row via the central direct-write
/// path (<see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>) — mirrors the
/// Notification Outbox / Inbound API central-origin pattern. The row is built through
/// the canonical <see cref="ScadaBridgeAuditEventFactory"/> so Action/Category/Outcome
/// map identically to every other emit site.
/// </summary>
/// <remarks>
/// Standing audit invariant: an audit-write failure NEVER aborts the secured-write
/// action. Every exception (repository resolution OR the insert) is caught, logged at
/// warning, and swallowed — the caller's own success/failure path is authoritative.
/// </remarks>
private static async Task EmitSecuredWriteAuditAsync(
IServiceProvider sp,
AuditKind kind,
AuditStatus status,
PendingSecuredWrite row,
string actor,
string? errorMessage = null)
{
try
{
var evt = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.SecuredWrite,
kind: kind,
status: status,
actor: actor,
target: $"{row.SiteId}/{row.ConnectionName}/{row.TagPath}",
correlationId: SecuredWriteCorrelation(row.Id),
sourceSiteId: row.SiteId,
errorMessage: errorMessage,
// Carry the counterparty (operator on a verifier-actioned row, and
// vice-versa) so a single row names both parties to the two-person write.
extra: JsonSerializer.Serialize(new
{
operatorUser = row.OperatorUser,
verifierUser = row.VerifierUser
}));
using var scope = sp.CreateScope();
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
await auditRepo.InsertIfNotExistsAsync(evt);
}
catch (Exception ex)
{
// Audit is best-effort — swallow + log; never abort the secured-write action.
sp.GetService<ILogger<ManagementActor>>()?.LogWarning(
ex,
"Best-effort secured-write audit emission failed (kind={Kind}, id={Id}); the write itself is unaffected.",
kind, row.Id);
}
}
private static async Task<object?> HandleSubmitSecuredWrite(
IServiceProvider sp, SubmitSecuredWriteCommand cmd, AuthenticatedUser user)
{
@@ -926,6 +997,12 @@ public class ManagementActor : ReceiveActor
var repo = sp.GetRequiredService<ISecuredWriteRepository>();
entity.Id = await repo.AddAsync(entity);
// T14b — one append-only audit row per lifecycle event. Emitted AFTER the row is
// persisted (so it carries the store-assigned id); best-effort — see helper.
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteSubmit, AuditStatus.Submitted, entity, actor: entity.OperatorUser);
return ToSecuredWriteDto(entity);
}
@@ -962,6 +1039,12 @@ public class ManagementActor : ReceiveActor
row.VerifierComment = cmd.Comment;
row.DecidedAtUtc = decidedAtUtc;
// T14b — the approval decision is itself an audited lifecycle event (the
// verifier won the CAS). Emitted with the in-flight Submitted status; the
// Execute row below records the terminal write outcome.
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteApprove, AuditStatus.Submitted, row, actor: user.Username);
// Validate the value type BEFORE attempting the relay. An unknown type can
// never be decoded/written, so fail the row deterministically rather than
// leaving it stuck Approved. (Addresses the C2 reviewer's deferred
@@ -972,10 +1055,32 @@ public class ManagementActor : ReceiveActor
row.ExecutedAtUtc = DateTime.UtcNow;
row.ExecutionError = "unknown value type";
await repo.UpdateAsync(row);
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteExecute, AuditStatus.Failed, row,
actor: user.Username, errorMessage: row.ExecutionError);
return ToSecuredWriteDto(row);
}
var value = Commons.Types.AttributeValueCodec.Decode(row.ValueJson, dataType, elementType: null);
// C3 robustness fix: Decode is UNGUARDED in the pre-T14b code — a List-typed
// value carrying corrupt JSON throws out of the handler and leaves the row
// stuck Approved. Contain it: fail the row deterministically with the decode
// error, audit the failure, and return WITHOUT relaying (nothing to write).
object? value;
try
{
value = Commons.Types.AttributeValueCodec.Decode(row.ValueJson, dataType, elementType: null);
}
catch (Exception ex)
{
row.Status = "Failed";
row.ExecutedAtUtc = DateTime.UtcNow;
row.ExecutionError = $"value decode error: {ex.Message}";
await repo.UpdateAsync(row);
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteExecute, AuditStatus.Failed, row,
actor: user.Username, errorMessage: row.ExecutionError);
return ToSecuredWriteDto(row);
}
// Relay the write to the site MxGateway connection. A transport exception is
// contained so the row is never left stuck Approved.
@@ -1007,6 +1112,17 @@ public class ManagementActor : ReceiveActor
// UpdateAsync overwrites all columns -> pass the fully-populated entity.
await repo.UpdateAsync(row);
// T14b — terminal execute outcome: Delivered (relay succeeded) maps to canonical
// Success, Failed maps to canonical Failure (the error rides in the row detail).
await EmitSecuredWriteAuditAsync(
sp,
AuditKind.SecuredWriteExecute,
success ? AuditStatus.Delivered : AuditStatus.Failed,
row,
actor: user.Username,
errorMessage: error);
return ToSecuredWriteDto(row);
}
@@ -1033,6 +1149,12 @@ public class ManagementActor : ReceiveActor
// UpdateAsync overwrites all columns -> pass the fully-populated entity.
await repo.UpdateAsync(entity);
// T14b — reject is a terminal lifecycle event (canonical Discarded outcome).
// Actor = the verifier; the operator is carried in the row's extra detail.
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteReject, AuditStatus.Discarded, entity, actor: user.Username);
return ToSecuredWriteDto(entity);
}