Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/AuditingDbConnection.cs
T
Joseph Doherty 9a87d7a328 Resolve all 33 medium-severity architecture review findings
Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.

Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.

Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.

Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.

Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.

Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.

Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.
2026-09-09 22:05:30 -04:00

201 lines
8.3 KiB
C#

using System.Data;
using System.Data.Common;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// Thin decorator over the
/// <see cref="DbConnection"/> returned by
/// <see cref="ScriptRuntimeContext.DatabaseHelper.Connection"/>. The decorator
/// itself does no audit work — it simply intercepts
/// <see cref="CreateDbCommand"/> so the <see cref="DbCommand"/> handed back to
/// the script is wrapped in an <see cref="AuditingDbCommand"/> that emits one
/// <c>DbOutbound</c>/<c>DbWrite</c> audit row per execution.
/// </summary>
/// <remarks>
/// <para>
/// Transactions and command connection readback retain this wrapper so they cannot
/// expose an unaudited provider connection. Other members forward to the inner connection
/// so the script keeps full ADO.NET semantics (transactions, state
/// transitions, server-version queries, etc.). Disposing the wrapper disposes
/// the inner connection — the caller is still responsible for disposal per
/// the <see cref="IDatabaseGateway"/> contract.
/// </para>
/// <para>
/// The audit-write failure contract (alog.md §7) is honoured at the
/// <see cref="AuditingDbCommand"/> layer — see that class for the 3-layer
/// fail-safe pattern (build, write, observe).
/// </para>
/// </remarks>
internal sealed class AuditingDbConnection : DbConnection
{
private readonly DbConnection _inner;
private readonly IAuditWriter _auditWriter;
private readonly string _connectionName;
private readonly string _siteId;
private readonly string _instanceName;
private readonly string? _sourceScript;
private readonly Guid _executionId;
/// <summary>
/// ParentExecutionId: the spawning execution's id when this
/// run was inbound-API-routed; <c>null</c> for non-routed runs. Threaded
/// alongside <see cref="_executionId"/> into the
/// <see cref="AuditingDbCommand"/> so its <c>DbWrite</c> row stamps it.
/// </summary>
private readonly Guid? _parentExecutionId;
/// <summary>
/// Flow Debugger (plan 2026-09-06 D5): optional observer handed the SAME
/// <see cref="AuditEvent"/> each <see cref="AuditingDbCommand"/> gives the writer —
/// the run's inline trace collector. Null when untraced.
/// </summary>
private readonly Action<AuditEvent>? _onAudit;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new auditing database connection decorator.
/// </summary>
/// <param name="inner">The inner database connection to wrap and audit commands through.</param>
/// <param name="auditWriter">Writer for audit log entries.</param>
/// <param name="connectionName">Name of the database connection being used.</param>
/// <param name="siteId">Identifier of the site executing commands.</param>
/// <param name="instanceName">Unique name of the instance executing commands.</param>
/// <param name="sourceScript">Optional name of the source script for audit trail.</param>
/// <param name="logger">Logger for diagnostics and warnings.</param>
/// <param name="executionId">Unique identifier for this script execution.</param>
/// <param name="parentExecutionId">Optional identifier of the parent execution (for routed calls).</param>
/// <param name="onAudit">Optional observer of every emitted <see cref="AuditEvent"/> (the Flow Debugger trace collector).</param>
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbCommand). parentExecutionId is a trailing
// optional param so existing positional callers stay source-compatible.
public AuditingDbConnection(
DbConnection inner,
IAuditWriter auditWriter,
string connectionName,
string siteId,
string instanceName,
string? sourceScript,
ILogger logger,
Guid executionId,
Guid? parentExecutionId = null,
Action<AuditEvent>? onAudit = null)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
_connectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName));
_siteId = siteId ?? string.Empty;
_instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_executionId = executionId;
_parentExecutionId = parentExecutionId;
_onAudit = onAudit;
}
/// <summary>
/// Exposes the wrapped <see cref="DbConnection"/> to the
/// sibling <see cref="AuditingDbCommand"/> in the same assembly, so the
/// command's <c>DbConnection</c> setter can unwrap an
/// <see cref="AuditingDbConnection"/> without reflecting into the
/// private <c>_inner</c> field. Both classes are <c>internal sealed</c>
/// in this assembly, so the accessor stays out of the public API and
/// matches the precedent of preferring proper API surface
/// over <see cref="System.Reflection"/>.
/// </summary>
internal DbConnection Inner => _inner;
/// <inheritdoc />
// The configured target is the audit identity; scripts cannot retarget it or
// retrieve connection credentials through the raw ADO surface.
public override string ConnectionString
{
#pragma warning disable CS8765
get => string.Empty;
set => throw new NotSupportedException("Audited connections cannot be retargeted.");
#pragma warning restore CS8765
}
/// <inheritdoc />
public override string Database => _inner.Database;
/// <inheritdoc />
public override string DataSource => _inner.DataSource;
/// <inheritdoc />
public override string ServerVersion => _inner.ServerVersion;
/// <inheritdoc />
public override ConnectionState State => _inner.State;
/// <inheritdoc />
public override void ChangeDatabase(string databaseName) => throw new NotSupportedException("Audited connections cannot be retargeted.");
/// <inheritdoc />
public override void Close() => _inner.Close();
/// <inheritdoc />
public override void Open() => _inner.Open();
/// <inheritdoc />
public override Task OpenAsync(CancellationToken cancellationToken) => _inner.OpenAsync(cancellationToken);
/// <inheritdoc />
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> new AuditingDbTransaction(_inner.BeginTransaction(isolationLevel), this);
/// <inheritdoc />
protected override async ValueTask<DbTransaction> BeginDbTransactionAsync(
IsolationLevel isolationLevel, CancellationToken cancellationToken)
=> new AuditingDbTransaction(
await _inner.BeginTransactionAsync(isolationLevel, cancellationToken).ConfigureAwait(false), this);
/// <inheritdoc />
protected override DbCommand CreateDbCommand()
{
var innerCmd = _inner.CreateCommand();
// Hand the script an auditing wrapper. The wrapper preserves the
// inner command's identity for parameters / type maps via delegation.
return new AuditingDbCommand(
innerCmd,
_auditWriter,
_connectionName,
_siteId,
_instanceName,
_sourceScript,
_logger,
_executionId,
// ParentExecutionId: the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId,
// Flow Debugger: the DbWrite rows ride the inline collector.
_onAudit) { Connection = this };
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
/// <inheritdoc />
public override ValueTask DisposeAsync()
{
// DbConnection.DisposeAsync is virtual; calling base would run the
// synchronous Dispose path. Forward to the inner connection
// asynchronously and short-circuit the base.
var task = _inner.DisposeAsync();
GC.SuppressFinalize(this);
return task;
}
}