feat(audit)!: ScadaBridge C3 — swap to canonical ZB.MOM.WW.Audit.AuditEvent across seams/emitters/DTO/redactor wiring; transitional 24-col storage shim (Task 2.5)

This commit is contained in:
Joseph Doherty
2026-06-02 12:37:50 -04:00
parent 5aaf9e2923
commit db707bb0de
127 changed files with 2240 additions and 3886 deletions
@@ -1,10 +1,11 @@
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
@@ -13,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// Central-side singleton (per Bundle E wiring) that ingests batches of
/// <see cref="AuditEvent"/> rows pushed from sites via the
/// <c>IngestAuditEvents</c> gRPC RPC. Each row is stamped with the central-side
/// <see cref="AuditEvent.IngestedAtUtc"/> and inserted idempotently via
/// the central-side IngestedAtUtc (in DetailsJson) and inserted idempotently via
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — duplicates are
/// silently swallowed (first-write-wins per Bundle A's hardening).
/// </summary>
@@ -127,19 +128,19 @@ public class AuditLogIngestActor : ReceiveActor
// without blocking on sync Dispose() of pending connection cleanup.
if (_injectedRepository is not null)
{
await IngestWithRepositoryAsync(_injectedRepository, filter: null, failureCounter: null, cmd, nowUtc, accepted)
await IngestWithRepositoryAsync(_injectedRepository, redactor: null, failureCounter: null, cmd, nowUtc, accepted)
.ConfigureAwait(false);
}
else
{
await using var scope = _serviceProvider!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var filter = scope.ServiceProvider.GetService<IAuditPayloadFilter>();
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
// M6 Bundle E (T8): central health counter is best-effort —
// unregistered (test composition roots) means the per-row catch
// simply logs without surfacing on the health dashboard.
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
await IngestWithRepositoryAsync(repository, filter, failureCounter, cmd, nowUtc, accepted)
await IngestWithRepositoryAsync(repository, redactor, failureCounter, cmd, nowUtc, accepted)
.ConfigureAwait(false);
}
@@ -148,7 +149,7 @@ public class AuditLogIngestActor : ReceiveActor
private async Task IngestWithRepositoryAsync(
IAuditLogRepository repository,
IAuditPayloadFilter? filter,
IAuditRedactor? redactor,
ICentralAuditWriteFailureCounter? failureCounter,
IngestAuditEventsCommand cmd,
DateTime nowUtc,
@@ -162,15 +163,17 @@ public class AuditLogIngestActor : ReceiveActor
// repository hardening already swallows duplicate-key races,
// so the same id arriving twice (site retry, reconciliation)
// is a silent no-op.
// Filter BEFORE the IngestedAtUtc stamp so the redacted
// copy carries the central-side ingest timestamp. Filter
// Redact BEFORE the IngestedAtUtc stamp so the redacted
// copy carries the central-side ingest timestamp. The redactor
// is contract-bound to never throw. AuditLog-008: a null
// filter (test composition root, no IAuditPayloadFilter
// redactor (test composition root, no IAuditRedactor
// registered) now falls back to the SafeDefault rather than
// pass-through, so HTTP header redaction always runs.
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
var filtered = safeFilter.Apply(evt);
var ingested = filtered with { IngestedAtUtc = nowUtc };
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on
// the canonical record, so stamp it via the projection helper.
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
var filtered = safeRedactor.Apply(evt);
var ingested = AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc);
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
accepted.Add(evt.EventId);
}
@@ -216,12 +219,12 @@ public class AuditLogIngestActor : ReceiveActor
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// Bundle C (M5-T6): resolve the filter for the whole batch from
// the scope; null = pass-through for test composition roots that
// skip the filter registration. The filter is contract-bound to
// Bundle C (M5-T6): resolve the redactor for the whole batch from
// the scope; null = SafeDefault for test composition roots that
// skip the redactor registration. The redactor is contract-bound to
// never throw, so we can apply it inside the per-entry try
// without risking an unbounded blast radius.
var filter = scope.ServiceProvider.GetService<IAuditPayloadFilter>();
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
// M6 Bundle E (T8): same best-effort central health counter as
// the OnIngestAsync path — null on test composition roots that
// skip the registration.
@@ -240,14 +243,16 @@ public class AuditLogIngestActor : ReceiveActor
// matching timestamps (debugging convenience, not a
// correctness invariant).
var ingestedAt = DateTime.UtcNow;
// Filter the audit half BEFORE the dual-write — only the
// AuditLog row's payload columns are filterable; SiteCalls
// Redact the audit half BEFORE the dual-write — only the
// AuditLog row's payload columns are redactable; SiteCalls
// carries operational state only (status, retry count) and
// is left untouched. AuditLog-008: null filter falls back
// is left untouched. AuditLog-008: null redactor falls back
// to SafeDefault so header redaction always runs.
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
var filteredAudit = safeFilter.Apply(entry.Audit);
var auditStamped = filteredAudit with { IngestedAtUtc = ingestedAt };
// C3 transitional shim: IngestedAtUtc is a DetailsJson field
// on the canonical record, so stamp it via the projection helper.
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
var filteredAudit = safeRedactor.Apply(entry.Audit);
var auditStamped = AuditRowProjection.WithIngestedAtUtc(filteredAudit, ingestedAt);
var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
await auditRepo.InsertIfNotExistsAsync(auditStamped)
@@ -1,9 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
@@ -41,7 +42,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
{
private readonly IServiceProvider _services;
private readonly ILogger<CentralAuditWriter> _logger;
private readonly IAuditPayloadFilter _filter;
private readonly IAuditRedactor _redactor;
private readonly ICentralAuditWriteFailureCounter _failureCounter;
private readonly INodeIdentityProvider? _nodeIdentity;
@@ -68,24 +69,25 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
/// </summary>
/// <param name="services">Service provider used to open a per-call scope for the scoped repository.</param>
/// <param name="logger">Logger for swallowed write-failure diagnostics.</param>
/// <param name="filter">Optional payload filter for truncation and redaction; defaults to a pass-through.</param>
/// <param name="redactor">Optional canonical redactor for truncation and redaction; defaults to the always-safe default.</param>
/// <param name="failureCounter">Optional counter incremented on swallowed repository failures; defaults to a no-op.</param>
/// <param name="nodeIdentity">Optional node identity provider for stamping <c>SourceNode</c> on central-origin rows.</param>
public CentralAuditWriter(
IServiceProvider services,
ILogger<CentralAuditWriter> logger,
IAuditPayloadFilter? filter = null,
IAuditRedactor? redactor = null,
ICentralAuditWriteFailureCounter? failureCounter = null,
INodeIdentityProvider? nodeIdentity = null)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// AuditLog-008: never default to null — over-redact instead.
// SafeDefaultAuditPayloadFilter applies HTTP header redaction with
// hard-coded sensitive defaults so a composition root that omits the
// real filter still scrubs Authorization / X-Api-Key / Cookie /
// Set-Cookie before persistence.
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
// C3 (Task 2.5): the canonical IAuditRedactor replaces the legacy
// IAuditPayloadFilter. SafeDefaultAuditRedactor applies HTTP header
// redaction with hard-coded sensitive defaults so a composition root
// that omits the real redactor still scrubs Authorization / X-Api-Key /
// Cookie / Set-Cookie before persistence.
_redactor = redactor ?? SafeDefaultAuditRedactor.Instance;
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
_nodeIdentity = nodeIdentity;
}
@@ -103,12 +105,12 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
try
{
// Filter BEFORE stamping IngestedAtUtc + handing to the repo. The
// filter contract is "never throws". AuditLog-008: _filter is now
// non-null (SafeDefaultAuditPayloadFilter fallback) so header
// Redact BEFORE stamping IngestedAtUtc + handing to the repo. The
// redactor contract is "never throws". AuditLog-008: _redactor is
// now non-null (SafeDefaultAuditRedactor fallback) so header
// redaction always runs even in composition roots that omit the
// real filter.
var filtered = _filter.Apply(evt);
// real redactor.
var filtered = _redactor.Apply(evt);
// SourceNode-stamping (Task 12): caller-provided value wins
// (supports any future direct-write callsite that already has its
@@ -124,7 +126,9 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
await using var scope = _services.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var stamped = filtered with { IngestedAtUtc = DateTime.UtcNow };
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on the
// canonical record, so stamp it via the projection helper.
var stamped = AuditRowProjection.WithIngestedAtUtc(filtered, DateTime.UtcNow);
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
}
catch (Exception ex)
@@ -143,17 +147,17 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
// misbehaving custom counter does, swallowing here keeps the
// best-effort contract intact.
}
// Log the input event's identifying fields. These three (EventId,
// Kind, Status) are immutable across the filter+stamp chain — the
// `with` clones above touch only SourceNode and IngestedAtUtc — so
// referencing `evt` here is intentional and equivalent to the
// stamped record for diagnostics. If you add a field here that the
// stamp chain DOES mutate (e.g., SourceNode), reference the latest
// post-stamp record name instead, not `evt`.
// Log the input event's identifying fields. EventId + Action are
// immutable across the redact+stamp chain — the `with` clones above
// touch only SourceNode and DetailsJson — so referencing `evt` here
// is intentional and equivalent to the stamped record for
// diagnostics. Action = "{Channel}.{Kind}" carries the kind; the
// canonical Outcome carries the coarse status (fine-grained Status
// lives in DetailsJson).
_logger.LogWarning(
ex,
"CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})",
evt.EventId, evt.Kind, evt.Status);
"CentralAuditWriter failed for EventId {EventId} (Action={Action}, Outcome={Outcome})",
evt.EventId, evt.Action, evt.Outcome);
}
}
}
@@ -2,8 +2,8 @@ using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
@@ -258,7 +258,9 @@ public class SiteAuditReconciliationActor : ReceiveActor
// concurrent push, or a retry of this very pull) collapse to
// a no-op courtesy of M2 Bundle A's race-fix on
// InsertIfNotExistsAsync.
var ingested = evt with { IngestedAtUtc = nowUtc };
// C3: IngestedAtUtc is a DetailsJson field on the canonical record —
// stamp it via the projection helper.
var ingested = AuditRowProjection.WithIngestedAtUtc(evt, nowUtc);
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
_failedInsertAttempts.Remove(evt.EventId);
advanceForThisRow = true;
@@ -299,9 +301,11 @@ public class SiteAuditReconciliationActor : ReceiveActor
}
}
if (advanceForThisRow && evt.OccurredAtUtc > maxOccurred)
// C3: canonical OccurredAtUtc is a DateTimeOffset; the cursor is a UTC DateTime.
var occurredUtc = evt.OccurredAtUtc.UtcDateTime;
if (advanceForThisRow && occurredUtc > maxOccurred)
{
maxOccurred = evt.OccurredAtUtc;
maxOccurred = occurredUtc;
}
}
@@ -1,304 +0,0 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary>
/// Default <see cref="IAuditPayloadFilter"/>. Bundle A established the
/// truncation backbone; Bundle B chains HTTP header redaction (M5-T3) BEFORE
/// truncation so redactors operate on the full payload and the cap then trims
/// the redacted result.
/// </summary>
/// <remarks>
/// <para>
/// Uses <see cref="IOptionsMonitor{TOptions}"/> (not <see cref="IOptions{TOptions}"/>)
/// so the M5-T8 hot-reload path sees fresh values without re-resolving the
/// singleton. <see cref="Apply"/> reads <see cref="IOptionsMonitor{T}.CurrentValue"/>
/// on every call, and the regex cache is keyed by pattern string — patterns
/// added via a live config change compile on first use of the next event;
/// patterns removed simply stop being looked up. No <c>OnChange</c> subscription
/// or explicit cache invalidation is required (the
/// <c>AuditLogOptionsBindingTests</c> fixture in <c>ZB.MOM.WW.ScadaBridge.AuditLog.Tests</c>
/// pins this behaviour).
/// </para>
/// <para>
/// "Error row" = <see cref="AuditEvent.Status"/> NOT IN (<c>Delivered</c>,
/// <c>Submitted</c>, <c>Forwarded</c>) — every other status, including the
/// non-terminal <c>Attempted</c>, the parked/discarded terminals, and the
/// short-circuit <c>Skipped</c>, receives the larger error cap so a verbose
/// error body survives.
/// </para>
/// <para>
/// Apply MUST NOT throw — on internal failure the filter over-redacts by
/// returning the input with <see cref="AuditEvent.PayloadTruncated"/> set and
/// increments the <c>AuditRedactionFailure</c> health metric via the injected
/// <see cref="IAuditRedactionFailureCounter"/>. Each redactor stage runs in
/// its own try/catch — a failure in (say) the header redactor still lets the
/// SQL parameter redactor and the truncator run on the remaining fields.
/// </para>
/// <para>
/// Stage order (each runs on every applicable field):
/// header redaction → body regex redaction → truncation. The SQL-parameter
/// stage piggybacks on the body-redactor path; both run BEFORE truncation so
/// the cap trims the redacted result, never bytes the redactor intended to
/// hide.
/// </para>
/// </remarks>
public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
{
// Redaction markers + the relaxed-escaping JSON options live in
// AuditRedactionPrimitives, and the compiled-regex cache (50 ms match
// timeout, 100 ms compile budget, invalid-pattern sentinel) lives in
// AuditRegexCache — both shared C2 helpers so the legacy filter and the
// canonical ScadaBridgeAuditRedactor emit byte-identical output.
private readonly IOptionsMonitor<AuditLogOptions> _options;
private readonly ILogger<DefaultAuditPayloadFilter> _logger;
private readonly IAuditRedactionFailureCounter _failureCounter;
private readonly AuditRegexCache _regexCache;
/// <summary>
/// Primary constructor used by DI — pulls the optional redaction-failure
/// counter from the container; a NoOp default is registered in
/// <see cref="ServiceCollectionExtensions.AddAuditLog"/>.
/// </summary>
/// <param name="options">Live-reloadable audit log options.</param>
/// <param name="logger">Logger for redaction diagnostics.</param>
/// <param name="failureCounter">Optional counter incremented when a redaction operation fails; defaults to a no-op.</param>
public DefaultAuditPayloadFilter(
IOptionsMonitor<AuditLogOptions> options,
ILogger<DefaultAuditPayloadFilter> logger,
IAuditRedactionFailureCounter? failureCounter = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter();
_regexCache = new AuditRegexCache(_logger);
}
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent)
{
try
{
var opts = _options.CurrentValue;
// Inbound API gets a dedicated, larger ceiling — request/response bodies are
// captured verbatim up to InboundMaxBytes (default 1 MiB) so support can
// replay exactly what the caller sent and what we returned. Other channels
// keep the global 8 KiB / 64 KiB policy.
// See docs/plans/2026-05-23-inbound-api-full-response-audit-design.md.
var cap = rawEvent.Channel == AuditChannel.ApiInbound
? opts.InboundMaxBytes
: (IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes);
// --- Header-redaction stage (runs BEFORE truncation) ----------
var request = RedactHeaders(rawEvent.RequestSummary, opts.HeaderRedactList);
var response = RedactHeaders(rawEvent.ResponseSummary, opts.HeaderRedactList);
var errorDetail = rawEvent.ErrorDetail;
var extra = rawEvent.Extra;
// --- Body-regex stage (also runs BEFORE truncation) -----------
// Resolves the active regex set per event so per-target overrides
// bound to AuditEvent.Target are picked up; effectively a no-op
// when neither GlobalBodyRedactors nor the per-target additions
// are configured.
var bodyRegexes = ResolveBodyRegexes(opts, rawEvent.Target);
if (bodyRegexes.Count > 0)
{
request = RedactBody(request, bodyRegexes);
response = RedactBody(response, bodyRegexes);
errorDetail = RedactBody(errorDetail, bodyRegexes);
extra = RedactBody(extra, bodyRegexes);
}
// --- SQL parameter redaction stage (DbOutbound only) ----------
// Parses the M4 AuditingDbCommand RequestSummary shape
// {"sql":"...","parameters":{...}} and redacts parameter VALUES
// whose NAME matches the per-connection regex. Opt-in: no
// PerTargetOverrides[connectionName].RedactSqlParamsMatching =>
// no-op. Channel-guarded so the same regex can never accidentally
// touch an ApiOutbound row.
if (rawEvent.Channel == AuditChannel.DbOutbound
&& TryGetSqlParamRedactor(opts, rawEvent.Target, out var sqlParamRegex))
{
request = RedactSqlParameters(request, sqlParamRegex!);
}
// --- Truncation stage -----------------------------------------
var truncated = false;
request = TruncateField(request, cap, ref truncated);
response = TruncateField(response, cap, ref truncated);
errorDetail = TruncateField(errorDetail, cap, ref truncated);
extra = TruncateField(extra, cap, ref truncated);
return rawEvent with
{
RequestSummary = request,
ResponseSummary = response,
ErrorDetail = errorDetail,
Extra = extra,
PayloadTruncated = rawEvent.PayloadTruncated || truncated,
};
}
catch (Exception ex)
{
// Audit is best-effort: over-redact rather than fail the caller.
// The per-stage try/catches above already handle redactor faults
// and increment the counter; this catch covers any unexpected
// surprise in the surrounding orchestration code.
_logger.LogWarning(
ex,
"Payload filter failed; returning raw event with PayloadTruncated=true");
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
return rawEvent with { PayloadTruncated = true };
}
}
/// <summary>
/// Parse <paramref name="json"/> as the documented
/// <c>{"headers": {...}, "body": ...}</c> shape and replace values whose
/// header NAME (case-insensitive) is in <paramref name="redactList"/> with
/// the redaction marker. Re-serialises and returns the result. Delegates to
/// <see cref="AuditRedactionPrimitives.RedactHeaders"/>.
/// </summary>
/// <remarks>
/// No-op pass-through for inputs that aren't JSON-shaped — emitters that
/// have not yet adopted the convention (the M2 site emitters today, which
/// leave RequestSummary null on outbound API calls) get a transparent
/// pass. If the redactor itself throws, we over-redact the whole field
/// with the redactor-error marker and bump the failure counter.
/// </remarks>
private string? RedactHeaders(string? json, IList<string> redactList)
=> AuditRedactionPrimitives.RedactHeaders(json, redactList, _logger, IncrementFailureCounter);
/// <summary>
/// Combine the global and per-target body-redactor lists for a single
/// event, returning the compiled-regex set to apply. Patterns that failed
/// compilation are silently skipped — the compile-time failure was logged
/// once on first encounter; we never let one bad pattern starve the rest.
/// </summary>
private IReadOnlyList<Regex> ResolveBodyRegexes(AuditLogOptions opts, string? target)
{
var hasGlobal = opts.GlobalBodyRedactors is { Count: > 0 };
var perTargetAdditions = (target != null
&& opts.PerTargetOverrides.TryGetValue(target, out var over)
&& over.AdditionalBodyRedactors is { Count: > 0 })
? over.AdditionalBodyRedactors
: null;
if (!hasGlobal && perTargetAdditions == null)
{
return Array.Empty<Regex>();
}
var result = new List<Regex>();
if (hasGlobal)
{
foreach (var pattern in opts.GlobalBodyRedactors)
{
if (_regexCache.TryGet(pattern, out var rx))
{
result.Add(rx!);
}
}
}
if (perTargetAdditions != null)
{
foreach (var pattern in perTargetAdditions)
{
if (_regexCache.TryGet(pattern, out var rx))
{
result.Add(rx!);
}
}
}
return result;
}
/// <summary>
/// Apply each compiled body-redactor regex to <paramref name="value"/> in
/// turn, replacing every match with the redaction marker. If any single
/// regex match throws (most commonly
/// <see cref="RegexMatchTimeoutException"/>) the field is over-redacted with
/// the redactor-error marker and the failure counter is incremented — the
/// user-facing action is never aborted. Delegates to
/// <see cref="AuditRedactionPrimitives.RedactBody"/>.
/// </summary>
private string? RedactBody(string? value, IReadOnlyList<Regex> regexes)
=> AuditRedactionPrimitives.RedactBody(value, regexes, _logger, IncrementFailureCounter);
/// <summary>
/// Resolve the per-connection SQL parameter redaction regex for the given
/// DbOutbound event target. Target shape (M4 AuditingDbCommand): the
/// connection name optionally followed by <c>.&lt;sql-snippet&gt;</c> for
/// disambiguation; the per-target dictionary is keyed by the connection
/// name alone, so we strip the snippet suffix before lookup. Patterns are
/// compiled with case-insensitive matching to match the documented
/// behaviour.
/// </summary>
private bool TryGetSqlParamRedactor(AuditLogOptions opts, string? target, out Regex? regex)
{
regex = null;
if (string.IsNullOrEmpty(target))
{
return false;
}
var dot = target.IndexOf('.');
var connectionKey = dot < 0 ? target : target[..dot];
if (!opts.PerTargetOverrides.TryGetValue(connectionKey, out var over)
|| string.IsNullOrEmpty(over.RedactSqlParamsMatching))
{
return false;
}
// Force case-insensitivity per the spec — even if the operator wrote
// the pattern without an IgnoreCase flag. The compile cache key folds
// the option to keep the entries unambiguous.
var cacheKey = "(?i)" + over.RedactSqlParamsMatching;
if (!_regexCache.TryGet(cacheKey, out regex))
{
return false;
}
return true;
}
/// <summary>
/// Walk the M4 <c>{"sql":"...","parameters":{...}}</c> RequestSummary
/// shape; for each parameter whose NAME matches
/// <paramref name="paramNameRegex"/>, replace its value with the redaction
/// marker. Re-serialise. Delegates to
/// <see cref="AuditRedactionPrimitives.RedactSqlParameters"/>.
/// </summary>
/// <remarks>
/// No-op pass-through when the input isn't parseable JSON, isn't a JSON
/// object, or doesn't carry a top-level <c>"parameters"</c> object. On
/// any unexpected fault the field is over-redacted and the failure counter
/// is bumped.
/// </remarks>
private string? RedactSqlParameters(string? json, Regex paramNameRegex)
=> AuditRedactionPrimitives.RedactSqlParameters(json, paramNameRegex, _logger, IncrementFailureCounter);
private static string? TruncateField(string? value, int cap, ref bool truncated)
=> AuditRedactionPrimitives.TruncateField(value, cap, ref truncated);
/// <summary>
/// Bumps the injected redaction-failure counter, swallowing any fault per
/// alog.md §7 (a counter failure must never abort the audited action).
/// Passed as the <c>onFailure</c> callback to the shared primitives.
/// </summary>
private void IncrementFailureCounter()
{
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
}
private static bool IsErrorStatus(AuditStatus status) => status switch
{
AuditStatus.Delivered or AuditStatus.Submitted or AuditStatus.Forwarded => false,
_ => true,
};
}
@@ -1,31 +0,0 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary>
/// Filters an <see cref="AuditEvent"/> between construction and persistence —
/// truncates oversized payload fields, applies header/body/SQL-parameter
/// redaction, sets <see cref="AuditEvent.PayloadTruncated"/>.
/// </summary>
/// <remarks>
/// <para>
/// Pure function: returns a filtered COPY of the input via <c>with</c>
/// expressions; never throws (over-redacts on internal failure and increments
/// the <c>AuditRedactionFailure</c> health metric).
/// </para>
/// <para>
/// Wired in M5 between event construction and the writer chain
/// (<c>FallbackAuditWriter.WriteAsync</c>, <c>CentralAuditWriter.WriteAsync</c>,
/// and the <c>AuditLogIngestActor</c> handlers).
/// </para>
/// </remarks>
public interface IAuditPayloadFilter
{
/// <summary>
/// Apply the configured truncation + redaction policy to <paramref name="rawEvent"/>
/// and return a filtered copy. MUST NOT throw — on internal failure, over-redact
/// and surface the failure via the audit-redaction-failure health metric.
/// </summary>
/// <param name="rawEvent">The unfiltered audit event to process.</param>
AuditEvent Apply(AuditEvent rawEvent);
}
@@ -1,79 +0,0 @@
using System.Text.RegularExpressions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary>
/// AuditLog-008: minimal always-safe fallback filter used by the writer chain
/// when no <see cref="IAuditPayloadFilter"/> is injected (test composition
/// roots, future composition roots that bypass <c>AddAuditLog</c>). Performs
/// HTTP header redaction for the always-sensitive defaults
/// (Authorization, X-Api-Key, Cookie, Set-Cookie) so a fixture that wires a
/// real <see cref="AuditEvent.RequestSummary"/> never persists those headers
/// in cleartext. Does NOT perform body-regex redaction, SQL-parameter
/// redaction, or truncation — those stages need
/// <see cref="DefaultAuditPayloadFilter"/> with live options. The contract is:
/// over-redact safely, never throw, never miss a header that's on the
/// default sensitive list.
/// </summary>
public sealed class SafeDefaultAuditPayloadFilter : IAuditPayloadFilter
{
/// <summary>Singleton instance — the filter is stateless and side-effect-free.</summary>
public static SafeDefaultAuditPayloadFilter Instance { get; } = new SafeDefaultAuditPayloadFilter();
private static readonly string[] DefaultHeaderRedactList =
{
"Authorization",
"X-Api-Key",
"Cookie",
"Set-Cookie",
};
private static readonly Regex HeaderRegex = new(
@"(?<name>[A-Za-z][A-Za-z0-9\-_]*)\s*:\s*(?<value>[^\r\n]*)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private SafeDefaultAuditPayloadFilter() { }
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent)
{
ArgumentNullException.ThrowIfNull(rawEvent);
try
{
return rawEvent with
{
RequestSummary = RedactHeaders(rawEvent.RequestSummary),
ResponseSummary = RedactHeaders(rawEvent.ResponseSummary),
};
}
catch
{
// Over-redact: drop both summaries entirely so a malformed parse
// path never leaks the original. The contract is "never throw."
return rawEvent with
{
RequestSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
ResponseSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
};
}
}
private static string? RedactHeaders(string? summary)
{
if (string.IsNullOrEmpty(summary)) return summary;
return HeaderRegex.Replace(summary, m =>
{
var name = m.Groups["name"].Value;
foreach (var sensitive in DefaultHeaderRedactList)
{
if (string.Equals(name, sensitive, StringComparison.OrdinalIgnoreCase))
{
return $"{name}: [REDACTED]";
}
}
return m.Value;
});
}
}
@@ -3,13 +3,16 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
namespace ZB.MOM.WW.ScadaBridge.AuditLog;
@@ -69,14 +72,15 @@ public static class ServiceCollectionExtensions
// validator (a strict improvement over the previous AddSingleton).
services.AddValidatedOptions<AuditLogOptions, AuditLogOptionsValidator>(config, ConfigSectionName);
// M5 Bundle A: payload filter — truncates oversized RequestSummary /
// ResponseSummary / ErrorDetail / Extra fields between event
// construction and persistence. Bundle B layers header / body /
// SQL-parameter redaction onto the same singleton; Bundle C wires it
// into the FallbackAuditWriter / CentralAuditWriter / IngestActor
// paths. Singleton — the filter is stateless and the IOptionsMonitor
// dependency picks up M5-T8 hot reloads on its own.
services.AddSingleton<IAuditPayloadFilter, DefaultAuditPayloadFilter>();
// C3 (Task 2.5): the canonical IAuditRedactor replaces the legacy
// IAuditPayloadFilter in the writer pipeline. ScadaBridgeAuditRedactor
// is the port of DefaultAuditPayloadFilter onto the canonical record +
// its DetailsJson payload bag — same truncation + header / body /
// SQL-parameter redaction, applied between event construction and
// persistence. Singleton — stateless; the IOptionsMonitor dependency
// picks up hot reloads on its own. The old IAuditPayloadFilter classes
// are retained but no longer wired into any pipeline (C7 deletes them).
services.AddSingleton<IAuditRedactor, ScadaBridgeAuditRedactor>();
// M5 Bundle B: per-stage redactor-failure counter. NoOp default;
// Bundle C replaces this binding with the Site Health Monitoring
@@ -115,7 +119,7 @@ public static class ServiceCollectionExtensions
// The script-thread surface is FallbackAuditWriter (primary + ring +
// counter), not the raw SqliteAuditWriter — primary failures must NEVER
// abort the user-facing action.
// Bundle C (M5-T6): the IAuditPayloadFilter singleton above is wired
// C3 (Task 2.5): the canonical IAuditRedactor singleton above is wired
// through the factory so every event written through this surface is
// truncated + redacted before it hits SQLite (and the ring on
// failure).
@@ -124,7 +128,7 @@ public static class ServiceCollectionExtensions
ring: sp.GetRequiredService<RingBufferFallback>(),
failureCounter: sp.GetRequiredService<IAuditWriteFailureCounter>(),
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(),
filter: sp.GetRequiredService<IAuditPayloadFilter>()));
redactor: sp.GetRequiredService<IAuditRedactor>()));
// ISiteStreamAuditClient: NoOp default. This binding remains correct for
// central/test composition roots that have no SiteCommunicationActor.
@@ -202,7 +206,7 @@ public static class ServiceCollectionExtensions
// is intentionally distinct from IAuditWriter so site composition roots
// do not accidentally bind it; central composition roots that include
// AddConfigurationDatabase get a working implementation transparently.
// Bundle C (M5-T6): wire the IAuditPayloadFilter into the factory so
// C3 (Task 2.5): wire the canonical IAuditRedactor into the factory so
// NotificationOutboxActor + Inbound API rows are truncated + redacted
// before they hit MS SQL.
// M6 Bundle E (T8): also wire the ICentralAuditWriteFailureCounter
@@ -210,7 +214,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter(
sp,
sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
sp.GetRequiredService<IAuditPayloadFilter>(),
sp.GetRequiredService<IAuditRedactor>(),
sp.GetRequiredService<ICentralAuditWriteFailureCounter>(),
// SourceNode-stamping (Task 12): wire the local node identity so
// central-origin rows (Notification Outbox dispatch, Inbound API)
@@ -1,7 +1,8 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
@@ -31,7 +32,7 @@ public sealed class FallbackAuditWriter : IAuditWriter
private readonly RingBufferFallback _ring;
private readonly IAuditWriteFailureCounter _failureCounter;
private readonly ILogger<FallbackAuditWriter> _logger;
private readonly IAuditPayloadFilter _filter;
private readonly IAuditRedactor _redactor;
private readonly SemaphoreSlim _drainGate = new(1, 1);
/// <summary>
@@ -48,26 +49,28 @@ public sealed class FallbackAuditWriter : IAuditWriter
/// <param name="ring">Drop-oldest ring buffer used to stash events when the primary fails.</param>
/// <param name="failureCounter">Counter incremented on each primary failure for health reporting.</param>
/// <param name="logger">Logger for diagnostics.</param>
/// <param name="filter">Optional payload filter applied before writing; null means no filtering.</param>
/// <param name="redactor">Optional canonical redactor applied before writing; null means the always-safe default.</param>
public FallbackAuditWriter(
IAuditWriter primary,
RingBufferFallback ring,
IAuditWriteFailureCounter failureCounter,
ILogger<FallbackAuditWriter> logger,
IAuditPayloadFilter? filter = null)
IAuditRedactor? redactor = null)
{
_primary = primary ?? throw new ArgumentNullException(nameof(primary));
_ring = ring ?? throw new ArgumentNullException(nameof(ring));
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// AuditLog-008: never default to a null filter — over-redact instead.
// SafeDefaultAuditPayloadFilter.Instance performs HTTP header
// AuditLog-008: never default to a null redactor — over-redact instead.
// C3 (Task 2.5): the canonical IAuditRedactor replaces the legacy
// IAuditPayloadFilter. SafeDefaultAuditRedactor performs HTTP header
// redaction with the hard-coded sensitive defaults (Authorization,
// X-Api-Key, Cookie, Set-Cookie) so a test composition root that
// doesn't bind the real options never persists those headers
// verbatim. The real DefaultAuditPayloadFilter (truncation + body /
// SQL-param redaction) is wired by AddAuditLog and takes precedence.
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
// X-Api-Key, Cookie, Set-Cookie) on the DetailsJson summaries so a test
// composition root that doesn't bind the real options never persists
// those headers verbatim. The full ScadaBridgeAuditRedactor (truncation
// + body / SQL-param redaction) is wired by AddAuditLog and takes
// precedence.
_redactor = redactor ?? SafeDefaultAuditRedactor.Instance;
}
/// <inheritdoc />
@@ -75,14 +78,14 @@ public sealed class FallbackAuditWriter : IAuditWriter
{
ArgumentNullException.ThrowIfNull(evt);
// Filter once, up-front. The filtered event flows BOTH to the primary
// Redact once, up-front. The redacted event flows BOTH to the primary
// and (on failure) to the ring buffer — so a primary outage that
// drains later still hands the SqliteAuditWriter a row that has
// already been truncated and redacted. The filter contract is
// "MUST NOT throw". AuditLog-008: _filter is now non-null (defaults
// to SafeDefaultAuditPayloadFilter so header redaction is always
// applied even in composition roots that don't wire the real filter).
var filtered = _filter.Apply(evt);
// already been truncated and redacted. The redactor contract is
// "MUST NOT throw". AuditLog-008: _redactor is now non-null (defaults
// to SafeDefaultAuditRedactor so header redaction is always applied
// even in composition roots that don't wire the real redactor).
var filtered = _redactor.Apply(evt);
try
{
@@ -1,6 +1,6 @@
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
@@ -2,10 +2,11 @@ using System.Threading.Channels;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
@@ -236,14 +237,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
{
ArgumentNullException.ThrowIfNull(evt);
// Site rows always carry a non-null ForwardState; central rows leave it
// null. Force Pending on enqueue so callers can pass a bare AuditEvent
// without thinking about site-vs-central provenance.
var siteEvt = evt.ForwardState is null
? evt with { ForwardState = AuditForwardState.Pending }
: evt;
var pending = new PendingAuditEvent(siteEvt);
// C3 transitional shim: the canonical record carries no ForwardState
// (a site-storage-only concern). Site rows always start Pending; the
// forwarding columns + queries are unchanged from the 24-column schema.
var pending = new PendingAuditEvent(evt, AuditForwardState.Pending);
// CreateBounded(FullMode=Wait) means WriteAsync will await room rather
// than throw when full — exactly the hot-path back-pressure semantics
@@ -360,13 +357,18 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
foreach (var pending in batch)
{
var e = pending.Event;
pEventId.Value = e.EventId.ToString();
pOccurredAt.Value = e.OccurredAtUtc.ToString("o");
pChannel.Value = e.Channel.ToString();
pKind.Value = e.Kind.ToString();
pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value;
pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value;
// C3 transitional shim: decompose the canonical record into
// the typed 24-column values the existing SQLite schema
// expects (Channel/Kind/Status + the DetailsJson domain
// fields). ForwardState rides alongside the canonical record
// (site-storage-only) and is bound from pending.ForwardState.
var r = AuditRowProjection.Decompose(pending.Event);
pEventId.Value = r.EventId.ToString();
pOccurredAt.Value = r.OccurredAtUtc.ToString("o");
pChannel.Value = r.Channel.ToString();
pKind.Value = r.Kind.ToString();
pCorrelationId.Value = (object?)r.CorrelationId?.ToString() ?? DBNull.Value;
pSourceSiteId.Value = (object?)r.SourceSiteId ?? DBNull.Value;
// SourceNode-stamping: caller-provided value wins (preserves
// rows reconciled in from other nodes via the same writer);
// otherwise stamp from the local INodeIdentityProvider. The
@@ -374,24 +376,24 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// time only. If the provider also returns null (unconfigured
// node), the row's SourceNode stays NULL — operators see
// "needs config" via the schema, not a magic fallback string.
var sourceNode = e.SourceNode ?? _nodeIdentity.NodeName;
var sourceNode = r.SourceNode ?? _nodeIdentity.NodeName;
pSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value;
pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value;
pActor.Value = (object?)e.Actor ?? DBNull.Value;
pTarget.Value = (object?)e.Target ?? DBNull.Value;
pStatus.Value = e.Status.ToString();
pHttpStatus.Value = (object?)e.HttpStatus ?? DBNull.Value;
pDurationMs.Value = (object?)e.DurationMs ?? DBNull.Value;
pErrorMessage.Value = (object?)e.ErrorMessage ?? DBNull.Value;
pErrorDetail.Value = (object?)e.ErrorDetail ?? DBNull.Value;
pRequestSummary.Value = (object?)e.RequestSummary ?? DBNull.Value;
pResponseSummary.Value = (object?)e.ResponseSummary ?? DBNull.Value;
pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
pExtra.Value = (object?)e.Extra ?? DBNull.Value;
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value;
pParentExecutionId.Value = (object?)e.ParentExecutionId?.ToString() ?? DBNull.Value;
pSourceInstanceId.Value = (object?)r.SourceInstanceId ?? DBNull.Value;
pSourceScript.Value = (object?)r.SourceScript ?? DBNull.Value;
pActor.Value = (object?)r.Actor ?? DBNull.Value;
pTarget.Value = (object?)r.Target ?? DBNull.Value;
pStatus.Value = r.Status.ToString();
pHttpStatus.Value = (object?)r.HttpStatus ?? DBNull.Value;
pDurationMs.Value = (object?)r.DurationMs ?? DBNull.Value;
pErrorMessage.Value = (object?)r.ErrorMessage ?? DBNull.Value;
pErrorDetail.Value = (object?)r.ErrorDetail ?? DBNull.Value;
pRequestSummary.Value = (object?)r.RequestSummary ?? DBNull.Value;
pResponseSummary.Value = (object?)r.ResponseSummary ?? DBNull.Value;
pPayloadTruncated.Value = r.PayloadTruncated ? 1 : 0;
pExtra.Value = (object?)r.Extra ?? DBNull.Value;
pForwardState.Value = pending.ForwardState.ToString();
pExecutionId.Value = (object?)r.ExecutionId?.ToString() ?? DBNull.Value;
pParentExecutionId.Value = (object?)r.ParentExecutionId?.ToString() ?? DBNull.Value;
try
{
@@ -405,7 +407,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// recorded under the first writer's payload.
_logger.LogDebug(ex,
"Duplicate EventId {EventId} swallowed by SqliteAuditWriter",
e.EventId);
r.EventId);
pending.Completion.TrySetResult();
}
}
@@ -788,34 +790,36 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
private static AuditEvent MapRow(SqliteDataReader reader)
{
return new AuditEvent
{
EventId = Guid.Parse(reader.GetString(0)),
OccurredAtUtc = DateTime.Parse(reader.GetString(1),
// C3 transitional shim: recompose the canonical record from the 24
// columns. The ForwardState column (ordinal 20) is read for the
// schema's sake but NOT placed on the canonical record — it stays a
// site-storage-only concern (the forwarding queries below own it).
return AuditRowProjection.Recompose(new AuditRowProjection.AuditRowValues(
EventId: Guid.Parse(reader.GetString(0)),
OccurredAtUtc: DateTime.Parse(reader.GetString(1),
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind),
Channel = Enum.Parse<AuditChannel>(reader.GetString(2)),
Kind = Enum.Parse<AuditKind>(reader.GetString(3)),
CorrelationId = reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)),
SourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5),
SourceNode = reader.IsDBNull(6) ? null : reader.GetString(6),
SourceInstanceId = reader.IsDBNull(7) ? null : reader.GetString(7),
SourceScript = reader.IsDBNull(8) ? null : reader.GetString(8),
Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
Target = reader.IsDBNull(10) ? null : reader.GetString(10),
Status = Enum.Parse<AuditStatus>(reader.GetString(11)),
HttpStatus = reader.IsDBNull(12) ? null : reader.GetInt32(12),
DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
ErrorMessage = reader.IsDBNull(14) ? null : reader.GetString(14),
ErrorDetail = reader.IsDBNull(15) ? null : reader.GetString(15),
RequestSummary = reader.IsDBNull(16) ? null : reader.GetString(16),
ResponseSummary = reader.IsDBNull(17) ? null : reader.GetString(17),
PayloadTruncated = reader.GetInt32(18) != 0,
Extra = reader.IsDBNull(19) ? null : reader.GetString(19),
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(20)),
ExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
ParentExecutionId = reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)),
};
IngestedAtUtc: null,
Channel: Enum.Parse<AuditChannel>(reader.GetString(2)),
Kind: Enum.Parse<AuditKind>(reader.GetString(3)),
Status: Enum.Parse<AuditStatus>(reader.GetString(11)),
CorrelationId: reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)),
ExecutionId: reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
ParentExecutionId: reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)),
SourceSiteId: reader.IsDBNull(5) ? null : reader.GetString(5),
SourceNode: reader.IsDBNull(6) ? null : reader.GetString(6),
SourceInstanceId: reader.IsDBNull(7) ? null : reader.GetString(7),
SourceScript: reader.IsDBNull(8) ? null : reader.GetString(8),
Actor: reader.IsDBNull(9) ? null : reader.GetString(9),
Target: reader.IsDBNull(10) ? null : reader.GetString(10),
HttpStatus: reader.IsDBNull(12) ? null : reader.GetInt32(12),
DurationMs: reader.IsDBNull(13) ? null : reader.GetInt32(13),
ErrorMessage: reader.IsDBNull(14) ? null : reader.GetString(14),
ErrorDetail: reader.IsDBNull(15) ? null : reader.GetString(15),
RequestSummary: reader.IsDBNull(16) ? null : reader.GetString(16),
ResponseSummary: reader.IsDBNull(17) ? null : reader.GetString(17),
PayloadTruncated: reader.GetInt32(18) != 0,
Extra: reader.IsDBNull(19) ? null : reader.GetString(19)));
}
/// <summary>
@@ -898,15 +902,19 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
private sealed class PendingAuditEvent
{
/// <summary>Initializes a new instance of the PendingAuditEvent class.</summary>
/// <param name="evt">The audit event to persist.</param>
public PendingAuditEvent(AuditEvent evt)
/// <param name="evt">The canonical audit event to persist.</param>
/// <param name="forwardState">Site-local forwarding state stored alongside the canonical row (C3 shim — not a canonical field).</param>
public PendingAuditEvent(AuditEvent evt, AuditForwardState forwardState)
{
Event = evt;
ForwardState = forwardState;
Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
/// <summary>The audit event to persist.</summary>
/// <summary>The canonical audit event to persist.</summary>
public AuditEvent Event { get; }
/// <summary>Site-local forwarding state for this row (C3 shim — bound to the ForwardState column).</summary>
public AuditForwardState ForwardState { get; }
/// <summary>Task completion source for write completion signaling.</summary>
public TaskCompletionSource Completion { get; }
}
@@ -1,8 +1,8 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
@@ -141,37 +141,33 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
var channel = ChannelStringToEnum(context.Channel);
return new CachedCallTelemetry(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
Channel = channel,
Kind = kind,
CorrelationId = context.TrackedOperationId.Value,
Audit: ScadaBridgeAuditEventFactory.Create(
channel: channel,
kind: kind,
status: status,
occurredAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
target: context.Target,
correlationId: context.TrackedOperationId.Value,
// Audit Log #23 (ExecutionId Task 4): the originating script
// execution's per-run correlation id, threaded through the S&F
// buffer; null on rows buffered before Task 4 (back-compat).
ExecutionId = context.ExecutionId,
executionId: context.ExecutionId,
// Audit Log #23 (ParentExecutionId Task 6): the spawning
// inbound-API request's ExecutionId, threaded through the S&F
// buffer alongside ExecutionId so the retry-loop cached rows
// correlate back to the cross-execution chain. Null for a
// non-routed run and on rows buffered before Task 6.
ParentExecutionId = context.ParentExecutionId,
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
SourceInstanceId = context.SourceInstanceId,
parentExecutionId: context.ParentExecutionId,
sourceSiteId: string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
sourceInstanceId: context.SourceInstanceId,
// Audit Log #23 (ExecutionId Task 4): SourceScript is now
// threaded through the S&F buffer alongside ExecutionId — the
// retry-loop cached rows carry the same provenance the
// script-side cached rows do. Null on pre-Task-4 buffered rows.
SourceScript = context.SourceScript,
Target = context.Target,
Status = status,
HttpStatus = httpStatus,
DurationMs = context.DurationMs,
ErrorMessage = lastError,
ForwardState = AuditForwardState.Pending,
},
sourceScript: context.SourceScript,
httpStatus: httpStatus,
durationMs: context.DurationMs,
errorMessage: lastError),
Operational: new SiteCallOperational(
TrackedOperationId: context.TrackedOperationId,
Channel: context.Channel,
@@ -1,9 +1,9 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
@@ -111,9 +111,11 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
// FallbackAuditWriter) handles transient writer failures upstream;
// a throw bubbling up here means the writer's own swallow contract
// failed, which is itself best-effort-handled.
// C3: Kind/Status are domain fields carried in DetailsJson — decompose to log them.
var d = AuditRowProjection.Decompose(telemetry.Audit);
_logger.LogWarning(ex,
"CachedCallTelemetryForwarder: audit emission threw for EventId {EventId} (Kind {Kind}, Status {Status})",
telemetry.Audit.EventId, telemetry.Audit.Kind, telemetry.Audit.Status);
d.EventId, d.Kind, d.Status);
}
}
@@ -128,9 +130,12 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
return;
}
// C3: the audit half's domain fields (Kind/SourceInstanceId/SourceScript)
// ride inside DetailsJson — decompose once for this packet.
var audit = AuditRowProjection.Decompose(telemetry.Audit);
try
{
switch (telemetry.Audit.Kind)
switch (audit.Kind)
{
case AuditKind.CachedSubmit:
// Enqueue — insert-if-not-exists with the operational
@@ -144,8 +149,8 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
telemetry.Operational.TrackedOperationId,
telemetry.Operational.Channel,
telemetry.Operational.Target,
telemetry.Audit.SourceInstanceId,
telemetry.Audit.SourceScript,
audit.SourceInstanceId,
audit.SourceScript,
sourceNode: _nodeIdentity?.NodeName,
ct).ConfigureAwait(false);
break;
@@ -180,7 +185,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
// forwarder.
_logger.LogWarning(
"CachedCallTelemetryForwarder: unexpected audit kind {Kind} on tracking emission for EventId {EventId}",
telemetry.Audit.Kind, telemetry.Audit.EventId);
audit.Kind, audit.EventId);
break;
}
}
@@ -1,5 +1,5 @@
using Akka.Actor;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
@@ -2,10 +2,11 @@ using Akka.Actor;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
@@ -259,8 +260,8 @@ public class SiteAuditTelemetryActor : ReceiveActor
// row stays Pending (still not in emittedEventIds) and
// central reconciliation will pick it up.
_logger.LogWarning(
"Cached-telemetry drain: audit row {EventId} ({Kind}) has no CorrelationId; skipping.",
auditRow.EventId, auditRow.Kind);
"Cached-telemetry drain: audit row {EventId} ({Action}) has no CorrelationId; skipping.",
auditRow.EventId, auditRow.Action);
continue;
}
@@ -363,10 +364,13 @@ public class SiteAuditTelemetryActor : ReceiveActor
private static CachedTelemetryPacket BuildCachedPacket(
AuditEvent auditRow, TrackingStatusSnapshot snapshot)
{
var sourceSite = auditRow.SourceSiteId ?? string.Empty;
// C3: SourceSiteId + Channel ride inside the canonical record's
// DetailsJson — decompose to read them.
var audit = AuditRowProjection.Decompose(auditRow);
var sourceSite = audit.SourceSiteId ?? string.Empty;
// Channel string form mirrors the AuditChannel-to-string convention used
// by SiteCallOperational + CachedCallLifecycleBridge.BuildPacket.
var channelString = auditRow.Channel.ToString();
var channelString = audit.Channel.ToString();
var target = auditRow.Target ?? snapshot.TargetSummary ?? string.Empty;
var operationalDto = new SiteCallOperationalDto
@@ -1,4 +1,4 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
@@ -1,11 +1,11 @@
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
/// Renders one <see cref="AuditEvent"/> in a right-side off-canvas drawer.
/// Renders one <see cref="AuditEventView"/> in a right-side off-canvas drawer.
/// The drawer owns only the offcanvas chrome — backdrop, header, and the two
/// Close buttons; the single-row detail body (read-only fields, conditional
/// Error/Request/Response/Extra subsections, and action buttons) is delegated
@@ -20,7 +20,7 @@ public partial class AuditDrilldownDrawer
/// The row to render. When null the drawer renders nothing — the host
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
/// </summary>
[Parameter] public AuditEvent? Event { get; set; }
[Parameter] public AuditEventView? Event { get; set; }
/// <summary>
/// True when the host wants the drawer visible. We deliberately keep
@@ -1,4 +1,4 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8).
@@ -3,7 +3,7 @@ using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
@@ -66,7 +66,7 @@ public partial class AuditEventDetail
/// The row to render. Required and non-null — the host (drawer or modal)
/// only mounts this component once it has a row to show.
/// </summary>
[Parameter, EditorRequired] public AuditEvent Event { get; set; } = null!;
[Parameter, EditorRequired] public AuditEventView Event { get; set; } = null!;
private const string RedactionSentinel = "<redacted>";
private const string RedactorErrorSentinel = "<redacted: redactor error>";
@@ -303,7 +303,7 @@ public partial class AuditEventDetail
/// outbound audit rows — the audit pipeline does not always capture
/// the verb explicitly.
/// </summary>
private static string BuildCurlCommand(AuditEvent ev)
private static string BuildCurlCommand(AuditEventView ev)
{
var sb = new StringBuilder();
sb.Append("curl");
@@ -1,6 +1,5 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@inject IAuditLogQueryService QueryService
@@ -103,7 +102,7 @@
return n.Length >= 8 ? n[..8] : n;
}
private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
private RenderFragment RenderCell(string key, AuditEventView row) => __builder =>
{
switch (key)
{
@@ -1,7 +1,7 @@
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -61,7 +61,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
private const string ColumnOrderStorageKey = "columnOrder";
private const string ColumnWidthsStorageKey = "columnWidths";
private readonly List<AuditEvent> _rows = new();
private readonly List<AuditEventView> _rows = new();
private int _pageNumber = 1;
private bool _loading;
private string? _error;
@@ -109,9 +109,9 @@ public partial class AuditResultsGrid : IAsyncDisposable
/// <summary>
/// Raised when the user clicks a row. Bundle C wires this to the drilldown
/// drawer. The event payload is the full <see cref="AuditEvent"/>.
/// drawer. The event payload is the full <see cref="AuditEventView"/>.
/// </summary>
[Parameter] public EventCallback<AuditEvent> OnRowSelected { get; set; }
[Parameter] public EventCallback<AuditEventView> OnRowSelected { get; set; }
// Effective page size used when paging. Mirrors PageSize but bounded > 0.
private int _pageSize => Math.Max(1, PageSize);
@@ -289,7 +289,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
}
}
private async Task HandleRowClick(AuditEvent row)
private async Task HandleRowClick(AuditEventView row)
{
if (OnRowSelected.HasDelegate)
{
@@ -1,4 +1,4 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@* Execution-Tree Node Detail Modal (Task 3).
Opened from an execution-tree node double-click. Given an ExecutionId it
@@ -2,7 +2,6 @@ using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -61,10 +60,10 @@ public partial class ExecutionDetailModal
[Parameter] public EventCallback OnClose { get; set; }
// The loaded rows for the current execution; empty until a load completes.
private IReadOnlyList<AuditEvent> _rows = Array.Empty<AuditEvent>();
private IReadOnlyList<AuditEventView> _rows = Array.Empty<AuditEventView>();
// The row whose detail is shown; null = list view.
private AuditEvent? _selectedRow;
private AuditEventView? _selectedRow;
private bool _loading;
private string? _error;
@@ -103,7 +102,7 @@ public partial class ExecutionDetailModal
_loading = true;
_error = null;
_selectedRow = null;
_rows = Array.Empty<AuditEvent>();
_rows = Array.Empty<AuditEventView>();
if (ExecutionId is null)
{
@@ -135,7 +134,7 @@ public partial class ExecutionDetailModal
// degrades the modal to an inline error banner rather than killing
// the SignalR circuit. Never rethrow.
_error = $"Could not load this execution's audit rows: {ex.Message}";
_rows = Array.Empty<AuditEvent>();
_rows = Array.Empty<AuditEventView>();
_selectedRow = null;
}
finally
@@ -144,7 +143,7 @@ public partial class ExecutionDetailModal
}
}
private void SelectRow(AuditEvent row) => _selectedRow = row;
private void SelectRow(AuditEventView row) => _selectedRow = row;
private void BackToList() => _selectedRow = null;
@@ -2,7 +2,6 @@
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@using ZB.MOM.WW.ScadaBridge.Security
@inject IAuditLogQueryService AuditLogQueryService
@@ -2,7 +2,7 @@ using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.WebUtilities;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -50,7 +50,7 @@ public partial class AuditLogPage : IDisposable
[Inject] private NavigationManager Navigation { get; set; } = null!;
private AuditLogQueryFilter? _currentFilter;
private AuditEvent? _selectedEvent;
private AuditEventView? _selectedEvent;
private bool _drawerOpen;
private string? _initialInstanceSearch;
@@ -222,7 +222,7 @@ public partial class AuditLogPage : IDisposable
_currentFilter = filter;
}
private void HandleRowSelected(AuditEvent row)
private void HandleRowSelected(AuditEventView row)
{
// Bundle C: a grid row click hands us the full AuditEvent. We pin it as
// the selected row and open the drilldown drawer — the drawer is fully
@@ -0,0 +1,104 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Flattened, typed view of a canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/> for the
/// Central UI audit pages. C3 (Task 2.5) made the canonical record the seam type — the
/// query service decomposes it into this view (via <see cref="AuditRowProjection"/>) so the
/// existing razor bindings (<c>row.Channel</c>, <c>Event.Status</c>, <c>evt.RequestSummary</c>,
/// …) keep working against typed properties rather than parsing <c>DetailsJson</c> inline.
/// </summary>
/// <remarks>
/// This is presentation-only: it carries the same field surface the bespoke
/// <c>Commons.Entities.Audit.AuditEvent</c> exposed before C3. <c>ForwardState</c> is always
/// null on the central read path (it is site-storage-only and not carried on canonical rows).
/// </remarks>
public sealed record AuditEventView
{
/// <summary>Idempotency key.</summary>
public Guid EventId { get; init; }
/// <summary>UTC timestamp when the audited action occurred.</summary>
public DateTime OccurredAtUtc { get; init; }
/// <summary>UTC ingest timestamp (central-set); null until ingest.</summary>
public DateTime? IngestedAtUtc { get; init; }
/// <summary>Trust-boundary channel.</summary>
public AuditChannel Channel { get; init; }
/// <summary>Specific event kind.</summary>
public AuditKind Kind { get; init; }
/// <summary>Per-operation correlation id.</summary>
public Guid? CorrelationId { get; init; }
/// <summary>Originating execution id.</summary>
public Guid? ExecutionId { get; init; }
/// <summary>Spawning execution id; null for top-level runs.</summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site id where the action originated.</summary>
public string? SourceSiteId { get; init; }
/// <summary>Cluster node that emitted the event.</summary>
public string? SourceNode { get; init; }
/// <summary>Instance id where the action originated.</summary>
public string? SourceInstanceId { get; init; }
/// <summary>Script that initiated the action.</summary>
public string? SourceScript { get; init; }
/// <summary>Authenticated actor.</summary>
public string? Actor { get; init; }
/// <summary>Target of the action.</summary>
public string? Target { get; init; }
/// <summary>Lifecycle status.</summary>
public AuditStatus Status { get; init; }
/// <summary>HTTP status code where applicable.</summary>
public int? HttpStatus { get; init; }
/// <summary>Duration of the action in ms.</summary>
public int? DurationMs { get; init; }
/// <summary>Human-readable error summary.</summary>
public string? ErrorMessage { get; init; }
/// <summary>Verbose error detail.</summary>
public string? ErrorDetail { get; init; }
/// <summary>Truncated/redacted request summary.</summary>
public string? RequestSummary { get; init; }
/// <summary>Truncated/redacted response summary.</summary>
public string? ResponseSummary { get; init; }
/// <summary>True when summaries were truncated.</summary>
public bool PayloadTruncated { get; init; }
/// <summary>Free-form JSON extension.</summary>
public string? Extra { get; init; }
/// <summary>Site-local forwarding state; always null on the central read path.</summary>
public AuditForwardState? ForwardState { get; init; }
/// <summary>
/// Decomposes a canonical <see cref="AuditEvent"/> into a flat view for the UI.
/// </summary>
public static AuditEventView From(AuditEvent evt)
{
var r = AuditRowProjection.Decompose(evt);
return new AuditEventView
{
EventId = r.EventId,
OccurredAtUtc = r.OccurredAtUtc,
IngestedAtUtc = r.IngestedAtUtc,
Channel = r.Channel,
Kind = r.Kind,
CorrelationId = r.CorrelationId,
ExecutionId = r.ExecutionId,
ParentExecutionId = r.ParentExecutionId,
SourceSiteId = r.SourceSiteId,
SourceNode = r.SourceNode,
SourceInstanceId = r.SourceInstanceId,
SourceScript = r.SourceScript,
Actor = r.Actor,
Target = r.Target,
Status = r.Status,
HttpStatus = r.HttpStatus,
DurationMs = r.DurationMs,
ErrorMessage = r.ErrorMessage,
ErrorDetail = r.ErrorDetail,
RequestSummary = r.RequestSummary,
ResponseSummary = r.ResponseSummary,
PayloadTruncated = r.PayloadTruncated,
Extra = r.Extra,
ForwardState = null,
};
}
}
@@ -1,6 +1,5 @@
using System.Globalization;
using System.Text;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -121,7 +120,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
{
break;
}
await writer.WriteLineAsync(FormatCsvRow(evt));
await writer.WriteLineAsync(FormatCsvRow(AuditEventView.From(evt)));
written++;
}
@@ -140,7 +139,9 @@ public sealed class AuditLogExportService : IAuditLogExportService
var last = page[^1];
cursor = new AuditLogPaging(
PageSize: pageSize,
AfterOccurredAtUtc: last.OccurredAtUtc,
// C3: canonical OccurredAtUtc is a DateTimeOffset; the keyset
// cursor column is a UTC DateTime.
AfterOccurredAtUtc: last.OccurredAtUtc.UtcDateTime,
AfterEventId: last.EventId);
}
@@ -169,13 +170,13 @@ public sealed class AuditLogExportService : IAuditLogExportService
"ResponseSummary,PayloadTruncated,Extra,ForwardState";
/// <summary>
/// Serialises one <see cref="AuditEvent"/> as a CSV row (no trailing newline).
/// Serialises one <see cref="AuditEventView"/> as a CSV row (no trailing newline).
/// Each nullable column renders as the empty string when null; non-null
/// scalars use invariant culture so an export taken on one locale parses
/// cleanly on another.
/// </summary>
/// <param name="evt">The audit event to format as a CSV row.</param>
internal static string FormatCsvRow(AuditEvent evt)
/// <param name="evt">The audit event view to format as a CSV row.</param>
internal static string FormatCsvRow(AuditEventView evt)
{
var sb = new StringBuilder(256);
AppendField(sb, evt.EventId.ToString(), first: true);
@@ -1,5 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -93,7 +92,7 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
public int DefaultPageSize => 100;
/// <inheritdoc />
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
public async Task<IReadOnlyList<AuditEventView>> QueryAsync(
AuditLogQueryFilter filter,
AuditLogPaging? paging = null,
CancellationToken ct = default)
@@ -101,17 +100,22 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
ArgumentNullException.ThrowIfNull(filter);
var effective = paging ?? new AuditLogPaging(DefaultPageSize);
// C3 (Task 2.5): the repository seam returns canonical records; decompose
// each into a flat AuditEventView so the audit pages keep binding to typed
// properties.
// Test-seam ctor: use the injected repository directly.
if (_injectedRepository is not null)
{
return await _injectedRepository.QueryAsync(filter, effective, ct);
var rows = await _injectedRepository.QueryAsync(filter, effective, ct);
return rows.Select(AuditEventView.From).ToList();
}
// Production: a fresh scope (and thus a fresh DbContext) per query so the
// page's auto-load never shares the circuit-scoped context.
await using var scope = _scopeFactory!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
return await repository.QueryAsync(filter, effective, ct);
var result = await repository.QueryAsync(filter, effective, ct);
return result.Select(AuditEventView.From).ToList();
}
/// <inheritdoc/>
@@ -1,4 +1,3 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -18,13 +17,18 @@ public interface IAuditLogQueryService
/// <paramref name="paging"/> is <c>null</c>, defaults to <see cref="DefaultPageSize"/>
/// rows with no cursor (first page). The repository orders by
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>; pass the last row's
/// <see cref="AuditEvent.OccurredAtUtc"/> + <see cref="AuditEvent.EventId"/>
/// <see cref="AuditEventView.OccurredAtUtc"/> + <see cref="AuditEventView.EventId"/>
/// back as the cursor for the next page.
/// </summary>
/// <remarks>
/// C3 (Task 2.5): the repository seam returns the canonical
/// <c>ZB.MOM.WW.Audit.AuditEvent</c>; this facade decomposes each row into a flat
/// <see cref="AuditEventView"/> so the audit pages keep binding to typed properties.
/// </remarks>
/// <param name="filter">Filter criteria applied to the audit log query.</param>
/// <param name="paging">Optional paging cursor; defaults to first page when null.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<AuditEvent>> QueryAsync(
Task<IReadOnlyList<AuditEventView>> QueryAsync(
AuditLogQueryFilter filter,
AuditLogPaging? paging = null,
CancellationToken ct = default);
@@ -1,137 +0,0 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
/// <summary>
/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
/// site rows leave IngestedAtUtc null until ingest. Append-only.
/// </summary>
/// <remarks>
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties on this record are
/// invariantly UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
/// Their init-setters call <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>
/// to force <see cref="DateTimeKind.Utc"/> on assignment, so a value built from a
/// <c>DateTime</c> literal or re-hydrated from a SQL Server <c>datetime2</c> column
/// (which strips the <c>Kind</c> flag on the wire) cannot leak downstream as
/// <see cref="DateTimeKind.Unspecified"/> or be silently re-interpreted as local
/// time. The unrelated <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications"/>
/// surface uses <see cref="DateTimeOffset"/> for the same UTC guarantee; this
/// entity stays on <see cref="DateTime"/> to match the partitioned SQL Server
/// <c>datetime2</c> column shape required by the AuditLog table.
/// </remarks>
public sealed record AuditEvent
{
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
public Guid EventId { get; init; }
/// <summary>
/// UTC timestamp when the audited action occurred at its source. The value
/// MUST be in UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
/// The init-setter forces <see cref="DateTimeKind.Utc"/> on assignment via
/// <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>, so any
/// construction path that supplies a value with <see cref="DateTimeKind.Unspecified"/>
/// (e.g. a <c>DateTime</c> literal, JSON deserialisation, or a SQL Server
/// <c>datetime2</c> read where the value bypassed the EF converter) is
/// re-tagged as UTC rather than treated as local time downstream. Producers
/// are still expected to supply values that ARE genuinely UTC — the setter
/// only fixes the <c>Kind</c> flag, it cannot re-interpret a local-time value.
/// </summary>
public DateTime OccurredAtUtc
{
get => _occurredAtUtc;
init => _occurredAtUtc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
private readonly DateTime _occurredAtUtc;
/// <summary>
/// UTC timestamp when the row was ingested at central; null on the site hot-path.
/// The value MUST be in UTC when non-null; the init-setter forces
/// <see cref="DateTimeKind.Utc"/> on assignment, matching
/// <see cref="OccurredAtUtc"/>'s contract.
/// </summary>
public DateTime? IngestedAtUtc
{
get => _ingestedAtUtc;
init => _ingestedAtUtc = value.HasValue
? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
: null;
}
private readonly DateTime? _ingestedAtUtc;
/// <summary>Trust-boundary channel the audited action crossed.</summary>
public AuditChannel Channel { get; init; }
/// <summary>Specific event kind within the channel (see alog.md §4).</summary>
public AuditKind Kind { get; init; }
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
public Guid? CorrelationId { get; init; }
/// <summary>
/// Id of the originating script execution / inbound request — the universal
/// per-run correlation value, distinct from <see cref="CorrelationId"/> (which
/// is the per-operation lifecycle id).
/// </summary>
public Guid? ExecutionId { get; init; }
/// <summary>
/// <see cref="ExecutionId"/> of the execution that spawned this run, when this
/// run was spawned by another; null for top-level runs. Lets a spawned
/// execution point back at its spawner for cross-run correlation.
/// </summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; }
/// <summary>
/// The cluster node on which the event was emitted — `node-a` / `node-b` for
/// site rows (qualified by <see cref="SourceSiteId"/>), `central-a` / `central-b`
/// for central-originated rows. Stamped by the writing node from
/// <c>INodeIdentityProvider</c>; nullable so reconciled rows from a node that
/// has since been retired don't block ingest.
/// </summary>
public string? SourceNode { get; init; }
/// <summary>Instance id where the action originated, when applicable.</summary>
public string? SourceInstanceId { get; init; }
/// <summary>Script that initiated the action (script trust boundary), when applicable.</summary>
public string? SourceScript { get; init; }
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
public string? Actor { get; init; }
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
public string? Target { get; init; }
/// <summary>Lifecycle status of this row.</summary>
public AuditStatus Status { get; init; }
/// <summary>HTTP status code where applicable (outbound API + inbound API).</summary>
public int? HttpStatus { get; init; }
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
public int? DurationMs { get; init; }
/// <summary>Human-readable error summary on failure rows.</summary>
public string? ErrorMessage { get; init; }
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
public string? ErrorDetail { get; init; }
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
public string? RequestSummary { get; init; }
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
public string? ResponseSummary { get; init; }
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
public bool PayloadTruncated { get; init; }
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
public string? Extra { get; init; }
/// <summary>Site-local forwarding state; null on central rows.</summary>
public AuditForwardState? ForwardState { get; init; }
}
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -7,6 +7,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// Implementations on the site write to local SQLite hot-path; on central they write to MS SQL directly.
/// Failures must NEVER abort the user-facing action.
/// </summary>
/// <remarks>
/// C3 (Task 2.5): the event type is the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/>.
/// The local seam is retained (rather than collapsed onto <c>ZB.MOM.WW.Audit.IAuditWriter</c>)
/// so it stays a distinct DI binding from <see cref="ICentralAuditWriter"/> and so the many
/// existing site/central implementations and test fakes keep their identity.
/// </remarks>
public interface IAuditWriter
{
/// <summary>
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -34,7 +34,7 @@ public interface ISiteAuditQueue
/// <see cref="MarkForwardedAsync"/> will yield the same rows again.
/// </summary>
/// <remarks>
/// AuditLog-001: cached-lifecycle <see cref="AuditEvent.Kind"/>s
/// AuditLog-001: cached-lifecycle audit kinds
/// (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
@@ -52,7 +52,7 @@ public interface ISiteAuditQueue
/// <summary>
/// AuditLog-001: returns up to <paramref name="limit"/> rows in
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/>
/// whose <see cref="AuditEvent.Kind"/> belongs to the cached-call lifecycle
/// whose audit kind belongs to the cached-call lifecycle
/// vocabulary (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
@@ -1,3 +1,4 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
@@ -0,0 +1,193 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Transitional canonical ⇄ 24-column shim for the two AuditLog storage
/// implementations (site SQLite, central SQL Server). C3 keeps the existing
/// 24-column tables UNCHANGED; this helper decomposes a canonical
/// <see cref="ZB.MOM.WW.Audit.AuditEvent"/> into the typed domain values the
/// columns expect (Channel/Kind/Status enums + the <see cref="AuditDetails"/>
/// fields) and recomposes a canonical record from those column values.
/// </summary>
/// <remarks>
/// <para>
/// C3 of the ScadaBridge audit re-architecture (Task 2.5). The canonical record
/// only carries Action/Category/Outcome at the top level and stashes every
/// ScadaBridge domain field inside <c>DetailsJson</c>; the legacy storage rows
/// carry the domain fields as typed columns. This shim bridges the two without
/// any schema change. C4 replaces the site shim with the real DetailsJson
/// schema; C5 the central one.
/// </para>
/// <para>
/// <c>ForwardState</c> is deliberately NOT part of this projection — it is a
/// site-storage-only concern carried alongside the canonical record by the site
/// SQLite writer, never inside <c>DetailsJson</c> and never on a central row.
/// </para>
/// </remarks>
public static class AuditRowProjection
{
/// <summary>
/// The decomposed domain view of a canonical <see cref="AuditEvent"/> — the
/// values the 24 storage columns expect. Built by <see cref="Decompose"/> from
/// the canonical top-level fields plus the <see cref="AuditDetails"/> bag.
/// </summary>
public readonly record struct AuditRowValues(
Guid EventId,
DateTime OccurredAtUtc,
DateTime? IngestedAtUtc,
AuditChannel Channel,
AuditKind Kind,
AuditStatus Status,
Guid? CorrelationId,
Guid? ExecutionId,
Guid? ParentExecutionId,
string? SourceSiteId,
string? SourceNode,
string? SourceInstanceId,
string? SourceScript,
string? Actor,
string? Target,
int? HttpStatus,
int? DurationMs,
string? ErrorMessage,
string? ErrorDetail,
string? RequestSummary,
string? ResponseSummary,
bool PayloadTruncated,
string? Extra);
/// <summary>
/// Decomposes a canonical record into the typed column values. Channel/Kind/Status
/// come from <c>DetailsJson</c> (the strings written by
/// <see cref="ScadaBridgeAuditEventFactory"/>); a missing/unparseable discriminator
/// falls back to the first enum member (defensive — production rows always carry them).
/// </summary>
public static AuditRowValues Decompose(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
var channel = ParseEnum(d.Channel, AuditChannel.ApiInbound);
var kind = ParseEnum(d.Kind, AuditKind.InboundRequest);
var status = ParseEnum(d.Status, AuditStatus.Submitted);
// The canonical OccurredAtUtc is UTC by construction; columns store a
// Kind=Utc DateTime so downstream UTC/local conversions are safe
// (CLAUDE.md: "All timestamps are UTC throughout the system.").
var occurred = DateTime.SpecifyKind(evt.OccurredAtUtc.UtcDateTime, DateTimeKind.Utc);
DateTime? ingested = d.IngestedAtUtc.HasValue
? DateTime.SpecifyKind(d.IngestedAtUtc.Value.UtcDateTime, DateTimeKind.Utc)
: null;
return new AuditRowValues(
EventId: evt.EventId,
OccurredAtUtc: occurred,
IngestedAtUtc: ingested,
Channel: channel,
Kind: kind,
Status: status,
CorrelationId: evt.CorrelationId,
ExecutionId: d.ExecutionId,
ParentExecutionId: d.ParentExecutionId,
SourceSiteId: d.SourceSiteId,
SourceNode: evt.SourceNode,
SourceInstanceId: d.SourceInstanceId,
SourceScript: d.SourceScript,
// Canonical Actor is a required non-null string; an empty Actor maps
// back to a NULL column (legacy rows stored null for system/anon).
Actor: string.IsNullOrEmpty(evt.Actor) ? null : evt.Actor,
Target: evt.Target,
HttpStatus: d.HttpStatus,
DurationMs: d.DurationMs,
ErrorMessage: d.ErrorMessage,
ErrorDetail: d.ErrorDetail,
RequestSummary: d.RequestSummary,
ResponseSummary: d.ResponseSummary,
PayloadTruncated: d.PayloadTruncated,
Extra: d.Extra);
}
/// <summary>
/// Recomposes a canonical <see cref="AuditEvent"/> from the typed column values read
/// back from storage. The inverse of <see cref="Decompose"/>: Action/Category/Outcome
/// are rebuilt via the field builders / outcome projector, and every domain field is
/// re-serialized into <c>DetailsJson</c> via <see cref="AuditDetailsCodec"/>.
/// </summary>
public static AuditEvent Recompose(in AuditRowValues v)
{
var details = new AuditDetails
{
Channel = v.Channel.ToString(),
Kind = v.Kind.ToString(),
Status = v.Status.ToString(),
ExecutionId = v.ExecutionId,
ParentExecutionId = v.ParentExecutionId,
SourceSiteId = v.SourceSiteId,
SourceInstanceId = v.SourceInstanceId,
SourceScript = v.SourceScript,
HttpStatus = v.HttpStatus,
DurationMs = v.DurationMs,
ErrorMessage = v.ErrorMessage,
ErrorDetail = v.ErrorDetail,
RequestSummary = v.RequestSummary,
ResponseSummary = v.ResponseSummary,
PayloadTruncated = v.PayloadTruncated,
Extra = v.Extra,
IngestedAtUtc = v.IngestedAtUtc.HasValue
? new DateTimeOffset(DateTime.SpecifyKind(v.IngestedAtUtc.Value, DateTimeKind.Utc))
: null,
};
return new AuditEvent
{
EventId = v.EventId,
OccurredAtUtc = new DateTimeOffset(
DateTime.SpecifyKind(v.OccurredAtUtc, DateTimeKind.Utc)),
Actor = v.Actor ?? string.Empty,
Action = AuditFieldBuilders.BuildAction(v.Channel, v.Kind),
Category = AuditFieldBuilders.BuildCategory(v.Channel),
Outcome = AuditOutcomeProjector.Project(v.Status, v.Kind),
Target = v.Target,
SourceNode = v.SourceNode,
CorrelationId = v.CorrelationId,
DetailsJson = AuditDetailsCodec.Serialize(details),
};
}
/// <summary>
/// Returns a copy of <paramref name="evt"/> with the central-side ingest timestamp
/// stamped into its <c>DetailsJson</c> (<see cref="AuditDetails.IngestedAtUtc"/>).
/// C3 transitional shim: <c>IngestedAtUtc</c> is a DetailsJson field on the canonical
/// record, so the central ingest paths stamp it here rather than on a top-level
/// property as the legacy bespoke record allowed.
/// </summary>
public static AuditEvent WithIngestedAtUtc(AuditEvent evt, DateTimeOffset ingestedAtUtc)
{
ArgumentNullException.ThrowIfNull(evt);
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson) with
{
IngestedAtUtc = ingestedAtUtc.ToUniversalTime(),
};
return evt with { DetailsJson = AuditDetailsCodec.Serialize(d) };
}
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct, Enum
=> !string.IsNullOrEmpty(value) && Enum.TryParse<TEnum>(value, ignoreCase: false, out var parsed)
? parsed
: fallback;
}
/// <summary>
/// Convenience extension that decomposes a canonical <see cref="AuditEvent"/> into its
/// typed 24-field <see cref="AuditRowProjection.AuditRowValues"/> view. Lets callers
/// (and tests) read the ScadaBridge domain fields — Channel/Kind/Status + the DetailsJson
/// fields — as typed properties off a canonical row.
/// </summary>
public static class AuditEventRowExtensions
{
/// <summary>Decomposes this canonical record into its typed 24-field view.</summary>
public static AuditRowProjection.AuditRowValues AsRow(this AuditEvent evt)
=> AuditRowProjection.Decompose(evt);
}
@@ -0,0 +1,125 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Single construction point for the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/>
/// from ScadaBridge's domain vocabulary. Every emit site builds its row through
/// <see cref="Create"/> so the canonical-field mapping (Channel/Kind/Status →
/// Action/Category/Outcome, every other domain field → <see cref="AuditDetails"/>
/// inside <see cref="ZB.MOM.WW.Audit.AuditEvent.DetailsJson"/>) is applied
/// identically everywhere — no per-site drift.
/// </summary>
/// <remarks>
/// <para>C3 of the ScadaBridge audit re-architecture (Task 2.5). The canonical
/// record is the type at every seam, emit site, DTO boundary, and redactor; the
/// ScadaBridge domain fields ride in <c>DetailsJson</c> via
/// <see cref="AuditDetailsCodec"/>.</para>
/// <para>Mapping (see Task 2.5 spec):
/// <list type="bullet">
/// <item><c>Action</c> = <see cref="AuditFieldBuilders.BuildAction"/>(channel, kind).</item>
/// <item><c>Category</c> = <see cref="AuditFieldBuilders.BuildCategory"/>(channel) (= channel name).</item>
/// <item><c>Outcome</c> = <see cref="AuditOutcomeProjector.Project"/>(status, kind).</item>
/// <item><c>DetailsJson</c> carries Channel/Kind/Status (as strings) + every other
/// ScadaBridge domain field. <c>ForwardState</c> is NOT a DetailsJson field — it is
/// a site-storage-only concern handled by the site SQLite shim.</item>
/// </list>
/// </para>
/// </remarks>
public static class ScadaBridgeAuditEventFactory
{
/// <summary>
/// Builds the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/> for one ScadaBridge
/// audit row. <paramref name="channel"/>/<paramref name="kind"/>/<paramref name="status"/>
/// drive the canonical Action/Category/Outcome and are also recorded (as strings) in
/// <c>DetailsJson</c>; all remaining ScadaBridge domain fields are carried in
/// <c>DetailsJson</c> too.
/// </summary>
/// <param name="channel">Trust-boundary channel the audited action crossed.</param>
/// <param name="kind">Specific event kind within the channel.</param>
/// <param name="status">Lifecycle status of this row.</param>
/// <param name="eventId">Idempotency key. Defaults to a fresh <see cref="Guid"/> when omitted.</param>
/// <param name="occurredAtUtc">When the action occurred (UTC). Defaults to <see cref="DateTime.UtcNow"/> when omitted.</param>
/// <param name="actor">Authenticated actor for inbound paths (API key name, user, "system", etc.).</param>
/// <param name="target">Target of the action (external system, db connection, list name, inbound method).</param>
/// <param name="sourceNode">Cluster node that emitted the event (top-level canonical field).</param>
/// <param name="correlationId">Per-operation lifecycle correlation id (top-level canonical field).</param>
/// <param name="executionId">Originating script-execution / inbound-request id (DetailsJson).</param>
/// <param name="parentExecutionId">Spawning execution's id (DetailsJson).</param>
/// <param name="sourceSiteId">Site id where the action originated (DetailsJson).</param>
/// <param name="sourceInstanceId">Instance id where the action originated (DetailsJson).</param>
/// <param name="sourceScript">Script that initiated the action (DetailsJson).</param>
/// <param name="httpStatus">HTTP status code where applicable (DetailsJson).</param>
/// <param name="durationMs">Duration of the audited action in ms (DetailsJson).</param>
/// <param name="errorMessage">Human-readable error summary on failure rows (DetailsJson).</param>
/// <param name="errorDetail">Verbose error detail (stack/exception) on failure rows (DetailsJson).</param>
/// <param name="requestSummary">Truncated/redacted request summary (DetailsJson).</param>
/// <param name="responseSummary">Truncated/redacted response summary (DetailsJson).</param>
/// <param name="payloadTruncated">True when summaries were truncated to the payload cap (DetailsJson).</param>
/// <param name="extra">Free-form JSON extension for channel-specific extras (DetailsJson).</param>
/// <param name="ingestedAtUtc">UTC ingest timestamp (central-set; DetailsJson).</param>
public static AuditEvent Create(
AuditChannel channel,
AuditKind kind,
AuditStatus status,
Guid? eventId = null,
DateTime? occurredAtUtc = null,
string? actor = null,
string? target = null,
string? sourceNode = null,
Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
string? sourceSiteId = null,
string? sourceInstanceId = null,
string? sourceScript = null,
int? httpStatus = null,
int? durationMs = null,
string? errorMessage = null,
string? errorDetail = null,
string? requestSummary = null,
string? responseSummary = null,
bool payloadTruncated = false,
string? extra = null,
DateTimeOffset? ingestedAtUtc = null)
{
var details = new AuditDetails
{
Channel = channel.ToString(),
Kind = kind.ToString(),
Status = status.ToString(),
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
SourceSiteId = sourceSiteId,
SourceInstanceId = sourceInstanceId,
SourceScript = sourceScript,
HttpStatus = httpStatus,
DurationMs = durationMs,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
RequestSummary = requestSummary,
ResponseSummary = responseSummary,
PayloadTruncated = payloadTruncated,
Extra = extra,
IngestedAtUtc = ingestedAtUtc,
};
return new AuditEvent
{
EventId = eventId ?? Guid.NewGuid(),
// DateTimeOffset assumes UTC when the source DateTime is Unspecified/Utc;
// every ScadaBridge OccurredAt value is UTC by contract.
OccurredAtUtc = new DateTimeOffset(
DateTime.SpecifyKind(occurredAtUtc ?? DateTime.UtcNow, DateTimeKind.Utc)),
Actor = actor ?? string.Empty,
Action = AuditFieldBuilders.BuildAction(channel, kind),
Category = AuditFieldBuilders.BuildCategory(channel),
Outcome = AuditOutcomeProjector.Project(status, kind),
Target = target,
SourceNode = sourceNode,
CorrelationId = correlationId,
DetailsJson = AuditDetailsCodec.Serialize(details),
};
}
}
@@ -1,4 +1,5 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
@@ -41,38 +42,44 @@ public static class AuditEventDtoMapper
{
ArgumentNullException.ThrowIfNull(evt);
// C3 (Task 2.5): the proto contract is the UNCHANGED 24-field wire. The
// canonical record carries the ScadaBridge domain fields inside
// DetailsJson — decompose them so the DTO's typed domain fields are
// populated exactly as before.
var r = AuditRowProjection.Decompose(evt);
var dto = new AuditEventDto
{
EventId = evt.EventId.ToString(),
OccurredAtUtc = Timestamp.FromDateTime(EnsureUtc(evt.OccurredAtUtc)),
Channel = evt.Channel.ToString(),
Kind = evt.Kind.ToString(),
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty,
ParentExecutionId = evt.ParentExecutionId?.ToString() ?? string.Empty,
SourceSiteId = evt.SourceSiteId ?? string.Empty,
SourceNode = evt.SourceNode ?? string.Empty,
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
SourceScript = evt.SourceScript ?? string.Empty,
Actor = evt.Actor ?? string.Empty,
Target = evt.Target ?? string.Empty,
Status = evt.Status.ToString(),
ErrorMessage = evt.ErrorMessage ?? string.Empty,
ErrorDetail = evt.ErrorDetail ?? string.Empty,
RequestSummary = evt.RequestSummary ?? string.Empty,
ResponseSummary = evt.ResponseSummary ?? string.Empty,
PayloadTruncated = evt.PayloadTruncated,
Extra = evt.Extra ?? string.Empty
EventId = r.EventId.ToString(),
OccurredAtUtc = Timestamp.FromDateTime(EnsureUtc(r.OccurredAtUtc)),
Channel = r.Channel.ToString(),
Kind = r.Kind.ToString(),
CorrelationId = r.CorrelationId?.ToString() ?? string.Empty,
ExecutionId = r.ExecutionId?.ToString() ?? string.Empty,
ParentExecutionId = r.ParentExecutionId?.ToString() ?? string.Empty,
SourceSiteId = r.SourceSiteId ?? string.Empty,
SourceNode = r.SourceNode ?? string.Empty,
SourceInstanceId = r.SourceInstanceId ?? string.Empty,
SourceScript = r.SourceScript ?? string.Empty,
Actor = r.Actor ?? string.Empty,
Target = r.Target ?? string.Empty,
Status = r.Status.ToString(),
ErrorMessage = r.ErrorMessage ?? string.Empty,
ErrorDetail = r.ErrorDetail ?? string.Empty,
RequestSummary = r.RequestSummary ?? string.Empty,
ResponseSummary = r.ResponseSummary ?? string.Empty,
PayloadTruncated = r.PayloadTruncated,
Extra = r.Extra ?? string.Empty
};
if (evt.HttpStatus.HasValue)
if (r.HttpStatus.HasValue)
{
dto.HttpStatus = evt.HttpStatus.Value;
dto.HttpStatus = r.HttpStatus.Value;
}
if (evt.DurationMs.HasValue)
if (r.DurationMs.HasValue)
{
dto.DurationMs = evt.DurationMs.Value;
dto.DurationMs = r.DurationMs.Value;
}
return dto;
@@ -89,33 +96,35 @@ public static class AuditEventDtoMapper
{
ArgumentNullException.ThrowIfNull(dto);
return new AuditEvent
{
EventId = Guid.Parse(dto.EventId),
OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
IngestedAtUtc = null,
Channel = Enum.Parse<AuditChannel>(dto.Channel),
Kind = Enum.Parse<AuditKind>(dto.Kind),
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
ParentExecutionId = NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null,
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
SourceNode = NullIfEmpty(dto.SourceNode),
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
SourceScript = NullIfEmpty(dto.SourceScript),
Actor = NullIfEmpty(dto.Actor),
Target = NullIfEmpty(dto.Target),
Status = Enum.Parse<AuditStatus>(dto.Status),
HttpStatus = dto.HttpStatus,
DurationMs = dto.DurationMs,
ErrorMessage = NullIfEmpty(dto.ErrorMessage),
ErrorDetail = NullIfEmpty(dto.ErrorDetail),
RequestSummary = NullIfEmpty(dto.RequestSummary),
ResponseSummary = NullIfEmpty(dto.ResponseSummary),
PayloadTruncated = dto.PayloadTruncated,
Extra = NullIfEmpty(dto.Extra),
ForwardState = null
};
// C3 (Task 2.5): recompose the canonical record from the 24-field wire
// DTO. The domain fields are re-serialized into DetailsJson via the
// projection helper; IngestedAtUtc is left null (central sets it at
// ingest) and ForwardState is dropped (site-storage-only, never on the
// wire).
return AuditRowProjection.Recompose(new AuditRowProjection.AuditRowValues(
EventId: Guid.Parse(dto.EventId),
OccurredAtUtc: DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
IngestedAtUtc: null,
Channel: Enum.Parse<AuditChannel>(dto.Channel),
Kind: Enum.Parse<AuditKind>(dto.Kind),
Status: Enum.Parse<AuditStatus>(dto.Status),
CorrelationId: NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
ExecutionId: NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
ParentExecutionId: NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null,
SourceSiteId: NullIfEmpty(dto.SourceSiteId),
SourceNode: NullIfEmpty(dto.SourceNode),
SourceInstanceId: NullIfEmpty(dto.SourceInstanceId),
SourceScript: NullIfEmpty(dto.SourceScript),
Actor: NullIfEmpty(dto.Actor),
Target: NullIfEmpty(dto.Target),
HttpStatus: dto.HttpStatus,
DurationMs: dto.DurationMs,
ErrorMessage: NullIfEmpty(dto.ErrorMessage),
ErrorDetail: NullIfEmpty(dto.ErrorDetail),
RequestSummary: NullIfEmpty(dto.RequestSummary),
ResponseSummary: NullIfEmpty(dto.ResponseSummary),
PayloadTruncated: dto.PayloadTruncated,
Extra: NullIfEmpty(dto.Extra)));
}
private static string? NullIfEmpty(string? value) =>
@@ -4,7 +4,7 @@ using Akka.Actor;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
@@ -1,16 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
/// <summary>
/// Maps the <see cref="AuditEvent"/> record to the central <c>AuditLog</c> table
/// described in alog.md §4. Column lengths/types and the five named indexes are
/// fixed by that specification — keep this in sync with the doc.
/// Maps the <see cref="AuditLogRow"/> persistence shape to the central <c>AuditLog</c>
/// table described in alog.md §4. Column lengths/types and the named indexes are
/// fixed by that specification — keep this in sync with the doc. C3 (Task 2.5) kept
/// the table unchanged; the canonical record is mapped onto this row at the repository
/// boundary via <c>AuditRowProjection</c>.
/// </summary>
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEvent>
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLogRow>
{
// SQL Server's datetime2 provider strips the DateTimeKind flag on the wire
// (a column hydrated from the database always surfaces as
@@ -33,9 +35,9 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
: null,
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
/// <summary>Applies the EF Core type configuration for <see cref="AuditEvent"/> to the model builder.</summary>
/// <summary>Applies the EF Core type configuration for <see cref="AuditLogRow"/> to the model builder.</summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<AuditEvent> builder)
public void Configure(EntityTypeBuilder<AuditLogRow> builder)
{
builder.ToTable("AuditLog");
@@ -0,0 +1,113 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
/// <summary>
/// Transitional EF Core persistence shape for the central <c>dbo.AuditLog</c> table
/// (Audit Log #23). This is the 24-column row formerly modelled by
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent</c>; in C3 (Task 2.5)
/// the canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> became the type at every seam,
/// emit site, DTO boundary, and redactor, and this row type was relocated here as a
/// storage-only entity so the existing table keeps working unchanged.
/// </summary>
/// <remarks>
/// <para>
/// The repository maps canonical ⇄ this row at the persistence boundary via
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.AuditRowProjection</c>. C5 replaces
/// this shim + table with the real DetailsJson-backed schema.
/// </para>
/// <para>
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties are invariantly UTC
/// (CLAUDE.md: "All timestamps are UTC throughout the system."). The init-setters
/// force <see cref="DateTimeKind.Utc"/> on assignment so a value re-hydrated from a
/// SQL Server <c>datetime2</c> column (which strips the <c>Kind</c> flag on the wire)
/// cannot leak downstream as <see cref="DateTimeKind.Unspecified"/> or be silently
/// re-interpreted as local time.
/// </para>
/// </remarks>
public sealed record AuditLogRow
{
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
public Guid EventId { get; init; }
/// <summary>UTC timestamp when the audited action occurred at its source.</summary>
public DateTime OccurredAtUtc
{
get => _occurredAtUtc;
init => _occurredAtUtc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
private readonly DateTime _occurredAtUtc;
/// <summary>UTC timestamp when the row was ingested at central; null on the site hot-path.</summary>
public DateTime? IngestedAtUtc
{
get => _ingestedAtUtc;
init => _ingestedAtUtc = value.HasValue
? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
: null;
}
private readonly DateTime? _ingestedAtUtc;
/// <summary>Trust-boundary channel the audited action crossed.</summary>
public AuditChannel Channel { get; init; }
/// <summary>Specific event kind within the channel.</summary>
public AuditKind Kind { get; init; }
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
public Guid? CorrelationId { get; init; }
/// <summary>Id of the originating script execution / inbound request.</summary>
public Guid? ExecutionId { get; init; }
/// <summary>ExecutionId of the execution that spawned this run; null for top-level runs.</summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; }
/// <summary>The cluster node on which the event was emitted.</summary>
public string? SourceNode { get; init; }
/// <summary>Instance id where the action originated, when applicable.</summary>
public string? SourceInstanceId { get; init; }
/// <summary>Script that initiated the action, when applicable.</summary>
public string? SourceScript { get; init; }
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
public string? Actor { get; init; }
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
public string? Target { get; init; }
/// <summary>Lifecycle status of this row.</summary>
public AuditStatus Status { get; init; }
/// <summary>HTTP status code where applicable.</summary>
public int? HttpStatus { get; init; }
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
public int? DurationMs { get; init; }
/// <summary>Human-readable error summary on failure rows.</summary>
public string? ErrorMessage { get; init; }
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
public string? ErrorDetail { get; init; }
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
public string? RequestSummary { get; init; }
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
public string? ResponseSummary { get; init; }
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
public bool PayloadTruncated { get; init; }
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
public string? Extra { get; init; }
/// <summary>Site-local forwarding state; null on central rows.</summary>
public AuditForwardState? ForwardState { get; init; }
}
@@ -41,7 +41,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent", b =>
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b =>
{
b.Property<Guid>("EventId")
.HasColumnType("uniqueidentifier");
@@ -2,10 +2,11 @@ using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
@@ -45,30 +46,36 @@ public class AuditLogRepository : IAuditLogRepository
throw new ArgumentNullException(nameof(evt));
}
// C3 transitional shim: the canonical record carries the ScadaBridge domain
// fields inside DetailsJson — decompose it into the typed 24-column values the
// existing dbo.AuditLog table expects. Central rows leave ForwardState null
// (it is a site-storage-only concern, never on a central row).
var r = AuditRowProjection.Decompose(evt);
// Enum columns are stored as varchar(32) (HasConversion<string>()), so do
// the conversion in C# rather than relying on parameter type inference —
// SqlClient would otherwise bind enums as int by default.
var channel = evt.Channel.ToString();
var kind = evt.Kind.ToString();
var status = evt.Status.ToString();
var forwardState = evt.ForwardState?.ToString();
var channel = r.Channel.ToString();
var kind = r.Kind.ToString();
var status = r.Status.ToString();
string? forwardState = null;
// FormattableString interpolation parameterises every value (no concatenation),
// so this is safe against injection even for the string columns.
try
{
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {r.EventId})
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState)
VALUES
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId}, {evt.ParentExecutionId},
{evt.SourceSiteId}, {evt.SourceNode}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
({r.EventId}, {r.OccurredAtUtc}, {r.IngestedAtUtc}, {channel}, {kind}, {r.CorrelationId}, {r.ExecutionId}, {r.ParentExecutionId},
{r.SourceSiteId}, {r.SourceNode}, {r.SourceInstanceId}, {r.SourceScript}, {r.Actor}, {r.Target}, {status},
{r.HttpStatus}, {r.DurationMs}, {r.ErrorMessage}, {r.ErrorDetail}, {r.RequestSummary},
{r.ResponseSummary}, {r.PayloadTruncated}, {r.Extra}, {forwardState});",
ct);
}
catch (SqlException ex) when (
@@ -85,7 +92,7 @@ VALUES
ex,
"InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for EventId {EventId}; treating as no-op.",
ex.Number,
evt.EventId);
r.EventId);
}
}
@@ -103,7 +110,10 @@ VALUES
throw new ArgumentNullException(nameof(paging));
}
var query = _context.Set<AuditEvent>().AsNoTracking();
// C3 transitional shim: the typed-column filter predicates query the
// AuditLogRow persistence shape as before (C6 retargets how the filter is
// applied); the materialized rows are recomposed into canonical records.
var query = _context.Set<AuditLogRow>().AsNoTracking();
// Multi-value dimensions: a null OR empty list means "no constraint"
// (the { Count: > 0 } guard prevents an empty list collapsing to a
@@ -181,13 +191,47 @@ VALUES
|| (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
}
return await query
var rows = await query
.OrderByDescending(e => e.OccurredAtUtc)
.ThenByDescending(e => e.EventId)
.Take(paging.PageSize)
.ToListAsync(ct);
return rows.Select(RowToCanonical).ToList();
}
/// <summary>
/// C3 transitional shim: recompose a canonical <see cref="AuditEvent"/> from a
/// materialized <see cref="AuditLogRow"/> read back from <c>dbo.AuditLog</c>.
/// <c>ForwardState</c> is dropped (central rows never carry it; it is not a
/// canonical / DetailsJson field).
/// </summary>
private static AuditEvent RowToCanonical(AuditLogRow row)
=> AuditRowProjection.Recompose(new AuditRowProjection.AuditRowValues(
EventId: row.EventId,
OccurredAtUtc: row.OccurredAtUtc,
IngestedAtUtc: row.IngestedAtUtc,
Channel: row.Channel,
Kind: row.Kind,
Status: row.Status,
CorrelationId: row.CorrelationId,
ExecutionId: row.ExecutionId,
ParentExecutionId: row.ParentExecutionId,
SourceSiteId: row.SourceSiteId,
SourceNode: row.SourceNode,
SourceInstanceId: row.SourceInstanceId,
SourceScript: row.SourceScript,
Actor: row.Actor,
Target: row.Target,
HttpStatus: row.HttpStatus,
DurationMs: row.DurationMs,
ErrorMessage: row.ErrorMessage,
ErrorDetail: row.ErrorDetail,
RequestSummary: row.RequestSummary,
ResponseSummary: row.ResponseSummary,
PayloadTruncated: row.PayloadTruncated,
Extra: row.Extra));
/// <inheritdoc />
public async Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
{
@@ -674,7 +718,7 @@ VALUES
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
{
return await _context.Set<AuditEvent>()
return await _context.Set<AuditLogRow>()
.AsNoTracking()
.Where(e => e.SourceNode != null)
.Select(e => e.SourceNode!)
@@ -13,6 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
@@ -124,8 +125,8 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
// Audit
/// <summary>Gets the set of audit log entries.</summary>
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
/// <summary>Gets the set of audit logs.</summary>
public DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();
/// <summary>Gets the set of audit log rows (central <c>dbo.AuditLog</c> persistence shape; mapped to/from the canonical record at the repository boundary).</summary>
public DbSet<AuditLogRow> AuditLogs => Set<AuditLogRow>();
/// <summary>Gets the set of site calls.</summary>
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
@@ -5,9 +5,10 @@ using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
@@ -234,37 +235,34 @@ public sealed class AuditWriteMiddleware
userAgent = ctx.Request.Headers.UserAgent.ToString(),
});
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiInbound,
Kind = kind,
var evt = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiInbound,
kind: kind,
status: status,
occurredAtUtc: DateTime.UtcNow,
actor: actor,
target: methodName,
// Audit Log #23: the per-request execution id minted ONCE at the
// start of the request (InvokeAsync) and stashed on
// HttpContext.Items. The same id is threaded onto a routed
// RouteToCallRequest.ParentExecutionId by the endpoint handler,
// so an inbound request and the site script it routes to share
// one correlation point. This inbound row stays top-level — its
// own ParentExecutionId is never set (see below).
ExecutionId = ResolveInboundExecutionId(ctx),
// own ParentExecutionId is never set.
executionId: ResolveInboundExecutionId(ctx),
// CorrelationId is purely the per-operation-lifecycle id; an
// inbound request is a one-shot from the audit row's
// perspective with no multi-row operation to correlate.
CorrelationId = null,
Actor = actor,
Target = methodName,
Status = status,
HttpStatus = statusCode,
DurationMs = (int)Math.Min(durationMs, int.MaxValue),
ErrorMessage = thrown?.Message,
RequestSummary = requestBody,
ResponseSummary = responseBody,
PayloadTruncated = payloadTruncated,
Extra = extra,
// Central direct-write — no site-local forwarding state.
ForwardState = null,
};
correlationId: null,
httpStatus: statusCode,
durationMs: (int)Math.Min(durationMs, int.MaxValue),
errorMessage: thrown?.Message,
requestSummary: requestBody,
responseSummary: responseBody,
payloadTruncated: payloadTruncated,
extra: extra);
// Central direct-write — no site-local forwarding state (not a
// canonical field).
// InboundAPI-018: fire-and-forget the writer so the user-facing
// response stays non-blocking (alog.md §13 — audit emission must
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -127,22 +127,26 @@ public static class AuditEndpoints
var paging = ParsePaging(context.Request.Query);
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
var events = await repo.QueryAsync(filter, paging, context.RequestAborted);
var canonical = await repo.QueryAsync(filter, paging, context.RequestAborted);
// The cursor for the next page is the last row of this page — but only
// when the page came back FULL. A short page means there is no next
// page, so nextCursor is null and the CLI stops paging.
object? nextCursor = null;
if (events.Count == paging.PageSize && events.Count > 0)
if (canonical.Count == paging.PageSize && canonical.Count > 0)
{
var last = events[^1];
var last = canonical[^1];
nextCursor = new
{
afterOccurredAtUtc = last.OccurredAtUtc,
// C3: canonical OccurredAtUtc is a DateTimeOffset; the cursor key is UTC.
afterOccurredAtUtc = last.OccurredAtUtc.UtcDateTime,
afterEventId = last.EventId,
};
}
// C3 (Task 2.5): decompose canonical rows into the flat AuditExportRow so the
// CLI's JSON shape (24-field) is unchanged.
var events = canonical.Select(AuditExportRow.From).ToList();
var payload = new { events, nextCursor };
// EnvelopeJsonOptions keeps an explicit null nextCursor on the wire so
// the CLI can always read the key. AuditEvent rows render with their
@@ -248,7 +252,7 @@ public static class AuditEndpoints
{
foreach (var evt in page)
{
await writer.WriteLineAsync(FormatCsvRow(evt));
await writer.WriteLineAsync(FormatCsvRow(AuditExportRow.From(evt)));
}
await writer.FlushAsync(ct);
await output.FlushAsync(ct);
@@ -275,7 +279,7 @@ public static class AuditEndpoints
{
foreach (var evt in page)
{
await writer.WriteLineAsync(JsonSerializer.Serialize(evt, JsonOptions));
await writer.WriteLineAsync(JsonSerializer.Serialize(AuditExportRow.From(evt), JsonOptions));
}
await writer.FlushAsync(ct);
await output.FlushAsync(ct);
@@ -309,7 +313,8 @@ public static class AuditEndpoints
}
var last = page[^1];
cursor = new AuditLogPaging(ExportPageSize, last.OccurredAtUtc, last.EventId);
// C3: canonical OccurredAtUtc is a DateTimeOffset; the keyset cursor column is UTC.
cursor = new AuditLogPaging(ExportPageSize, last.OccurredAtUtc.UtcDateTime, last.EventId);
}
}
@@ -571,7 +576,7 @@ public static class AuditEndpoints
/// Formats a single <see cref="AuditEvent"/> as an RFC 4180 CSV row matching <see cref="CsvHeader"/>.
/// </summary>
/// <param name="evt">The audit event to format.</param>
public static string FormatCsvRow(AuditEvent evt)
public static string FormatCsvRow(AuditExportRow evt)
{
var sb = new StringBuilder(256);
AppendField(sb, evt.EventId.ToString(), first: true);
@@ -0,0 +1,97 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
/// <summary>
/// Flat, wire-shape view of a canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/> for the
/// management CLI's <c>/api/audit/query</c> + <c>/api/audit/export</c> endpoints. C3
/// (Task 2.5) made the canonical record the repository seam type; this DTO preserves the
/// existing 24-field JSON/CSV shape the CLI consumes by decomposing the canonical row
/// (via <see cref="AuditRowProjection"/>) at the endpoint boundary.
/// </summary>
public sealed record AuditExportRow
{
/// <summary>Idempotency key.</summary>
public Guid EventId { get; init; }
/// <summary>UTC timestamp when the audited action occurred.</summary>
public DateTime OccurredAtUtc { get; init; }
/// <summary>UTC ingest timestamp; null until ingest.</summary>
public DateTime? IngestedAtUtc { get; init; }
/// <summary>Trust-boundary channel.</summary>
public AuditChannel Channel { get; init; }
/// <summary>Specific event kind.</summary>
public AuditKind Kind { get; init; }
/// <summary>Per-operation correlation id.</summary>
public Guid? CorrelationId { get; init; }
/// <summary>Originating execution id.</summary>
public Guid? ExecutionId { get; init; }
/// <summary>Spawning execution id; null for top-level runs.</summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site id where the action originated.</summary>
public string? SourceSiteId { get; init; }
/// <summary>Cluster node that emitted the event.</summary>
public string? SourceNode { get; init; }
/// <summary>Instance id where the action originated.</summary>
public string? SourceInstanceId { get; init; }
/// <summary>Script that initiated the action.</summary>
public string? SourceScript { get; init; }
/// <summary>Authenticated actor.</summary>
public string? Actor { get; init; }
/// <summary>Target of the action.</summary>
public string? Target { get; init; }
/// <summary>Lifecycle status.</summary>
public AuditStatus Status { get; init; }
/// <summary>HTTP status code where applicable.</summary>
public int? HttpStatus { get; init; }
/// <summary>Duration of the action in ms.</summary>
public int? DurationMs { get; init; }
/// <summary>Human-readable error summary.</summary>
public string? ErrorMessage { get; init; }
/// <summary>Verbose error detail.</summary>
public string? ErrorDetail { get; init; }
/// <summary>Truncated/redacted request summary.</summary>
public string? RequestSummary { get; init; }
/// <summary>Truncated/redacted response summary.</summary>
public string? ResponseSummary { get; init; }
/// <summary>True when summaries were truncated.</summary>
public bool PayloadTruncated { get; init; }
/// <summary>Free-form JSON extension.</summary>
public string? Extra { get; init; }
/// <summary>Site-local forwarding state; always null on the central read path.</summary>
public AuditForwardState? ForwardState { get; init; }
/// <summary>Decomposes a canonical <see cref="AuditEvent"/> into this flat export shape.</summary>
public static AuditExportRow From(AuditEvent evt)
{
var r = AuditRowProjection.Decompose(evt);
return new AuditExportRow
{
EventId = r.EventId,
OccurredAtUtc = r.OccurredAtUtc,
IngestedAtUtc = r.IngestedAtUtc,
Channel = r.Channel,
Kind = r.Kind,
CorrelationId = r.CorrelationId,
ExecutionId = r.ExecutionId,
ParentExecutionId = r.ParentExecutionId,
SourceSiteId = r.SourceSiteId,
SourceNode = r.SourceNode,
SourceInstanceId = r.SourceInstanceId,
SourceScript = r.SourceScript,
Actor = r.Actor,
Target = r.Target,
Status = r.Status,
HttpStatus = r.HttpStatus,
DurationMs = r.DurationMs,
ErrorMessage = r.ErrorMessage,
ErrorDetail = r.ErrorDetail,
RequestSummary = r.RequestSummary,
ResponseSummary = r.ResponseSummary,
PayloadTruncated = r.PayloadTruncated,
Extra = r.Extra,
ForwardState = null,
};
}
}
@@ -1,11 +1,12 @@
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
@@ -627,8 +628,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
{
try
{
var evt = BuildNotifyDeliverEvent(notification, now, AuditStatus.Attempted, errorMessage)
with { DurationMs = durationMs };
var evt = BuildNotifyDeliverEvent(
notification, now, AuditStatus.Attempted, errorMessage, durationMs);
await _auditWriter.WriteAsync(evt);
}
catch (Exception ex)
@@ -658,42 +659,41 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
Notification notification,
DateTimeOffset now,
AuditStatus status,
string? errorMessage)
string? errorMessage,
int? durationMs = null)
{
Guid? correlationId = Guid.TryParse(notification.NotificationId, out var parsed)
? parsed
: null;
return new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = now.UtcDateTime,
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifyDeliver,
CorrelationId = correlationId,
return ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.Notification,
kind: AuditKind.NotifyDeliver,
status: status,
occurredAtUtc: now.UtcDateTime,
// Central dispatch — a system identity per the Actor-column spec;
// there is no per-call authenticated user here. The originating
// script is still captured on SourceScript (and on the upstream
// NotifySend row).
Actor = SystemActor,
SourceSiteId = notification.SourceSiteId,
SourceInstanceId = notification.SourceInstanceId,
SourceScript = notification.SourceScript,
actor: SystemActor,
target: notification.ListName,
correlationId: correlationId,
// ExecutionId (Audit Log #23): the originating script execution's id,
// carried from the site on NotificationSubmit and persisted on the
// Notification row. Echoing it here links the central NotifyDeliver
// rows to the site-emitted NotifySend row for the same run. Null when
// the notification was raised outside a script execution.
ExecutionId = notification.OriginExecutionId,
executionId: notification.OriginExecutionId,
// ParentExecutionId (Audit Log #23): the originating routed run's
// parent ExecutionId, carried from the site on NotificationSubmit and
// persisted on the Notification row. Echoing it here links the central
// NotifyDeliver rows to the routed run's parent. Null for non-routed runs.
ParentExecutionId = notification.OriginParentExecutionId,
Target = notification.ListName,
Status = status,
ErrorMessage = errorMessage,
};
parentExecutionId: notification.OriginParentExecutionId,
sourceSiteId: notification.SourceSiteId,
sourceInstanceId: notification.SourceInstanceId,
sourceScript: notification.SourceScript,
durationMs: durationMs,
errorMessage: errorMessage);
}
/// <summary>
@@ -2,9 +2,10 @@ using System.Data;
using System.Data.Common;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
@@ -473,40 +474,36 @@ internal sealed class AuditingDbCommand : DbCommand
? $"{{\"op\":\"write\",\"rowsAffected\":{(rowsAffected ?? 0)}}}"
: $"{{\"op\":\"read\",\"rowsReturned\":{(rowsReturned ?? 0)}}}";
return new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound,
Kind = AuditKind.DbWrite,
return ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.DbOutbound,
kind: AuditKind.DbWrite,
status: status,
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
// Outbound channel: per the Audit Log Actor-column spec the actor is
// the calling script. Null when no single script owns the call
// (e.g. a shared script running inline).
actor: _sourceScript,
target: target,
// Audit Log #23: a sync one-shot DB write has no operation
// lifecycle, so CorrelationId is null. ExecutionId carries the
// per-execution id so this row shares an id with the other sync
// trust-boundary rows from the same script run.
CorrelationId = null,
ExecutionId = _executionId,
correlationId: null,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning execution's id;
// null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
// Outbound channel: per the Audit Log Actor-column spec the actor is
// the calling script. Null when no single script owns the call
// (e.g. a shared script running inline).
Actor = _sourceScript,
Target = target,
Status = status,
HttpStatus = null,
DurationMs = durationMs,
ErrorMessage = thrown?.Message,
ErrorDetail = thrown?.ToString(),
RequestSummary = requestSummary,
ResponseSummary = null,
PayloadTruncated = false,
Extra = extra,
ForwardState = AuditForwardState.Pending,
};
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
sourceInstanceId: _instanceName,
sourceScript: _sourceScript,
httpStatus: null,
durationMs: durationMs,
errorMessage: thrown?.Message,
errorDetail: thrown?.ToString(),
requestSummary: requestSummary,
responseSummary: null,
payloadTruncated: false,
extra: extra);
}
/// <summary>
@@ -3,7 +3,6 @@ using System.Text.Json;
using System.Text.RegularExpressions;
using Akka.Actor;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
@@ -11,7 +10,9 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
@@ -735,29 +736,25 @@ public class ScriptRuntimeContext
try
{
telemetry = new CachedCallTelemetry(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit,
Audit: ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.CachedSubmit,
status: AuditStatus.Submitted,
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
target: target,
// CorrelationId stays the per-operation lifecycle id
// (TrackedOperationId); ExecutionId carries the
// per-execution id shared across this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
correlationId: trackedId.Value,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
Target = target,
Status = AuditStatus.Submitted,
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
sourceInstanceId: _instanceName,
sourceScript: _sourceScript,
// Submit precedes the call — request args only, no response yet.
RequestSummary = SerializeRequest(parameters),
ForwardState = AuditForwardState.Pending,
},
requestSummary: SerializeRequest(parameters)),
Operational: new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: "ApiOutbound",
@@ -857,30 +854,26 @@ public class ScriptRuntimeContext
try
{
attempted = new CachedCallTelemetry(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCallCached,
Audit: ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCallCached,
status: AuditStatus.Attempted,
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
target: target,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
correlationId: trackedId.Value,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
Target = target,
Status = AuditStatus.Attempted,
HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage,
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result.ResponseJson,
ForwardState = AuditForwardState.Pending,
},
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
sourceInstanceId: _instanceName,
sourceScript: _sourceScript,
httpStatus: httpStatus,
errorMessage: result.Success ? null : result.ErrorMessage,
requestSummary: SerializeRequest(parameters),
responseSummary: result.ResponseJson),
Operational: new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: "ApiOutbound",
@@ -929,30 +922,26 @@ public class ScriptRuntimeContext
try
{
resolve = new CachedCallTelemetry(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedResolve,
Audit: ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.CachedResolve,
status: auditTerminalStatus,
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
target: target,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
correlationId: trackedId.Value,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
Target = target,
Status = auditTerminalStatus,
HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage,
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result.ResponseJson,
ForwardState = AuditForwardState.Pending,
},
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
sourceInstanceId: _instanceName,
sourceScript: _sourceScript,
httpStatus: httpStatus,
errorMessage: result.Success ? null : result.ErrorMessage,
requestSummary: SerializeRequest(parameters),
responseSummary: result.ResponseJson),
Operational: new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: "ApiOutbound",
@@ -1112,44 +1101,40 @@ public class ScriptRuntimeContext
}
}
return new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
return ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: status,
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
// Outbound channel: per the Audit Log Actor-column spec the actor
// is the calling script. Null when no single script owns the call
// (e.g. a shared script running inline).
actor: _sourceScript,
target: $"{systemName}.{methodName}",
// Audit Log #23: a sync one-shot call has no operation
// lifecycle, so CorrelationId is null. ExecutionId carries the
// per-execution id so all the sync ApiCall/DbWrite rows from
// one script run can be correlated together.
CorrelationId = null,
ExecutionId = _executionId,
correlationId: null,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// id; null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
// Outbound channel: per the Audit Log Actor-column spec the actor
// is the calling script. Null when no single script owns the call
// (e.g. a shared script running inline).
Actor = _sourceScript,
Target = $"{systemName}.{methodName}",
Status = status,
HttpStatus = httpStatus,
DurationMs = durationMs,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
sourceInstanceId: _instanceName,
sourceScript: _sourceScript,
httpStatus: httpStatus,
durationMs: durationMs,
errorMessage: errorMessage,
errorDetail: errorDetail,
// Payload capture: the request arguments and the response body.
// The audit writer's payload filter applies the configured size
// cap and header/secret redaction downstream — the emitter just
// hands over the raw values.
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result?.ResponseJson,
PayloadTruncated = false,
Extra = null,
ForwardState = AuditForwardState.Pending,
};
// The audit writer's redactor applies the configured size cap and
// header/secret redaction downstream — the emitter just hands
// over the raw values.
requestSummary: SerializeRequest(parameters),
responseSummary: result?.ResponseJson,
payloadTruncated: false,
extra: null);
}
/// <summary>
@@ -1383,26 +1368,22 @@ public class ScriptRuntimeContext
try
{
telemetry = new CachedCallTelemetry(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound,
Kind = AuditKind.CachedSubmit,
Audit: ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.DbOutbound,
kind: AuditKind.CachedSubmit,
status: AuditStatus.Submitted,
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
target: target,
// CorrelationId = per-operation lifecycle id
// (TrackedOperationId); ExecutionId = per-execution id.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
correlationId: trackedId.Value,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
Target = target,
Status = AuditStatus.Submitted,
ForwardState = AuditForwardState.Pending,
},
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
sourceInstanceId: _instanceName,
sourceScript: _sourceScript),
Operational: new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: "DbOutbound",
@@ -1830,42 +1811,38 @@ public class ScriptRuntimeContext
body = body,
});
evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend,
// CorrelationId is the NotificationId-derived per-operation
// lifecycle id; ExecutionId carries the per-execution id.
CorrelationId = correlationId,
ExecutionId = _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
evt = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.Notification,
kind: AuditKind.NotifySend,
status: AuditStatus.Submitted,
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
// Outbound channel: per the Audit Log Actor-column spec the
// actor is the calling script. Null when no single script
// owns the call (e.g. a shared script running inline).
Actor = _sourceScript,
Target = _listName,
Status = AuditStatus.Submitted,
HttpStatus = null,
actor: _sourceScript,
target: _listName,
// CorrelationId is the NotificationId-derived per-operation
// lifecycle id; ExecutionId carries the per-execution id.
correlationId: correlationId,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
sourceInstanceId: _instanceName,
sourceScript: _sourceScript,
httpStatus: null,
// Send is fire-and-forget from the script's perspective —
// the dispatcher (NotificationOutboxActor) times each
// delivery attempt and stamps DurationMs on its
// NotifyDeliver(Attempted) rows.
DurationMs = null,
ErrorMessage = null,
ErrorDetail = null,
RequestSummary = requestSummary,
ResponseSummary = null,
PayloadTruncated = false,
Extra = null,
ForwardState = AuditForwardState.Pending,
};
durationMs: null,
errorMessage: null,
errorDetail: null,
requestSummary: requestSummary,
responseSummary: null,
payloadTruncated: false,
extra: null);
}
catch (Exception buildEx)
{