fix(inbound): authorize+secure Database helper, async/deadline-bound DB, wait-timeout-bound WaitForAttribute
Resolves InboundAPI-026/027/028/029 (+ newly-surfaced -030). - 026: authorize the scoped Database helper in the design doc; SQL-injection protection is parameter binding (values never concatenated); allow writes via ExecuteAsync; drop the false 'read-only' claim. Named connections only. - 027: async ADO.NET end-to-end (no .GetAwaiter().GetResult()); honour the method deadline token on ExecuteScalarAsync/ExecuteReaderAsync/ExecuteNonQueryAsync + a CommandTimeout backstop derived from the method timeout. - 028: negative-path tests (null-gateway, deadline cancellation, parameterization) + e2e Database + WaitForAttribute cases through the real endpoint. - 029: WaitForAttribute is bounded by its WAIT timeout (per-wait CTS + client-abort + explicit token), NOT the method deadline (spec §6) — a long wait may outlive the method timeout; WithRequestAborted threads the raw client-abort token separately. - 030: Central UI compile-surface mirrors (InboundScriptHost / SandboxInboundScriptHost) gained the Database member (drifted since the runtime helper was added) so the authorized async API type-checks at the design-time gate.
This commit is contained in:
@@ -5,45 +5,90 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only database access exposed to inbound API scripts. All ADO.NET stays
|
||||
/// internal here — scripts call QuerySingle/Query by name and never reference
|
||||
/// System.Data. Named connections only; parameters are bound (anonymous-object
|
||||
/// properties become @-prefixed SQL parameters), never string-concatenated.
|
||||
/// Scoped, parameterized database access exposed to inbound API scripts as
|
||||
/// <c>InboundScriptContext.Database</c>. 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 <see cref="QueryAsync"/> / <see cref="QuerySingleAsync{T}"/> / <see cref="ExecuteAsync"/>
|
||||
/// by name and never reference <c>System.Data</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>SQL-injection protection (InboundAPI-026).</b> Statement text is authored by the
|
||||
/// (design-time) method script, but every <em>value</em> is bound as a named SQL
|
||||
/// parameter (anonymous-object properties become <c>@</c>-prefixed parameters via
|
||||
/// <see cref="AddParameters"/>) 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 <see cref="IDatabaseGateway"/> — a script
|
||||
/// cannot supply an arbitrary connection string.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Reads and writes are both permitted</b> (InboundAPI-026 design decision): the move-in
|
||||
/// integration needs to record results, not just read them. Use <see cref="QueryAsync"/>
|
||||
/// / <see cref="QuerySingleAsync{T}"/> for reads and <see cref="ExecuteAsync"/> for writes.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Async + deadline-bound (InboundAPI-027).</b> Every call uses the async ADO.NET path
|
||||
/// end-to-end (no <c>.GetAwaiter().GetResult()</c> blocking a pool thread) and honours the
|
||||
/// executing method's deadline token on the command itself, with a <see cref="DbCommand.CommandTimeout"/>
|
||||
/// backstop derived from the method timeout — so a slow query is bounded by the method
|
||||
/// timeout instead of running unbounded.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class InboundDatabaseHelper
|
||||
{
|
||||
private readonly IDatabaseGateway? _gateway;
|
||||
private readonly CancellationToken _ct;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
|
||||
public InboundDatabaseHelper(IDatabaseGateway? gateway, CancellationToken ct)
|
||||
{ _gateway = gateway; _ct = ct; }
|
||||
/// <summary>
|
||||
/// Initializes the helper.
|
||||
/// </summary>
|
||||
/// <param name="gateway">The central database gateway, or null when no gateway is registered (the helper then throws on first use).</param>
|
||||
/// <param name="ct">The executing method's deadline token; forwarded to every async DB call so a slow query is bounded by the method timeout.</param>
|
||||
/// <param name="commandTimeout">The method timeout; used to derive a <see cref="DbCommand.CommandTimeout"/> backstop. <see cref="TimeSpan.Zero"/> (the default) leaves the provider default.</param>
|
||||
public InboundDatabaseHelper(IDatabaseGateway? gateway, CancellationToken ct, TimeSpan commandTimeout = default)
|
||||
{
|
||||
_gateway = gateway;
|
||||
_ct = ct;
|
||||
_commandTimeoutSeconds = commandTimeout > TimeSpan.Zero
|
||||
? (int)Math.Ceiling(commandTimeout.TotalSeconds)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/// <summary>First column of the first row converted to T (default if no rows).</summary>
|
||||
public T? QuerySingle<T>(string connectionName, string sql, object? parameters = null)
|
||||
/// <summary>First column of the first row converted to <typeparamref name="T"/> (default if no rows).</summary>
|
||||
/// <typeparam name="T">The type to convert the scalar result to.</typeparam>
|
||||
/// <param name="connectionName">Name of a connection configured on the central database gateway.</param>
|
||||
/// <param name="sql">The SQL statement. Values MUST be supplied via <paramref name="parameters"/>, not concatenated.</param>
|
||||
/// <param name="parameters">Optional anonymous object whose properties become bound <c>@</c>-prefixed parameters.</param>
|
||||
/// <returns>The converted scalar, or <c>default</c> when there are no rows / a NULL value.</returns>
|
||||
public async Task<T?> QuerySingleAsync<T>(string connectionName, string sql, object? parameters = null)
|
||||
{
|
||||
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
|
||||
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
AddParameters(cmd, parameters);
|
||||
var result = cmd.ExecuteScalar();
|
||||
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
|
||||
await using var cmd = CreateCommand(conn, sql, parameters);
|
||||
var result = await cmd.ExecuteScalarAsync(_ct);
|
||||
if (result is null or DBNull) return default;
|
||||
if (result is T t) return t;
|
||||
return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>All rows as column→value dictionaries (case-insensitive keys).</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> Query(
|
||||
/// <param name="connectionName">Name of a connection configured on the central database gateway.</param>
|
||||
/// <param name="sql">The SQL statement. Values MUST be supplied via <paramref name="parameters"/>, not concatenated.</param>
|
||||
/// <param name="parameters">Optional anonymous object whose properties become bound <c>@</c>-prefixed parameters.</param>
|
||||
/// <returns>The result rows; empty when nothing matches.</returns>
|
||||
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string connectionName, string sql, object? parameters = null)
|
||||
{
|
||||
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
|
||||
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
AddParameters(cmd, parameters);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
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 rows = new List<IReadOnlyDictionary<string, object?>>();
|
||||
while (reader.Read())
|
||||
while (await reader.ReadAsync(_ct))
|
||||
{
|
||||
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
@@ -56,6 +101,35 @@ public sealed class InboundDatabaseHelper
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a write statement (INSERT/UPDATE/DELETE/DDL) and returns the number of
|
||||
/// rows affected. Writes are authorized for inbound API scripts (InboundAPI-026);
|
||||
/// values are still bound as parameters, never concatenated.
|
||||
/// </summary>
|
||||
/// <param name="connectionName">Name of a connection configured on the central database gateway.</param>
|
||||
/// <param name="sql">The SQL statement. Values MUST be supplied via <paramref name="parameters"/>, not concatenated.</param>
|
||||
/// <param name="parameters">Optional anonymous object whose properties become bound <c>@</c>-prefixed parameters.</param>
|
||||
/// <returns>The number of rows affected.</returns>
|
||||
public async Task<int> ExecuteAsync(string connectionName, string sql, object? parameters = null)
|
||||
{
|
||||
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
|
||||
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
|
||||
await using var cmd = CreateCommand(conn, sql, parameters);
|
||||
return await cmd.ExecuteNonQueryAsync(_ct);
|
||||
}
|
||||
|
||||
private DbCommand CreateCommand(DbConnection conn, string sql, object? parameters)
|
||||
{
|
||||
var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
// InboundAPI-027: 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;
|
||||
|
||||
Reference in New Issue
Block a user