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:
Joseph Doherty
2026-06-23 22:00:17 -04:00
parent d39089f4ed
commit b3c9014379
11 changed files with 540 additions and 68 deletions
@@ -19,6 +19,7 @@ public class RouteHelper
private readonly IInstanceLocator _instanceLocator;
private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
private readonly CancellationToken _requestAbortedToken;
private readonly Guid? _parentExecutionId;
/// <summary>
@@ -29,7 +30,7 @@ public class RouteHelper
public RouteHelper(
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter)
: this(instanceLocator, instanceRouter, CancellationToken.None, parentExecutionId: null)
: this(instanceLocator, instanceRouter, CancellationToken.None, CancellationToken.None, parentExecutionId: null)
{
}
@@ -37,11 +38,13 @@ public class RouteHelper
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
CancellationToken deadlineToken,
CancellationToken requestAbortedToken,
Guid? parentExecutionId)
{
_instanceLocator = instanceLocator;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
_requestAbortedToken = requestAbortedToken;
_parentExecutionId = parentExecutionId;
}
@@ -55,7 +58,20 @@ public class RouteHelper
/// <param name="deadlineToken">The executing method's timeout cancellation token to inherit for routed calls.</param>
/// <returns>A new <see cref="RouteHelper"/> inheriting the given deadline token.</returns>
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
new(_instanceLocator, _instanceRouter, deadlineToken, _parentExecutionId);
new(_instanceLocator, _instanceRouter, deadlineToken, _requestAbortedToken, _parentExecutionId);
/// <summary>
/// InboundAPI-029: returns a <see cref="RouteHelper"/> carrying the raw request-abort
/// token (a client disconnect) <em>separately</em> from the method deadline. Most
/// routed calls remain bounded by the method deadline (which already incorporates the
/// abort), but <see cref="RouteTarget.WaitForAttribute"/> uses this token so its wait
/// is bounded by the WAIT timeout — not the generic method deadline (spec §6) — while
/// still being cancelled by a client disconnect.
/// </summary>
/// <param name="requestAbortedToken">The raw client-disconnect token, independent of the method timeout.</param>
/// <returns>A new <see cref="RouteHelper"/> carrying the given request-abort token.</returns>
public RouteHelper WithRequestAborted(CancellationToken requestAbortedToken) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, requestAbortedToken, _parentExecutionId);
/// <summary>
/// Audit Log #23 (ParentExecutionId): returns a <see cref="RouteHelper"/> whose
@@ -69,7 +85,7 @@ public class RouteHelper
/// <param name="parentExecutionId">The inbound request's execution id to stamp as the spawning parent on routed calls, or null for non-routed runs.</param>
/// <returns>A new <see cref="RouteHelper"/> carrying the given parent execution id.</returns>
public RouteHelper WithParentExecutionId(Guid? parentExecutionId) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, parentExecutionId);
new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, parentExecutionId);
/// <summary>
/// Creates a route target for the specified instance.
@@ -79,7 +95,7 @@ public class RouteHelper
public RouteTarget To(string instanceCode)
{
return new RouteTarget(
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _parentExecutionId);
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId);
}
}
@@ -88,10 +104,18 @@ public class RouteHelper
/// </summary>
public class RouteTarget
{
// InboundAPI-029: a small grace past the wait timeout. The SITE enforces the wait
// timeout and returns Matched=false when it elapses; the local backstop fires only
// if the site fails to respond, so it must sit slightly LATER than the wait timeout
// (it must not pre-empt the site's own timed-out response and turn a clean `false`
// into a cancellation).
private static readonly TimeSpan WaitResponseGrace = TimeSpan.FromSeconds(5);
private readonly string _instanceCode;
private readonly IInstanceLocator _instanceLocator;
private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
private readonly CancellationToken _requestAbortedToken;
private readonly Guid? _parentExecutionId;
/// <summary>
@@ -101,18 +125,21 @@ public class RouteTarget
/// <param name="instanceLocator">Service to resolve the site id for the instance.</param>
/// <param name="instanceRouter">Service to route cross-site calls.</param>
/// <param name="deadlineToken">Cancellation token representing the method-level deadline.</param>
/// <param name="requestAbortedToken">Raw client-disconnect token, independent of the method timeout (InboundAPI-029).</param>
/// <param name="parentExecutionId">Optional parent execution id for audit correlation on routed calls.</param>
internal RouteTarget(
string instanceCode,
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
CancellationToken deadlineToken,
CancellationToken requestAbortedToken,
Guid? parentExecutionId)
{
_instanceCode = instanceCode;
_instanceLocator = instanceLocator;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
_requestAbortedToken = requestAbortedToken;
_parentExecutionId = parentExecutionId;
}
@@ -211,11 +238,22 @@ public class RouteTarget
/// wire: the target is canonically encoded via <see cref="AttributeValueCodec"/> and
/// the site evaluates equality — there is no predicate and no quality flag in the
/// comparison.
///
/// <para>
/// InboundAPI-029: unlike the other routed calls (which inherit the method deadline
/// per InboundAPI-016), the wait is bounded by <paramref name="timeout"/> — the WAIT
/// timeout — NOT the generic method deadline. The SITE enforces <paramref name="timeout"/>
/// and returns <c>Matched=false</c> when it elapses; the local token is built from a
/// per-wait CTS (the wait timeout plus a small grace), an explicit caller token, and
/// the client-disconnect token, but deliberately EXCLUDES the method deadline. So a
/// wait longer than the method timeout runs to its full wait timeout, as spec §6
/// requires, while a client disconnect still cancels it.
/// </para>
/// </summary>
/// <param name="attributeName">Name of the attribute to wait on.</param>
/// <param name="targetValue">Target value the attribute must equal for the wait to match.</param>
/// <param name="timeout">Maximum time to wait for the attribute to reach the target value.</param>
/// <param name="cancellationToken">Optional cancellation token; defaults to the method deadline.</param>
/// <param name="timeout">Maximum time to wait for the attribute to reach the target value; this — not the method deadline — bounds the wait.</param>
/// <param name="cancellationToken">Optional explicit cancellation token (a tighter caller-supplied bound); the wait is otherwise bounded by <paramref name="timeout"/>.</param>
/// <returns>A task that resolves to <c>true</c> if the attribute reached the target value, <c>false</c> if the wait timed out.</returns>
public async Task<bool> WaitForAttribute(
string attributeName,
@@ -223,7 +261,12 @@ public class RouteTarget
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
var token = Effective(cancellationToken);
// InboundAPI-029: bound the wait by the WAIT timeout (+ grace backstop), the
// client-disconnect token, and an explicit caller token — NOT the method deadline.
using var waitCts = new CancellationTokenSource(timeout + WaitResponseGrace);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
waitCts.Token, _requestAbortedToken, cancellationToken);
var token = linked.Token;
var siteId = await ResolveSiteAsync(token);
// Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the