Files
scadalink-design/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs
Joseph Doherty e4d902753b feat(siteruntime): emit DbOutbound.DbWrite on sync Database.Execute*/ExecuteReader (#23 M4)
Audit Log #23 — M4 Bundle A (Tasks A1+A2): every script-initiated
synchronous DB call routed through Database.Connection(name) now emits
exactly one DbOutbound/DbWrite audit row.

Implementation — three thin ADO.NET decorators in
src/ScadaLink.SiteRuntime/Scripts/:

  - AuditingDbConnection: wraps the gateway-returned DbConnection so
    CreateDbCommand() hands the script an AuditingDbCommand. All other
    ADO.NET surface forwards unchanged.
  - AuditingDbCommand: intercepts ExecuteNonQuery / ExecuteScalar /
    ExecuteReader (sync + async). On terminal:
      Channel = DbOutbound, Kind = DbWrite, Status = Delivered|Failed,
      Extra = {"op":"write","rowsAffected":N}   (Execute*),
              {"op":"read","rowsReturned":N}   (ExecuteReader),
      RequestSummary = JSON of SQL + parameter values (default capture;
                       redaction in M5),
      Target = "<connection>.<first 60 chars of SQL>",
      DurationMs captured via Stopwatch,
      Provenance from ScriptRuntimeContext (SourceSiteId,
                       SourceInstanceId, SourceScript).
  - AuditingDbDataReader: counts rows on Read/ReadAsync and fires the
    audit emission exactly once on Close/CloseAsync/Dispose.

DatabaseHelper now takes an IAuditWriter; ScriptRuntimeContext.Database
threads through _auditWriter. When the writer is null (tests / minimal
hosts) Connection() returns the raw inner DbConnection unchanged.

Best-effort emission (alog.md §7): mirrors M2 Bundle F's 3-layer
fail-safe — build, write, continuation. Audit-build, audit-write, and
audit-continuation faults are logged + swallowed; the original ADO.NET
result (or original exception) flows back to the script untouched. The
SiteAuditWriteFailures counter increments automatically through the
existing FallbackAuditWriter (Bundle G).

Tests — tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs
(7 new, all passing):
  1. Execute / INSERT success — one DbWrite row, op=write, rowsAffected=1.
  2. ExecuteScalar success — one DbWrite row, op=write.
  3. Execute throws — Status=Failed, ErrorMessage + ErrorDetail set.
  4. ExecuteReader success — op=read, rowsReturned counts rows pulled.
  5. AuditWriter throws — original ADO.NET rowsAffected returned, no
     events captured, no exception propagates.
  6. Provenance populated from context.
  7. DurationMs recorded non-zero.

Tests use Microsoft.Data.Sqlite in-memory (already transitively
available via SiteRuntime). Total SiteRuntime test suite: 251 passing
(244 baseline + 7 new). Full solution test suite passes.
2026-05-20 15:54:54 -04:00

117 lines
4.5 KiB
C#

using System.Data;
using System.Data.Common;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.SiteRuntime.Scripts;
/// <summary>
/// Audit Log #23 — M4 Bundle A: 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>
/// All other <see cref="DbConnection"/> members forward to the inner connection
/// unchanged 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 ILogger _logger;
public AuditingDbConnection(
DbConnection inner,
IAuditWriter auditWriter,
string connectionName,
string siteId,
string instanceName,
string? sourceScript,
ILogger logger)
{
_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));
}
// ConnectionString is settable on DbConnection — forward both halves.
public override string ConnectionString
{
// Some providers throw on get when the connection hasn't been opened
// with a string set explicitly. The wrapper has no opinion — forward.
#pragma warning disable CS8765 // nullability of overridden member parameter — base setter accepts null in practice
get => _inner.ConnectionString;
set => _inner.ConnectionString = value;
#pragma warning restore CS8765
}
public override string Database => _inner.Database;
public override string DataSource => _inner.DataSource;
public override string ServerVersion => _inner.ServerVersion;
public override ConnectionState State => _inner.State;
public override void ChangeDatabase(string databaseName) => _inner.ChangeDatabase(databaseName);
public override void Close() => _inner.Close();
public override void Open() => _inner.Open();
public override Task OpenAsync(CancellationToken cancellationToken) => _inner.OpenAsync(cancellationToken);
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> _inner.BeginTransaction(isolationLevel);
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);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
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;
}
}