using System.Data.Common; using System.Diagnostics; using System.Globalization; using System.Text; using ZB.MOM.WW.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; /// /// Scoped, parameterized database access exposed to inbound API scripts as /// InboundScriptContext.Database. This is the dedicated, curated data-access /// helper authorized by the design doc (Component-InboundAPI.md, "Database access") — /// not a raw connection handed to the script. All ADO.NET stays internal here: scripts /// call / / /// by name and never reference System.Data. /// /// /// SQL-injection protection. Statement text is authored by the /// (design-time) method script, but every value is bound as a named SQL /// parameter (anonymous-object properties become @-prefixed parameters via /// ) and is NEVER string-concatenated into the command text. /// Request-derived values therefore reach the database only through parameter binding, /// closing the injection vector. Connection access is restricted to the named /// connections configured on the central — a script /// cannot supply an arbitrary connection string. /// /// /// /// Reads and writes are both permitted: the move-in /// integration needs to record results, not just read them. Use /// / for reads and for writes. /// /// /// /// Async + deadline-bound. Every call uses the async ADO.NET path /// end-to-end (no .GetAwaiter().GetResult() blocking a pool thread) and honours the /// executing method's deadline token on the command itself, with a /// backstop derived from the method timeout — so a slow query is bounded by the method /// timeout instead of running unbounded. /// /// /// /// Audited (Audit Log #23, outbound DB scope; Flow Debugger outbound-calls plan /// 2026-09-06 D7). Every call emits exactly ONE DbOutbound/DbWrite row /// through the central using the site's /// AuditingDbCommand vocabulary: Extra = {"op":"write","rowsAffected":N} for /// , {"op":"read","rowsReturned":N} for the two query /// methods; RequestSummary = {"sql":…,"parameters":{"@name":"value"}}. The /// 8 KB payload cap and per-target SQL-parameter redaction are applied at write time by /// the writer's IAuditRedactor — the same stage that caps the site's rows — so the /// helper builds the raw summary only. Emission is best-effort: a build, write or sink /// fault never reaches the script; the original result (or exception) always does. When /// a flow-trace sink is present the same instance goes to the /// sink first, then to the writer, so a Live Flow Debugger run renders the row without /// a round-trip to the audit store. A helper built without a writer and without a sink /// (the pre-existing three-argument shape) emits nothing. /// /// public sealed class InboundDatabaseHelper { private readonly IDatabaseGateway? _gateway; private readonly CancellationToken _ct; private readonly int _commandTimeoutSeconds; private readonly ICentralAuditWriter? _auditWriter; private readonly INodeIdentityProvider? _nodeIdentity; private readonly Guid? _executionId; private readonly Guid? _parentExecutionId; private readonly string? _actor; private readonly Action? _traceSink; /// /// Initializes the helper. /// /// The central database gateway, or null when no gateway is registered (the helper then throws on first use). /// The executing method's deadline token; forwarded to every async DB call so a slow query is bounded by the method timeout. /// The method timeout; used to derive a backstop. (the default) leaves the provider default. /// Central-direct-write audit writer for the per-call DbOutbound/DbWrite row; null = no row is written. /// Stamps SourceNode (central-a/central-b) on the row; null = NULL. /// The executing inbound request's per-request execution id, stamped as the row's ExecutionId. /// The spawning execution's id, if any (an inbound request is a root, so callers normally pass null). /// The executing API method's name; the row's Actor becomes api:{methodName}. Null = NULL actor. /// Flow Debugger trace sink; receives the same event instance before the writer does. Null = none. // Trailing optional parameters: the original (gateway, ct, commandTimeout) shape stays // source-compatible for every existing caller and test. public InboundDatabaseHelper( IDatabaseGateway? gateway, CancellationToken ct, TimeSpan commandTimeout = default, ICentralAuditWriter? auditWriter = null, INodeIdentityProvider? nodeIdentity = null, Guid? executionId = null, Guid? parentExecutionId = null, string? methodName = null, Action? traceSink = null) { _gateway = gateway; _ct = ct; _commandTimeoutSeconds = commandTimeout > TimeSpan.Zero ? (int)Math.Ceiling(commandTimeout.TotalSeconds) : 0; _auditWriter = auditWriter; _nodeIdentity = nodeIdentity; _executionId = executionId; _parentExecutionId = parentExecutionId; _actor = string.IsNullOrEmpty(methodName) ? null : "api:" + methodName; _traceSink = traceSink; } /// First column of the first row converted to (default if no rows). /// The type to convert the scalar result to. /// Name of a connection configured on the central database gateway. /// The SQL statement. Values MUST be supplied via , not concatenated. /// Optional anonymous object whose properties become bound @-prefixed parameters. /// The converted scalar, or default when there are no rows / a NULL value. public async Task QuerySingleAsync(string connectionName, string sql, object? parameters = null) { var (result, _) = await RunAudited(connectionName, sql, parameters, op: "read", async () => { await using var conn = await _gateway!.GetConnectionAsync(connectionName, _ct); await using var cmd = CreateCommand(conn, sql, parameters); var scalar = await cmd.ExecuteScalarAsync(_ct); if (scalar is null or DBNull) return (default(T), 0); if (scalar is T t) return (t, 1); return ((T)Convert.ChangeType(scalar, typeof(T), CultureInfo.InvariantCulture), 1); }); return result; } /// All rows as column→value dictionaries (case-insensitive keys). /// Name of a connection configured on the central database gateway. /// The SQL statement. Values MUST be supplied via , not concatenated. /// Optional anonymous object whose properties become bound @-prefixed parameters. /// The result rows; empty when nothing matches. public async Task>> QueryAsync( string connectionName, string sql, object? parameters = null) { var (rows, _) = await RunAudited(connectionName, sql, parameters, op: "read", async () => { await using var conn = await _gateway!.GetConnectionAsync(connectionName, _ct); await using var cmd = CreateCommand(conn, sql, parameters); await using var reader = await cmd.ExecuteReaderAsync(_ct); var list = new List>(); while (await reader.ReadAsync(_ct)) { var row = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var i = 0; i < reader.FieldCount; i++) { var v = reader.GetValue(i); row[reader.GetName(i)] = v is DBNull ? null : v; } list.Add(row); } return ((IReadOnlyList>)list, list.Count); }); return rows; } /// /// Executes a write statement (INSERT/UPDATE/DELETE/DDL) and returns the number of /// rows affected. Writes are authorized for inbound API scripts; /// values are still bound as parameters, never concatenated. /// /// Name of a connection configured on the central database gateway. /// The SQL statement. Values MUST be supplied via , not concatenated. /// Optional anonymous object whose properties become bound @-prefixed parameters. /// The number of rows affected. public async Task ExecuteAsync(string connectionName, string sql, object? parameters = null) { var (affected, _) = await RunAudited(connectionName, sql, parameters, op: "write", async () => { await using var conn = await _gateway!.GetConnectionAsync(connectionName, _ct); await using var cmd = CreateCommand(conn, sql, parameters); var n = await cmd.ExecuteNonQueryAsync(_ct); return (n, n); }); return affected; } /// /// Runs and emits the one DbOutbound/DbWrite row for /// it — Delivered with the body's row count, or Failed with the thrown /// exception's message, which is then rethrown untouched to the script. The gateway /// null-check is inside the audited span deliberately so "Database is not available" /// is itself recorded as a failed outbound DB call. /// private async Task<(T Result, int Rows)> RunAudited( string connectionName, string sql, object? parameters, string op, Func> body) { var startedUtc = DateTime.UtcNow; var sw = Stopwatch.StartNew(); (T Result, int Rows) outcome; try { if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method"); outcome = await body(); } catch (Exception ex) { EmitAudit(connectionName, sql, parameters, op, startedUtc, sw.ElapsedMilliseconds, rows: 0, thrown: ex); throw; } EmitAudit(connectionName, sql, parameters, op, startedUtc, sw.ElapsedMilliseconds, outcome.Rows, thrown: null); return outcome; } /// /// Best-effort emission (mirrors RouteTarget.EmitRouteCall and the site's /// AuditingDbCommand.EmitAudit): build, sink, write — every layer swallows its /// own faults so nothing reaches the script. /// private void EmitAudit( string connectionName, string sql, object? parameters, string op, DateTime startedUtc, long elapsedMs, int rows, Exception? thrown) { if (_auditWriter is null && _traceSink is null) return; AuditEvent evt; try { // Vocabulary lock with the site's AuditingDbCommand: both reads and writes are // DbWrite rows on the DbOutbound channel; Extra carries the discriminator. var extra = op == "write" ? $"{{\"op\":\"write\",\"rowsAffected\":{rows}}}" : $"{{\"op\":\"read\",\"rowsReturned\":{rows}}}"; evt = ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.DbOutbound, kind: AuditKind.DbWrite, status: thrown is null ? AuditStatus.Delivered : AuditStatus.Failed, occurredAtUtc: DateTime.SpecifyKind(startedUtc, DateTimeKind.Utc), actor: _actor, target: connectionName, sourceNode: _nodeIdentity?.NodeName, // A sync one-shot DB call has no operation lifecycle, so CorrelationId stays // null; ExecutionId ties the row to the inbound request's other rows. correlationId: null, executionId: _executionId, parentExecutionId: _parentExecutionId, durationMs: (int)Math.Min(elapsedMs, int.MaxValue), errorMessage: thrown?.Message, errorDetail: thrown?.ToString(), requestSummary: BuildRequestSummary(sql, parameters), responseSummary: null, payloadTruncated: false, extra: extra); } catch (Exception) { return; } // Sink FIRST (task contract): a Live Flow Debugger run reads the row from the // sink, and the writer's redaction stage must not be the thing gating it. if (_traceSink is not null) { try { _traceSink(evt); } catch (Exception) { /* a misbehaving sink must not fail the script */ } } if (_auditWriter is null) return; try { var t = _auditWriter.WriteAsync(evt, CancellationToken.None); if (!t.IsCompletedSuccessfully) { _ = t.ContinueWith(static _ => { }, TaskContinuationOptions.OnlyOnFaulted); } } catch (Exception) { // Best-effort: an audit failure never fails the DB call. } } /// /// Same JSON shape as the site's AuditingDbCommand.BuildRequestSummary: /// {"sql":"…","parameters":{"@name":"value",…}}, values stringified, NULL as /// JSON null. Built from the script's arguments rather than a /// so a failure before the command exists (gateway/open) still carries the statement. /// Capping and per-target parameter redaction happen at write time in the redactor. /// private static string? BuildRequestSummary(string sql, object? parameters) { if (string.IsNullOrEmpty(sql)) return null; var sb = new StringBuilder(sql.Length + 64); sb.Append("{\"sql\":"); AppendJsonString(sb, sql); var props = parameters?.GetType().GetProperties(); if (props is { Length: > 0 }) { sb.Append(",\"parameters\":{"); var first = true; foreach (var prop in props) { if (!first) sb.Append(','); first = false; AppendJsonString(sb, "@" + prop.Name); sb.Append(':'); var value = prop.GetValue(parameters); if (value is null || value is DBNull) { sb.Append("null"); } else { AppendJsonString(sb, Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); } } sb.Append('}'); } sb.Append('}'); return sb.ToString(); } private static void AppendJsonString(StringBuilder sb, string value) { sb.Append('"'); foreach (var ch in value) { switch (ch) { case '"': sb.Append("\\\""); break; case '\\': sb.Append("\\\\"); break; case '\b': sb.Append("\\b"); break; case '\f': sb.Append("\\f"); break; case '\n': sb.Append("\\n"); break; case '\r': sb.Append("\\r"); break; case '\t': sb.Append("\\t"); break; default: if (ch < 0x20) sb.Append("\\u").Append(((int)ch).ToString("x4")); else sb.Append(ch); break; } } sb.Append('"'); } private DbCommand CreateCommand(DbConnection conn, string sql, object? parameters) { var cmd = conn.CreateCommand(); cmd.CommandText = sql; // A CommandTimeout backstop derived from the method timeout so a // slow query cannot outrun the method deadline even if the provider does not // honour token cancellation mid-statement. if (_commandTimeoutSeconds > 0) cmd.CommandTimeout = _commandTimeoutSeconds; AddParameters(cmd, parameters); return cmd; } private static void AddParameters(DbCommand cmd, object? parameters) { if (parameters is null) return; foreach (var prop in parameters.GetType().GetProperties()) { var p = cmd.CreateParameter(); p.ParameterName = "@" + prop.Name; p.Value = prop.GetValue(parameters) ?? DBNull.Value; cmd.Parameters.Add(p); } } }