refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,168 @@
using System.Security.Cryptography;
using System.Text;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// WP-1: Validates API keys from X-API-Key header.
/// Checks that the key exists, is enabled, and is approved for the requested method.
/// </summary>
public class ApiKeyValidator
{
private readonly IInboundApiRepository _repository;
private readonly IApiKeyHasher _hasher;
// InboundAPI-011: the single message used for both "method not found" and
// "key not approved" so the two outcomes are indistinguishable to the caller.
private const string NotApprovedMessage = "API key not approved for this method";
/// <param name="repository">Inbound-API data access.</param>
/// <param name="hasher">
/// ConfigurationDatabase-012: hashes the presented candidate key with the same
/// HMAC-SHA256 pepper used at key creation, so authentication compares hashes —
/// the database never holds a plaintext credential. Defaults to the unpeppered
/// <see cref="ApiKeyHasher.Default"/>; production wiring injects the configured,
/// peppered hasher.
/// </param>
public ApiKeyValidator(IInboundApiRepository repository, IApiKeyHasher? hasher = null)
{
_repository = repository;
_hasher = hasher ?? ApiKeyHasher.Default;
}
/// <summary>
/// Validates an API key for a given method.
/// Returns (isValid, apiKey, statusCode, errorMessage).
/// </summary>
/// <param name="apiKeyValue">The API key value from the X-API-Key header.</param>
/// <param name="methodName">The name of the method being invoked.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<ApiKeyValidationResult> ValidateAsync(
string? apiKeyValue,
string methodName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(apiKeyValue))
{
return ApiKeyValidationResult.Unauthorized("Missing X-API-Key header");
}
// InboundAPI-003: do NOT resolve the key with a secret-equality lookup
// (GetApiKeyByValueAsync translates to a SQL "WHERE KeyHash = @hash" early-exit
// comparison — a timing side-channel). Fetch all keys and match in-process
// with a constant-time comparison so neither match position nor length is
// observable to a network attacker.
// ConfigurationDatabase-012: the database stores only the HMAC hash of each
// key, so the presented candidate is hashed with the same pepper and the
// comparison runs over the resulting hashes — never over plaintext.
var candidateHash = _hasher.Hash(apiKeyValue);
var apiKey = FindKeyConstantTime(
await _repository.GetAllApiKeysAsync(cancellationToken),
candidateHash);
if (apiKey == null || !apiKey.IsEnabled)
{
return ApiKeyValidationResult.Unauthorized("Invalid or disabled API key");
}
// InboundAPI-011: "method not found" and "key not approved" must produce an
// indistinguishable response. Otherwise a caller holding any valid key could
// enumerate which method names exist by observing the status/message
// difference. Both cases return 403 with the identical message below, and the
// caller-supplied method name is never echoed back into the response.
var method = await _repository.GetMethodByNameAsync(methodName, cancellationToken);
if (method == null)
{
return ApiKeyValidationResult.Forbidden(NotApprovedMessage);
}
// Check if this key is approved for the method
var approvedKeys = await _repository.GetApprovedKeysForMethodAsync(method.Id, cancellationToken);
var isApproved = approvedKeys.Any(k => k.Id == apiKey.Id);
if (!isApproved)
{
return ApiKeyValidationResult.Forbidden(NotApprovedMessage);
}
return ApiKeyValidationResult.Valid(apiKey, method);
}
/// <summary>
/// InboundAPI-003 / ConfigurationDatabase-012: Finds the key whose stored
/// <see cref="ApiKey.KeyHash"/> matches <paramref name="candidateHash"/> — the
/// HMAC hash of the presented key — using
/// <see cref="CryptographicOperations.FixedTimeEquals"/> over the UTF-8 bytes.
/// Every candidate row is compared so that the running time does not depend on the
/// match position; length mismatches return false without leaking length timing.
/// </summary>
private static ApiKey? FindKeyConstantTime(IEnumerable<ApiKey> keys, string candidateHash)
{
var candidateBytes = Encoding.UTF8.GetBytes(candidateHash);
ApiKey? match = null;
foreach (var key in keys)
{
var keyBytes = Encoding.UTF8.GetBytes(key.KeyHash);
if (CryptographicOperations.FixedTimeEquals(candidateBytes, keyBytes))
{
// Do not break — continuing keeps the loop's timing independent of
// where (or whether) a match is found.
match = key;
}
}
return match;
}
}
/// <summary>
/// Result of API key validation.
/// </summary>
public class ApiKeyValidationResult
{
/// <summary>
/// Whether the API key validation was successful.
/// </summary>
public bool IsValid { get; private init; }
/// <summary>
/// The HTTP status code for the validation result.
/// </summary>
public int StatusCode { get; private init; }
/// <summary>
/// Error message if validation failed, if any.
/// </summary>
public string? ErrorMessage { get; private init; }
/// <summary>
/// The validated API key, if successful.
/// </summary>
public ApiKey? ApiKey { get; private init; }
/// <summary>
/// The validated API method, if successful.
/// </summary>
public ApiMethod? Method { get; private init; }
/// <summary>
/// Creates a successful validation result.
/// </summary>
/// <param name="apiKey">The validated API key.</param>
/// <param name="method">The validated API method.</param>
public static ApiKeyValidationResult Valid(ApiKey apiKey, ApiMethod method) =>
new() { IsValid = true, StatusCode = 200, ApiKey = apiKey, Method = method };
/// <summary>
/// Creates an unauthorized validation result.
/// </summary>
/// <param name="message">The error message.</param>
public static ApiKeyValidationResult Unauthorized(string message) =>
new() { IsValid = false, StatusCode = 401, ErrorMessage = message };
/// <summary>
/// Creates a forbidden validation result.
/// </summary>
/// <param name="message">The error message.</param>
public static ApiKeyValidationResult Forbidden(string message) =>
new() { IsValid = false, StatusCode = 403, ErrorMessage = message };
}
@@ -0,0 +1,38 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// Default <see cref="IInstanceRouter"/> implementation. Delegates every routed
/// call to <see cref="CommunicationService"/>, which dispatches to the target
/// site cluster via the central communication actor.
/// </summary>
public sealed class CommunicationServiceInstanceRouter : IInstanceRouter
{
private readonly CommunicationService _communicationService;
/// <summary>
/// Initializes the router with the central communication service.
/// </summary>
/// <param name="communicationService">Service used to dispatch routed calls to site clusters.</param>
public CommunicationServiceInstanceRouter(CommunicationService communicationService)
{
_communicationService = communicationService;
}
/// <inheritdoc />
public Task<RouteToCallResponse> RouteToCallAsync(
string siteId, RouteToCallRequest request, CancellationToken cancellationToken) =>
_communicationService.RouteToCallAsync(siteId, request, cancellationToken);
/// <inheritdoc />
public Task<RouteToGetAttributesResponse> RouteToGetAttributesAsync(
string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken) =>
_communicationService.RouteToGetAttributesAsync(siteId, request, cancellationToken);
/// <inheritdoc />
public Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken) =>
_communicationService.RouteToSetAttributesAsync(siteId, request, cancellationToken);
}
@@ -0,0 +1,148 @@
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// WP-1: POST /api/{methodName} endpoint registration.
/// WP-2: Method routing and parameter validation.
/// WP-3: Script execution on central.
/// WP-5: Error handling — 401, 403, 400, 500.
/// </summary>
public static class EndpointExtensions
{
/// <summary>Registers the <c>POST /api/{methodName}</c> inbound API endpoint with the active-node gate and body-size filter applied.</summary>
/// <param name="endpoints">The route builder to add the endpoint to.</param>
public static IEndpointRouteBuilder MapInboundAPI(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost("/api/{methodName}", HandleInboundApiRequest)
// InboundAPI-006 / InboundAPI-008: active-node gating + request body
// size cap are enforced by the endpoint filter before the handler runs.
.AddEndpointFilter<InboundApiEndpointFilter>();
return endpoints;
}
private static async Task<IResult> HandleInboundApiRequest(
HttpContext httpContext,
string methodName)
{
var logger = httpContext.RequestServices.GetRequiredService<ILogger<ApiKeyValidator>>();
var validator = httpContext.RequestServices.GetRequiredService<ApiKeyValidator>();
var executor = httpContext.RequestServices.GetRequiredService<InboundScriptExecutor>();
var routeHelper = httpContext.RequestServices.GetRequiredService<RouteHelper>();
var options = httpContext.RequestServices.GetRequiredService<IOptions<InboundApiOptions>>().Value;
// WP-1: Extract and validate API key
var apiKeyValue = httpContext.Request.Headers["X-API-Key"].FirstOrDefault();
var validationResult = await validator.ValidateAsync(apiKeyValue, methodName, httpContext.RequestAborted);
if (!validationResult.IsValid)
{
// WP-5: Failures-only logging
logger.LogWarning(
"Inbound API auth failure for method {Method}: {Error} (status {StatusCode})",
methodName, validationResult.ErrorMessage, validationResult.StatusCode);
return Results.Json(
new { error = validationResult.ErrorMessage },
statusCode: validationResult.StatusCode);
}
var method = validationResult.Method!;
// Audit Log (#23 M4 Bundle D): publish the resolved API key name so
// AuditWriteMiddleware can populate AuditEvent.Actor in its finally
// block. Done AFTER validation succeeded — auth failures leave the
// slot empty and the middleware records the row with Actor=null.
httpContext.Items[AuditWriteMiddleware.AuditActorItemKey] =
validationResult.ApiKey!.Name;
// WP-2: Deserialize and validate parameters
JsonElement? body = null;
try
{
// InboundAPI-020: the content-type sniff must be case-insensitive — a
// request with `application/JSON` or `Application/Json` is still JSON
// and must enter the body-parsing path. The previous case-sensitive
// `Contains("json")` silently skipped JSON deserialization for any
// capitalised value, leaving `body = null` and surfacing required
// parameters as 400 "missing" even though the caller sent a valid body.
if (httpContext.Request.ContentLength > 0
|| httpContext.Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
using var doc = await JsonDocument.ParseAsync(
httpContext.Request.Body, cancellationToken: httpContext.RequestAborted);
body = doc.RootElement.Clone();
}
}
catch (JsonException)
{
return Results.Json(
new { error = "Invalid JSON in request body" },
statusCode: 400);
}
var paramResult = ParameterValidator.Validate(body, method.ParameterDefinitions);
if (!paramResult.IsValid)
{
return Results.Json(
new { error = paramResult.ErrorMessage },
statusCode: 400);
}
// WP-3: Execute the method's script
var timeout = method.TimeoutSeconds > 0
? TimeSpan.FromSeconds(method.TimeoutSeconds)
: options.DefaultMethodTimeout;
// Audit Log #23 (ParentExecutionId): the inbound request's per-request
// ExecutionId was minted early by AuditWriteMiddleware and stashed on
// HttpContext.Items. Thread it into the executor so a routed
// Route.To(...).Call(...) carries it as RouteToCallRequest.ParentExecutionId
// — the spawned site script execution points back at this inbound request.
var parentExecutionId =
httpContext.Items.TryGetValue(
AuditWriteMiddleware.InboundExecutionIdItemKey, out var stashedExecutionId)
&& stashedExecutionId is Guid inboundExecutionId
? inboundExecutionId
: (Guid?)null;
var scriptResult = await executor.ExecuteAsync(
method, paramResult.Parameters, routeHelper, timeout,
httpContext.RequestAborted, parentExecutionId);
if (!scriptResult.Success)
{
// InboundAPI-004: a client-aborted request is not a script failure.
// Do not pollute the failure log (reserved for genuine script errors)
// and do not attempt to write a 500 body to an already-gone connection.
if (httpContext.RequestAborted.IsCancellationRequested)
{
return Results.Empty;
}
// WP-5: 500 for script failures, safe error message
logger.LogWarning(
"Inbound API script failure for method {Method}: {Error}",
methodName, scriptResult.ErrorMessage);
return Results.Json(
new { error = scriptResult.ErrorMessage ?? "Internal server error" },
statusCode: 500);
}
// Return the script result as JSON
if (scriptResult.ResultJson != null)
{
return Results.Text(scriptResult.ResultJson, "application/json", statusCode: 200);
}
return Results.Ok();
}
}
@@ -0,0 +1,211 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// InboundAPI-005: Enforces the ScadaBridge script trust model on inbound API method
/// scripts before they are compiled into executable handlers.
///
/// The trust model (CLAUDE.md, Akka.NET conventions) forbids scripts from reaching
/// <c>System.IO</c>, <c>System.Diagnostics.Process</c>, <c>System.Threading</c>,
/// <c>System.Reflection</c>, and raw network APIs. Roslyn scripting performs no
/// API allow/deny-listing — restricting default imports is a convenience, not a
/// sandbox — so a script can fully-qualify any referenced type. This static check
/// walks the script syntax tree and rejects any reference to a forbidden namespace,
/// whether reached through a <c>using</c> directive or a fully-qualified name.
///
/// <para>
/// InboundAPI-015: a purely namespace-textual deny-list is bypassable because
/// reflection is reachable through members of <em>permitted</em> types that never
/// spell a forbidden namespace, e.g.
/// <c>typeof(string).Assembly.GetType("System.IO.File")</c>. The walker therefore
/// also rejects a curated set of reflection-gateway member names (<c>GetType</c>,
/// <c>Assembly</c>, <c>GetMethod</c>, <c>InvokeMember</c>, <c>CreateInstance</c>, …)
/// and the <c>dynamic</c> keyword. This is hardening of a best-effort static check,
/// <strong>not</strong> a true sandbox — a determined script author may still find
/// a vector the syntax walker cannot see (see the security notes in
/// <c>code-reviews/InboundAPI/findings.md</c>, InboundAPI-015). The check is
/// defence-in-depth; genuine containment needs a runtime boundary (restricted
/// <c>AssemblyLoadContext</c> / curated reference set / out-of-process sandbox).
/// </para>
/// </summary>
public static class ForbiddenApiChecker
{
/// <summary>
/// Namespace prefixes the trust model forbids. A script segment matches if it is
/// equal to one of these or is a child namespace of it.
/// </summary>
private static readonly string[] ForbiddenNamespaces =
{
"System.IO",
"System.Diagnostics", // covers Process
"System.Threading", // Task/Tasks is explicitly re-allowed below
"System.Reflection",
"System.Net", // raw network (Sockets, HttpClient, etc.)
"System.Runtime.InteropServices",
"Microsoft.Win32",
};
/// <summary>
/// Namespaces that would otherwise be caught by a forbidden prefix but are
/// required for normal async script authoring and carry no host-access risk.
/// </summary>
private static readonly string[] AllowedExceptions =
{
"System.Threading.Tasks",
};
/// <summary>
/// InboundAPI-015: member names that are reflection gateways. Reaching any of
/// these — even off a permitted type such as <c>typeof(string)</c> or a plain
/// <c>string</c> — lets a script escape the namespace deny-list (obtain an
/// arbitrary <c>Type</c>, load an assembly, late-bind a method). They are
/// rejected regardless of the receiver expression. <c>Invoke</c> is deliberately
/// excluded because <c>Action</c>/<c>Func</c> delegate invocation is legitimate;
/// the reflection <c>MethodInfo.Invoke</c> path is already cut off by rejecting
/// the <c>GetMethod</c>/<c>GetConstructor</c> that produces the <c>MethodInfo</c>.
/// </summary>
private static readonly HashSet<string> ForbiddenMemberNames = new(StringComparer.Ordinal)
{
"GetType", // object.GetType() / Type.GetType(string) — yields a System.Type
"GetTypeInfo", // -> TypeInfo (reflection)
"Assembly", // Type.Assembly — yields a System.Reflection.Assembly
"Module", // Type.Module / MethodBase.Module
"CreateInstance", // Activator.CreateInstance / Assembly.CreateInstance
"InvokeMember", // Type.InvokeMember — late-bound dispatch
"GetMethod",
"GetMethods",
"GetConstructor",
"GetConstructors",
"GetField",
"GetFields",
"GetProperty",
"GetProperties",
"GetMember",
"GetMembers",
"GetRuntimeMethod",
"GetRuntimeMethods",
"MethodHandle", // RuntimeMethodHandle escape
"TypeHandle",
};
/// <summary>
/// Analyses the script source and returns the list of trust-model violations.
/// An empty list means the script is acceptable.
/// </summary>
/// <param name="scriptCode">The C# script source to analyse.</param>
public static IReadOnlyList<string> FindViolations(string scriptCode)
{
if (string.IsNullOrWhiteSpace(scriptCode))
return Array.Empty<string>();
var tree = CSharpSyntaxTree.ParseText(
scriptCode,
new CSharpParseOptions(kind: SourceCodeKind.Script));
var walker = new ForbiddenApiWalker();
walker.Visit(tree.GetRoot());
return walker.Violations;
}
private static bool IsForbidden(string dottedName)
{
foreach (var allowed in AllowedExceptions)
{
if (dottedName == allowed || dottedName.StartsWith(allowed + ".", StringComparison.Ordinal))
return false;
}
foreach (var forbidden in ForbiddenNamespaces)
{
if (dottedName == forbidden || dottedName.StartsWith(forbidden + ".", StringComparison.Ordinal))
return true;
}
return false;
}
private sealed class ForbiddenApiWalker : CSharpSyntaxWalker
{
private readonly List<string> _violations = new();
/// <summary>Gets the accumulated list of trust-model violation descriptions.</summary>
public IReadOnlyList<string> Violations => _violations;
/// <inheritdoc />
public override void VisitUsingDirective(UsingDirectiveSyntax node)
{
if (node.Name is not null && IsForbidden(node.Name.ToString()))
_violations.Add($"forbidden namespace import '{node.Name}'");
base.VisitUsingDirective(node);
}
/// <inheritdoc />
public override void VisitQualifiedName(QualifiedNameSyntax node)
{
// Check the longest qualified name; do not descend so a single
// System.IO.File reference is reported once, not three times.
var text = node.ToString();
if (IsForbidden(text))
{
_violations.Add($"forbidden type reference '{text}'");
return;
}
base.VisitQualifiedName(node);
}
/// <inheritdoc />
public override void VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
{
// Catches fully-qualified expressions such as System.IO.File.Delete(...).
var text = node.ToString();
if (IsForbidden(text))
{
_violations.Add($"forbidden API access '{text}'");
return;
}
// InboundAPI-015: reject reflection-gateway members regardless of the
// receiver. typeof(string).Assembly.GetType("System.IO.File") never
// spells a forbidden namespace, but '.Assembly' and '.GetType' do
// appear here as the accessed member name.
var memberName = node.Name.Identifier.ValueText;
if (ForbiddenMemberNames.Contains(memberName))
{
_violations.Add($"forbidden reflection member access '.{memberName}'");
// Still descend: the receiver may contain a further violation.
}
base.VisitMemberAccessExpression(node);
}
/// <inheritdoc />
public override void VisitIdentifierName(IdentifierNameSyntax node)
{
// InboundAPI-015: 'dynamic' widens late-bound member access that the
// static walker cannot see through — reject its use outright. The
// 'dynamic' contextual keyword surfaces as an identifier name.
if (node.Identifier.ValueText == "dynamic")
{
_violations.Add("forbidden use of the 'dynamic' keyword");
return;
}
// InboundAPI-015: a bare reference to the reflection entry-point types
// (e.g. 'Activator', 'Type') as an identifier. 'Activator' has no
// non-reflection use; flag it. ('Type' as an identifier is too broad
// to flag here — the gateway members above already cut off its use.)
if (node.Identifier.ValueText == "Activator")
{
_violations.Add("forbidden reflection type reference 'Activator'");
return;
}
base.VisitIdentifierName(node);
}
}
}
@@ -0,0 +1,24 @@
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// InboundAPI-008: abstraction the inbound API endpoint uses to determine whether
/// this node is the active (cluster-leader) central node.
///
/// The design states the inbound API is "Central cluster only (active node)" and
/// "fails over with it". A standby central node must not execute method scripts or
/// <c>Route.To()</c> calls — that can race the active node or run against stale
/// singleton state. <see cref="InboundApiEndpointFilter"/> consults this gate and
/// returns HTTP 503 from a standby so Traefik/clients only reach the live node.
///
/// The implementation lives in the Host (it needs Akka cluster state); when no
/// implementation is registered, the endpoint defaults to "allow" so non-clustered
/// hosts and tests are unaffected.
/// </summary>
public interface IActiveNodeGate
{
/// <summary>
/// <c>true</c> when this node is the active central node and may serve the
/// inbound API; <c>false</c> on a standby node.
/// </summary>
bool IsActiveNode { get; }
}
@@ -0,0 +1,34 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// Seam over the cross-site routing transport used by <see cref="RouteHelper"/>.
/// The production implementation (<see cref="CommunicationServiceInstanceRouter"/>)
/// delegates to <c>ZB.MOM.WW.ScadaBridge.Communication.CommunicationService</c>; the interface
/// exists so <see cref="RouteHelper"/>/<see cref="RouteTarget"/> can be unit tested
/// without a live actor system (InboundAPI-017).
/// </summary>
public interface IInstanceRouter
{
/// <summary>Routes a script call request to the specified site.</summary>
/// <param name="siteId">Target site identifier.</param>
/// <param name="request">The call request to route.</param>
/// <param name="cancellationToken">Cancellation token for the routed call.</param>
Task<RouteToCallResponse> RouteToCallAsync(
string siteId, RouteToCallRequest request, CancellationToken cancellationToken);
/// <summary>Routes a batch attribute read request to the specified site.</summary>
/// <param name="siteId">Target site identifier.</param>
/// <param name="request">The get-attributes request to route.</param>
/// <param name="cancellationToken">Cancellation token for the routed call.</param>
Task<RouteToGetAttributesResponse> RouteToGetAttributesAsync(
string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken);
/// <summary>Routes a batch attribute write request to the specified site.</summary>
/// <param name="siteId">Target site identifier.</param>
/// <param name="request">The set-attributes request to route.</param>
/// <param name="cancellationToken">Cancellation token for the routed call.</param>
Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken);
}
@@ -0,0 +1,84 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// Endpoint filter applied to <c>POST /api/{methodName}</c> that enforces two
/// cross-cutting guards before the request handler runs:
///
/// <list type="bullet">
/// <item><description>
/// InboundAPI-008 — active-node gating. The inbound API is central-active-node-only;
/// a standby node returns HTTP 503 so it never executes method scripts.
/// </description></item>
/// <item><description>
/// InboundAPI-006 — request body size cap. Oversized bodies are rejected with HTTP
/// 413 before being buffered into a <c>JsonDocument</c>.
/// </description></item>
/// </list>
/// </summary>
public sealed class InboundApiEndpointFilter : IEndpointFilter
{
private readonly ILogger<InboundApiEndpointFilter> _logger;
private readonly InboundApiOptions _options;
/// <summary>Initializes a new <see cref="InboundApiEndpointFilter"/> with the given logger and options.</summary>
/// <param name="logger">Logger for request rejection diagnostics.</param>
/// <param name="options">Inbound API options including the maximum request body size.</param>
public InboundApiEndpointFilter(
ILogger<InboundApiEndpointFilter> logger,
IOptions<InboundApiOptions> options)
{
_logger = logger;
_options = options.Value;
}
/// <summary>Applies active-node gating and request body size checks before delegating to the next filter or handler.</summary>
/// <param name="context">The endpoint filter invocation context containing the HTTP context and arguments.</param>
/// <param name="next">The next filter or endpoint handler in the pipeline.</param>
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var httpContext = context.HttpContext;
// InboundAPI-008: refuse to serve the inbound API on a standby central node.
// The gate is optional — when no IActiveNodeGate is registered (non-clustered
// host / tests) the API is served, preserving prior behaviour.
var gate = httpContext.RequestServices.GetService<IActiveNodeGate>();
if (gate is { IsActiveNode: false })
{
_logger.LogWarning(
"Inbound API request rejected — this node is a standby (not the active central node)");
return Results.Json(
new { error = "Inbound API is only available on the active central node" },
statusCode: StatusCodes.Status503ServiceUnavailable);
}
// InboundAPI-006: cap the request body size. Reject an over-limit body up
// front via Content-Length; also lower the per-request max body size so a
// chunked/unknown-length stream is cut off by Kestrel as it is read.
var maxBytes = _options.MaxRequestBodyBytes;
if (httpContext.Request.ContentLength is { } declaredLength && declaredLength > maxBytes)
{
_logger.LogWarning(
"Inbound API request rejected — body length {Length} exceeds limit {Limit}",
declaredLength, maxBytes);
return Results.Json(
new { error = "Request body too large" },
statusCode: StatusCodes.Status413PayloadTooLarge);
}
var sizeFeature = httpContext.Features.Get<IHttpMaxRequestBodySizeFeature>();
if (sizeFeature is { IsReadOnly: false })
{
sizeFeature.MaxRequestBodySize = maxBytes;
}
return await next(context);
}
}
@@ -0,0 +1,35 @@
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
public class InboundApiOptions
{
/// <summary>
/// Default cap on the inbound API request body, in bytes (InboundAPI-006).
/// </summary>
public const long DefaultMaxRequestBodyBytes = 1L * 1024 * 1024; // 1 MiB
/// <summary>Default timeout for inbound API method execution before the request is cancelled.</summary>
public TimeSpan DefaultMethodTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// InboundAPI-006: maximum accepted request body size for <c>POST /api/{methodName}</c>.
/// Requests whose body exceeds this are rejected with HTTP 413 before being
/// buffered into a <see cref="System.Text.Json.JsonDocument"/>. The inbound API
/// has no rate limiting (a deliberate design choice), so an explicit, modest cap
/// bounds per-request allocations.
/// </summary>
public long MaxRequestBodyBytes { get; set; } = DefaultMaxRequestBodyBytes;
/// <summary>
/// ConfigurationDatabase-012: server-side HMAC pepper used to hash inbound-API
/// bearer credentials. API keys are persisted as a deterministic keyed hash, never
/// as plaintext; this pepper is the HMAC key that binds every hash to this
/// deployment, so a stolen configuration database is not directly exploitable.
/// <para>
/// This is a secret: supply a strong, random value via configuration or a secret
/// store, never hard-coded. It must be present and at least
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi.ApiKeyHasher.MinimumPepperLength"/>
/// characters — <c>AddInboundAPI</c> fails fast otherwise.
/// </para>
/// </summary>
public string ApiKeyPepper { get; set; } = string.Empty;
}
@@ -0,0 +1,361 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// WP-3: Executes the C# script associated with an inbound API method.
/// Compiles method scripts via Roslyn and caches compiled delegates.
/// </summary>
public class InboundScriptExecutor
{
private readonly ILogger<InboundScriptExecutor> _logger;
// InboundAPI-001: this executor is registered as a singleton and its handler cache
// is read and written from concurrent ASP.NET request threads. A plain Dictionary is
// not safe for concurrent read/write, so a ConcurrentDictionary is used throughout.
private readonly ConcurrentDictionary<string, Func<InboundScriptContext, Task<object?>>> _scriptHandlers = new();
// InboundAPI-009: a script that fails to compile (or violates the trust model)
// is recorded here so it is compiled at most once. Without this, every subsequent
// request for a broken method re-runs the expensive Roslyn compilation — a CPU
// amplification vector since the inbound API has no rate limiting. The entry is
// cleared whenever the method is (re)compiled via CompileAndRegister.
//
// InboundAPI-024: bound the cache so a spam attack of unique method names cannot
// grow it without bound. Once the cap is reached new bad-method records are
// dropped — the cache is just a fast-fail optimisation; the per-request DB
// lookup remains the correctness path.
private const int KnownBadMethodsCap = 1000;
private readonly ConcurrentDictionary<string, byte> _knownBadMethods = new();
/// <summary>
/// InboundAPI-024 diagnostic helper — returns the current size of the
/// known-bad-methods cache so tests can assert the cap is honoured. Internal
/// so the cache itself stays an implementation detail.
/// </summary>
internal int KnownBadMethodCount => _knownBadMethods.Count;
/// <summary>
/// InboundAPI-024: records <paramref name="methodName"/> in the known-bad-methods
/// cache only if the cache has not reached <see cref="KnownBadMethodsCap"/>.
/// Once full, new records are dropped (paying the cheap recompile next time
/// rather than leaking memory under a unique-name flood). Existing entries are
/// not touched — they remain capped fast-fail records until cleared on a
/// successful (re)compile in <see cref="CompileAndRegister"/>.
/// </summary>
private void TryRecordBadMethod(string methodName)
{
if (_knownBadMethods.ContainsKey(methodName))
return;
if (_knownBadMethods.Count >= KnownBadMethodsCap)
return;
_knownBadMethods.TryAdd(methodName, 0);
}
private readonly IServiceProvider _serviceProvider;
/// <summary>
/// Initializes a new instance of the InboundScriptExecutor.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="serviceProvider">Service provider for dependency resolution.</param>
public InboundScriptExecutor(ILogger<InboundScriptExecutor> logger, IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
}
/// <summary>
/// Registers a compiled script handler for a method name.
/// </summary>
/// <param name="methodName">The method name to register.</param>
/// <param name="handler">The compiled handler function.</param>
public void RegisterHandler(string methodName, Func<InboundScriptContext, Task<object?>> handler)
{
_scriptHandlers[methodName] = handler;
}
/// <summary>
/// Removes a compiled script handler for a method name.
/// </summary>
/// <param name="methodName">The method name to remove.</param>
public void RemoveHandler(string methodName)
{
_scriptHandlers.TryRemove(methodName, out _);
}
/// <summary>
/// Compiles and registers a single API method script. Returns <c>false</c> if the
/// script is empty, fails Roslyn compilation, or violates the script trust model.
/// </summary>
/// <param name="method">The API method to compile and register.</param>
/// <returns>True if successfully compiled and registered, false otherwise.</returns>
public bool CompileAndRegister(ApiMethod method)
{
var handler = Compile(method);
if (handler == null)
{
// InboundAPI-009: record the failure so the lazy-compile path does not
// keep recompiling a broken script on every request. InboundAPI-024:
// routed through the capped TryRecordBadMethod helper so the cache
// cannot grow without bound under a flood of unique method names.
TryRecordBadMethod(method.Name);
return false;
}
// The method definition was (re)compiled successfully — drop any stale
// failure record so a fixed script is no longer treated as bad.
_knownBadMethods.TryRemove(method.Name, out _);
return Register(method.Name, handler);
}
private bool Register(string methodName, Func<InboundScriptContext, Task<object?>> handler)
{
_scriptHandlers[methodName] = handler;
return true;
}
/// <summary>
/// Compiles a single API method script into an executable handler. Returns
/// <c>null</c> when the script is missing, fails to compile, or violates the
/// script trust model (InboundAPI-005). Does not mutate the handler cache.
/// </summary>
private Func<InboundScriptContext, Task<object?>>? Compile(ApiMethod method)
{
if (string.IsNullOrWhiteSpace(method.Script))
{
_logger.LogWarning("API method {Method} has no script code", method.Name);
return null;
}
// InboundAPI-005: enforce the script trust model before compiling. Roslyn
// scripting performs no API allow/deny-listing, so forbidden namespaces must
// be rejected statically or the script could reach the host process.
var violations = ForbiddenApiChecker.FindViolations(method.Script);
if (violations.Count > 0)
{
_logger.LogWarning(
"API method {Method} script rejected — trust model violation(s): {Violations}",
method.Name, string.Join("; ", violations));
return null;
}
try
{
var scriptOptions = ScriptOptions.Default
.WithReferences(
typeof(object).Assembly,
typeof(Enumerable).Assembly,
typeof(Dictionary<,>).Assembly,
typeof(RouteHelper).Assembly,
typeof(ScriptParameters).Assembly,
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly)
.WithImports(
"System",
"System.Collections.Generic",
"System.Linq",
"System.Threading.Tasks");
var compiled = CSharpScript.Create<object?>(
method.Script,
scriptOptions,
globalsType: typeof(InboundScriptContext));
var diagnostics = compiled.Compile();
var errors = diagnostics
.Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error)
.Select(d => d.GetMessage())
.ToList();
if (errors.Count > 0)
{
_logger.LogWarning(
"API method {Method} script compilation failed: {Errors}",
method.Name, string.Join("; ", errors));
return null;
}
_logger.LogInformation("API method {Method} script compiled", method.Name);
return async ctx =>
{
var state = await compiled.RunAsync(ctx, ctx.CancellationToken);
return state.ReturnValue;
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to compile API method {Method} script", method.Name);
return null;
}
}
/// <summary>
/// Executes the script for the given method with the provided context.
/// </summary>
/// <param name="method">The API method containing the script to execute.</param>
/// <param name="parameters">Input parameters for the script.</param>
/// <param name="route">Route helper for cross-site routing.</param>
/// <param name="timeout">Timeout duration for script execution.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="parentExecutionId">
/// Audit Log #23 (ParentExecutionId): the inbound API request's per-request
/// <c>ExecutionId</c> (minted early by <c>AuditWriteMiddleware</c> and stashed
/// on <c>HttpContext.Items</c>). When supplied, a routed
/// <c>Route.To(...).Call(...)</c> inside the script carries it as
/// <see cref="RouteToCallRequest.ParentExecutionId"/> so the spawned site
/// script execution points back at this inbound request. Null when the script
/// runs outside an inbound API request flow.
/// </param>
/// <returns>The result of executing the script.</returns>
public async Task<InboundScriptResult> ExecuteAsync(
ApiMethod method,
IReadOnlyDictionary<string, object?> parameters,
RouteHelper route,
TimeSpan timeout,
CancellationToken cancellationToken = default,
// Deliberate ordering: this optional parameter trails the CancellationToken
// because it was appended additively for non-breaking contract evolution.
// Every call site passes it by named argument (parentExecutionId:).
Guid? parentExecutionId = null)
{
// InboundAPI-004: keep the timeout source and the request-abort source
// separable. A single linked CTS makes a genuine client disconnect
// indistinguishable from a method timeout, so a normal disconnect would be
// logged and reported as "Script execution timed out". Use a dedicated
// timeout CTS, linked with the request token, so the two can be told apart.
using var timeoutCts = new CancellationTokenSource(timeout);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, timeoutCts.Token);
try
{
// InboundAPI-016: bind the route helper to the method deadline so a
// routed Route.To(...).Call(...) inherits the method-level timeout
// without the script having to thread the context token by hand.
//
// Audit Log #23 (ParentExecutionId): also bind the inbound request's
// ExecutionId so a routed call carries it as ParentExecutionId — the
// spawned site script execution points back at this inbound request.
var context = new InboundScriptContext(
parameters,
route.WithDeadline(cts.Token).WithParentExecutionId(parentExecutionId),
cts.Token);
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
{
// InboundAPI-009: a method already known to fail compilation must not
// be recompiled on every request — short-circuit before Roslyn runs.
if (_knownBadMethods.ContainsKey(method.Name))
return new InboundScriptResult(false, null, "Script compilation failed for this method");
// Lazy compile on first request (handles methods created after startup).
// Compile outside the cache so a failed compile is not stored, then add
// atomically so concurrent first-callers share a single handler instance.
var compiled = Compile(method);
if (compiled == null)
{
// Cache the failure so the next request short-circuits above.
// InboundAPI-024: routed through TryRecordBadMethod so the
// cache is bounded under a flood of unique method names.
TryRecordBadMethod(method.Name);
return new InboundScriptResult(false, null, "Script compilation failed for this method");
}
handler = _scriptHandlers.GetOrAdd(method.Name, compiled);
}
var result = await handler(context).WaitAsync(cts.Token);
var resultJson = result != null
? JsonSerializer.Serialize(result)
: null;
// InboundAPI-014: validate the script's return value against the
// method's declared ReturnDefinition. A method whose script returns a
// shape inconsistent with its definition must not silently emit a
// malformed 200 — surface it as a script failure (500) and log.
var returnValidation = ReturnValueValidator.Validate(resultJson, method.ReturnDefinition);
if (!returnValidation.IsValid)
{
_logger.LogWarning(
"API method {Method} return value rejected: {Error}",
method.Name, returnValidation.ErrorMessage);
return new InboundScriptResult(false, null, "Method return value did not match its return definition");
}
return new InboundScriptResult(true, resultJson, null);
}
catch (OperationCanceledException)
{
// InboundAPI-004: distinguish a genuine method timeout from a client
// abort. Only the timeout CTS firing is a real timeout; if the caller's
// request token fired, the client disconnected — do not pollute the
// timeout log (reserved for genuine script-execution timeouts).
if (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("Script execution timed out for method {Method}", method.Name);
return new InboundScriptResult(false, null, "Script execution timed out");
}
_logger.LogDebug("Inbound API request for method {Method} cancelled by client", method.Name);
return new InboundScriptResult(false, null, "Request cancelled by client");
}
catch (Exception ex)
{
_logger.LogError(ex, "Script execution failed for method {Method}", method.Name);
// WP-5: Safe error message, no internal details
return new InboundScriptResult(false, null, "Internal script error");
}
}
}
/// <summary>
/// Context provided to inbound API scripts.
/// </summary>
public class InboundScriptContext
{
/// <summary>
/// The script parameters.
/// </summary>
public ScriptParameters Parameters { get; }
/// <summary>
/// The route helper for cross-site routing.
/// </summary>
public RouteHelper Route { get; }
/// <summary>
/// The cancellation token for script execution.
/// </summary>
public CancellationToken CancellationToken { get; }
/// <summary>
/// Initializes a new instance of the InboundScriptContext.
/// </summary>
/// <param name="parameters">The input parameters for the script.</param>
/// <param name="route">The route helper for cross-site routing.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public InboundScriptContext(
IReadOnlyDictionary<string, object?> parameters,
RouteHelper route,
CancellationToken cancellationToken = default)
{
Parameters = new ScriptParameters(parameters);
Route = route;
CancellationToken = cancellationToken;
}
}
/// <summary>
/// Result of executing an inbound API script.
/// </summary>
public record InboundScriptResult(
bool Success,
string? ResultJson,
string? ErrorMessage);
@@ -0,0 +1,699 @@
using System.Buffers;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
/// <summary>
/// Audit Log #23 (M4 Bundle D, T7) — emits one <see cref="AuditChannel.ApiInbound"/>
/// row per inbound API request via <see cref="ICentralAuditWriter"/> covering the
/// full set of response shapes:
///
/// <list type="bullet">
/// <item><description>2xx / non-error → <see cref="AuditKind.InboundRequest"/> with <see cref="AuditStatus.Delivered"/>.</description></item>
/// <item><description>401/403 → <see cref="AuditKind.InboundAuthFailure"/> with <see cref="AuditStatus.Failed"/>.</description></item>
/// <item><description>4xx (non-auth) / 5xx / thrown exception → <see cref="AuditKind.InboundRequest"/> with <see cref="AuditStatus.Failed"/>.</description></item>
/// </list>
///
/// <para>
/// <b>Best-effort contract (alog.md §13).</b> Audit emission NEVER alters the
/// user-facing HTTP response — a thrown writer or any other failure during
/// emission is caught, logged at warning, and dropped. A handler exception is
/// recorded on the audit row then re-thrown so the framework error path stays
/// authoritative.
/// </para>
///
/// <para>
/// <b>Actor resolution.</b> Inbound API auth runs inside the endpoint handler
/// (no <c>UseAuthentication</c>-backed scheme populates <see cref="HttpContext.User"/>
/// for X-API-Key callers), so the handler stashes the resolved API key name on
/// <see cref="HttpContext.Items"/> under <see cref="AuditActorItemKey"/> after
/// <c>ApiKeyValidator.ValidateAsync</c> succeeds. The middleware reads it in
/// its <c>finally</c> block — on auth failures the key remains absent and
/// <see cref="AuditEvent.Actor"/> stays null (we never echo back an
/// unauthenticated principal).
/// </para>
///
/// <para>
/// <b>Body capture.</b> The request body is buffered via
/// <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/> then
/// rewound so the downstream endpoint handler still sees the full payload. The
/// response body is captured by wrapping <see cref="HttpResponse.Body"/> in a
/// forwarding stream that mirrors writes to the original sink (transparent to
/// the real client) while capturing a bounded copy for audit.
/// </para>
///
/// <para>
/// <b>Bounded capture at the source.</b> Both the request- and response-body
/// audit copies are bounded at <see cref="AuditLogOptions.InboundMaxBytes"/>
/// (default 1 MiB) AT THE CAPTURE SITE — we never buffer more than
/// <c>cap + 1</c> bytes per body even when the client streams hundreds of MiB.
/// The downstream handler and the real client still see every byte; only the
/// audit copy is bounded. The cap is also enforced again by
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.DefaultAuditPayloadFilter"/> (which OR's
/// in its own <see cref="AuditEvent.PayloadTruncated"/> determination), so a
/// row truncated here remains truncated even if the filter is bypassed.
/// </para>
/// </summary>
public sealed class AuditWriteMiddleware
{
/// <summary>
/// <see cref="HttpContext.Items"/> key used by the endpoint handler to publish
/// the resolved API key name once <c>ApiKeyValidator.ValidateAsync</c> has
/// succeeded. Exposed as a constant so the handler and middleware share a
/// single source of truth (no stringly-typed coupling).
/// </summary>
public const string AuditActorItemKey = "ZB.MOM.WW.ScadaBridge.InboundAPI.AuditActor";
/// <summary>
/// Audit Log #23 (ParentExecutionId): <see cref="HttpContext.Items"/> key under
/// which this middleware stashes the inbound request's per-request
/// <c>ExecutionId</c> (a <see cref="Guid"/>) at the very start of the request.
/// The id is minted ONCE and shared: the endpoint handler reads it to thread it
/// onto a routed <c>RouteToCallRequest.ParentExecutionId</c>, and the
/// middleware's own inbound audit row uses the same id for its
/// <see cref="AuditEvent.ExecutionId"/>. Exposed as a constant so the handler
/// and middleware share a single source of truth (no stringly-typed coupling).
/// </summary>
public const string InboundExecutionIdItemKey = "ZB.MOM.WW.ScadaBridge.InboundAPI.InboundExecutionId";
private readonly RequestDelegate _next;
private readonly ICentralAuditWriter _auditWriter;
private readonly ILogger<AuditWriteMiddleware> _logger;
private readonly IOptionsMonitor<AuditLogOptions> _options;
/// <summary>
/// Initializes the middleware with its required dependencies.
/// </summary>
/// <param name="next">The next middleware in the pipeline.</param>
/// <param name="auditWriter">Central audit writer used to persist inbound API audit events.</param>
/// <param name="logger">Logger for this middleware.</param>
/// <param name="options">Live-reloadable audit log options, read per-request.</param>
public AuditWriteMiddleware(
RequestDelegate next,
ICentralAuditWriter auditWriter,
ILogger<AuditWriteMiddleware> logger,
IOptionsMonitor<AuditLogOptions> options)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>
/// Executes the middleware: captures the request/response bodies and writes an inbound API audit event.
/// </summary>
/// <param name="ctx">The current HTTP context.</param>
public async Task InvokeAsync(HttpContext ctx)
{
var sw = Stopwatch.StartNew();
// Per-request hot read of the inbound cap — mirrors the convention used
// by DefaultAuditPayloadFilter so a live config change picks up on the
// next request without re-resolving the singleton.
var cap = _options.CurrentValue.InboundMaxBytes;
// Audit Log #23 (ParentExecutionId): mint the inbound request's per-request
// ExecutionId ONCE, here at the start of the request, and stash it on
// HttpContext.Items. Two consumers share this single id:
// (a) the endpoint handler reads it to thread onto a routed
// RouteToCallRequest.ParentExecutionId, so a spawned site script
// execution points back at this inbound request;
// (b) the inbound audit row this middleware emits uses it as its own
// ExecutionId (the row stays top-level — its ParentExecutionId is
// never set).
ctx.Items[InboundExecutionIdItemKey] = Guid.NewGuid();
// Buffer the request body up front so we can both audit it and let the
// downstream handler still parse it. EnableBuffering swaps the request
// stream for a seekable wrapper that the framework rewinds at the end
// of the pipeline for us — but we also rewind to position 0 after our
// own read so the very next reader starts from the top.
//
// InboundAPI-019: skip EnableBuffering for bodyless requests (a known
// empty Content-Length or a method that conventionally carries no body —
// GET / HEAD / DELETE / TRACE / OPTIONS). The FileBufferingReadStream
// wrapper EnableBuffering installs allocates an internal buffer regardless
// of whether the request actually has a body; bodyless inbound traffic
// (e.g. GET /api/audit/query, health probes) no longer pays that cost.
// ReadBufferedRequestBodyAsync's own ContentLength is 0 short-circuit
// returns (null, false) for the bodyless case anyway, so the audit row
// is unchanged.
var requestBody = (string?)null;
var requestTruncated = false;
if (RequestHasBody(ctx.Request))
{
ctx.Request.EnableBuffering();
(requestBody, requestTruncated) =
await ReadBufferedRequestBodyAsync(ctx.Request, cap).ConfigureAwait(false);
}
// Response body — wrap Response.Body in a forwarding stream that mirrors
// every write to the original sink (transparent to the real client)
// while capturing AT MOST `cap + 1` bytes for the audit copy. The
// original Response.Body is restored in the finally block.
var originalResponseBody = ctx.Response.Body;
using var captureStream = new CapturedResponseStream(originalResponseBody, cap);
ctx.Response.Body = captureStream;
Exception? thrown = null;
try
{
await _next(ctx).ConfigureAwait(false);
}
catch (Exception ex)
{
thrown = ex;
// Re-throw — audit emission is BEST EFFORT, but the user-facing
// request's own error path must remain authoritative (alog.md §13).
throw;
}
finally
{
sw.Stop();
// Restore the original stream and resolve the captured audit copy.
// The forwarding wrapper has already written every byte to the
// original sink; this just pulls back the bounded UTF-8 string.
ctx.Response.Body = originalResponseBody;
var (responseBody, responseTruncated) = captureStream.GetCapturedBody();
EmitInboundAudit(
ctx,
sw.ElapsedMilliseconds,
thrown,
requestBody,
responseBody,
requestTruncated || responseTruncated);
}
}
/// <summary>
/// Builds and writes the <see cref="AuditChannel.ApiInbound"/> row for the
/// request. Wrapped in try/catch so a thrown writer or any other emission
/// failure stays out of the user-facing response (alog.md §13).
/// </summary>
private void EmitInboundAudit(
HttpContext ctx,
long durationMs,
Exception? thrown,
string? requestBody,
string? responseBody,
bool payloadTruncated)
{
try
{
var statusCode = ctx.Response.StatusCode;
var isAuthFailure = statusCode is 401 or 403;
var kind = isAuthFailure
? AuditKind.InboundAuthFailure
: AuditKind.InboundRequest;
// A thrown handler exception is always Failed; otherwise any 4xx/5xx
// response signals failure. 2xx/3xx are Delivered.
var status = (thrown != null || statusCode >= 400)
? AuditStatus.Failed
: AuditStatus.Delivered;
var actor = isAuthFailure ? null : ResolveActor(ctx);
var methodName = ResolveMethodName(ctx);
var extra = JsonSerializer.Serialize(new
{
remoteIp = ctx.Connection.RemoteIpAddress?.ToString(),
userAgent = ctx.Request.Headers.UserAgent.ToString(),
});
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiInbound,
Kind = kind,
// Audit Log #23: the per-request execution id minted ONCE at the
// start of the request (InvokeAsync) and stashed on
// HttpContext.Items. The same id is threaded onto a routed
// RouteToCallRequest.ParentExecutionId by the endpoint handler,
// so an inbound request and the site script it routes to share
// one correlation point. This inbound row stays top-level — its
// own ParentExecutionId is never set (see below).
ExecutionId = ResolveInboundExecutionId(ctx),
// CorrelationId is purely the per-operation-lifecycle id; an
// inbound request is a one-shot from the audit row's
// perspective with no multi-row operation to correlate.
CorrelationId = null,
Actor = actor,
Target = methodName,
Status = status,
HttpStatus = statusCode,
DurationMs = (int)Math.Min(durationMs, int.MaxValue),
ErrorMessage = thrown?.Message,
RequestSummary = requestBody,
ResponseSummary = responseBody,
PayloadTruncated = payloadTruncated,
Extra = extra,
// Central direct-write — no site-local forwarding state.
ForwardState = null,
};
// InboundAPI-018: fire-and-forget the writer so the user-facing
// response stays non-blocking (alog.md §13 — audit emission must
// NEVER abort or delay the user request), but observe the returned
// Task so an asynchronous fault is logged instead of vanishing into
// TaskScheduler.UnobservedTaskException. The outer try/catch still
// catches a synchronous throw before WriteAsync returns a task; the
// ContinueWith only fires on a faulted task and runs off-thread, so
// it does not block the response.
var writeTask = _auditWriter.WriteAsync(evt);
ObserveAuditWriteFault(writeTask, ctx);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"AuditWriteMiddleware emission failed for {Method} {Path} (status {Status})",
ctx.Request.Method, ctx.Request.Path, ctx.Response.StatusCode);
}
}
/// <summary>
/// InboundAPI-018: observe the audit writer's returned <see cref="Task"/>
/// so a fault that surfaces ASYNCHRONOUSLY (e.g. a DB timeout deep in the
/// central audit pipeline) is logged at Warning rather than dropped into
/// <see cref="TaskScheduler.UnobservedTaskException"/>. Stays
/// fire-and-forget so the user-facing response is not delayed — the
/// continuation runs only on a faulted task and writes a single log line
/// off the request thread. A completed-successfully task takes the fast
/// path with no continuation scheduled.
/// </summary>
private void ObserveAuditWriteFault(Task writeTask, HttpContext ctx)
{
if (writeTask.IsCompletedSuccessfully)
{
return;
}
var method = ctx.Request.Method;
var path = ctx.Request.Path;
var status = ctx.Response.StatusCode;
_ = writeTask.ContinueWith(
t => _logger.LogWarning(
t.Exception,
"AuditWriteMiddleware async audit write faulted for {Method} {Path} (status {Status})",
method, path, status),
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
/// <summary>
/// InboundAPI-019: decides whether the request is likely to carry a body, so the
/// caller can skip <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/>
/// (and the associated <c>FileBufferingReadStream</c> allocation) on requests that
/// definitely won't have one. Returns <c>true</c> when <see cref="HttpRequest.ContentLength"/>
/// is positive OR when the HTTP method is one that conventionally carries a body
/// (POST / PUT / PATCH). Bodyless methods (GET / HEAD / DELETE / TRACE / OPTIONS)
/// with an absent or zero Content-Length return <c>false</c> — those are the
/// requests that previously paid the buffering allocation for no benefit. A
/// body-carrying method with no Content-Length (e.g. chunked transfer-encoding)
/// still buffers, so streamed POST bodies are unaffected.
/// </summary>
private static bool RequestHasBody(HttpRequest request)
{
if (request.ContentLength is > 0)
{
return true;
}
var method = request.Method;
return HttpMethods.IsPost(method)
|| HttpMethods.IsPut(method)
|| HttpMethods.IsPatch(method);
}
/// <summary>
/// Reads the buffered request body up to <paramref name="capBytes"/> bytes
/// into a string for the audit copy and rewinds the stream so the
/// downstream handler sees the unconsumed payload. Returns
/// <c>(null, false)</c> for empty/missing bodies so the audit row's
/// <see cref="AuditEvent.RequestSummary"/> stays null rather than
/// containing an empty string.
/// </summary>
/// <remarks>
/// Reads AT MOST <c>cap + 1</c> bytes from the request stream into a
/// scratch buffer; if the extra byte arrives the body is over the cap and
/// the returned string is UTF-8 byte-safe truncated to exactly
/// <c>cap</c> bytes with <c>truncated = true</c>. The cap applies only to
/// the audit copy — the request stream is always rewound to position 0
/// afterwards so the framework's next reader (the endpoint handler's
/// JSON parser) sees the full body.
/// </remarks>
private static async Task<(string? body, bool truncated)> ReadBufferedRequestBodyAsync(
HttpRequest request,
int capBytes)
{
if (request.ContentLength is 0)
{
return (null, false);
}
// Read AT MOST cap + 1 bytes — the extra byte tells us the body was
// over the cap without forcing us to allocate the whole payload. Rent
// the scratch buffer from the shared ArrayPool so we don't allocate
// (and immediately discard) `cap + 1` bytes per request — the pool
// may hand back a buffer LARGER than `limit`, so we treat `limit`
// (not `buffer.Length`) as the read ceiling.
var limit = capBytes + 1;
var buffer = ArrayPool<byte>.Shared.Rent(limit);
try
{
request.Body.Position = 0;
var total = 0;
while (total < limit)
{
var read = await request.Body
.ReadAsync(buffer.AsMemory(total, limit - total))
.ConfigureAwait(false);
if (read == 0)
{
break;
}
total += read;
}
if (total == 0)
{
return (null, false);
}
var truncated = total > capBytes;
var bytesForString = truncated ? capBytes : total;
var content = DecodeUtf8Bounded(buffer, bytesForString, cutAtValidBytes: truncated);
return (string.IsNullOrEmpty(content) ? null : content, truncated);
}
catch
{
// A failed body read must not abort the request — fall through
// with a null RequestSummary; the audit row still records the
// outcome.
return (null, false);
}
finally
{
// Even on a thrown read, the downstream handler must see the full
// body from position 0 — never let a failed audit copy leak a
// truncated view. A rewind failure is swallowed: best-effort,
// same philosophy as the rest of the file.
try { request.Body.Position = 0; } catch { /* swallow */ }
ArrayPool<byte>.Shared.Return(buffer);
}
}
/// <summary>
/// UTF-8 byte-safe decode of <paramref name="validBytes"/> bytes from
/// <paramref name="bytes"/>. When <paramref name="cutAtValidBytes"/> is
/// <c>true</c> the input is the result of a hard byte-count truncation, so
/// we walk back from <c>validBytes</c> while the byte is a continuation
/// byte (<c>byte &amp; 0xC0 == 0x80</c>) to avoid splitting a multi-byte
/// codepoint. When <c>false</c> the caller is decoding the full payload
/// and the boundary stands as-is.
/// </summary>
/// <remarks>
/// Mirrors the algorithm in <c>DefaultAuditPayloadFilter.TruncateUtf8</c>;
/// kept local to avoid a backwards project reference from
/// ZB.MOM.WW.ScadaBridge.AuditLog into ZB.MOM.WW.ScadaBridge.InboundAPI.
/// </remarks>
private static string DecodeUtf8Bounded(byte[] bytes, int validBytes, bool cutAtValidBytes)
{
if (validBytes <= 0)
{
return string.Empty;
}
var boundary = validBytes;
if (cutAtValidBytes)
{
while (boundary > 0 && (bytes[boundary] & 0xC0) == 0x80)
{
boundary--;
}
}
return Encoding.UTF8.GetString(bytes, 0, boundary);
}
/// <summary>
/// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request
/// <c>ExecutionId</c> that <see cref="InvokeAsync"/> minted and stashed on
/// <see cref="HttpContext.Items"/> under <see cref="InboundExecutionIdItemKey"/>.
/// Throws <see cref="InvalidOperationException"/> if the slot is absent — for a
/// correlation feature a silently-divergent id is the worst failure mode, so we
/// fail fast rather than mint a fresh one. <see cref="EmitInboundAudit"/>'s
/// try/catch degrades the throw to a dropped best-effort audit row, never a
/// failed request.
/// </summary>
private static Guid ResolveInboundExecutionId(HttpContext ctx)
{
if (ctx.Items.TryGetValue(InboundExecutionIdItemKey, out var stashed)
&& stashed is Guid id)
{
return id;
}
throw new InvalidOperationException(
"Inbound ExecutionId invariant violated: the inbound ExecutionId must be "
+ "stashed by AuditWriteMiddleware.InvokeAsync before the audit row is emitted.");
}
/// <summary>
/// Reads the API key name the endpoint handler stashed on
/// <see cref="HttpContext.Items"/> after successful auth. Falls back to
/// the authenticated user name when an ASP.NET scheme has populated
/// <see cref="HttpContext.User"/> (defensive — currently unused for inbound
/// API but cheap and forward-compatible).
/// </summary>
private static string? ResolveActor(HttpContext ctx)
{
if (ctx.Items.TryGetValue(AuditActorItemKey, out var stashed)
&& stashed is string name
&& !string.IsNullOrWhiteSpace(name))
{
return name;
}
var user = ctx.User;
if (user?.Identity is { IsAuthenticated: true, Name: { Length: > 0 } userName })
{
return userName;
}
return null;
}
/// <summary>
/// Pulls the <c>{methodName}</c> route value off the request. Falls back to
/// the last segment of <see cref="HttpRequest.Path"/> when no route value
/// is bound (e.g. when the request never reached the matched endpoint).
/// </summary>
private static string? ResolveMethodName(HttpContext ctx)
{
if (ctx.Request.RouteValues.TryGetValue("methodName", out var raw)
&& raw is string method
&& !string.IsNullOrWhiteSpace(method))
{
return method;
}
var path = ctx.Request.Path.Value;
if (string.IsNullOrEmpty(path))
{
return null;
}
var lastSlash = path.LastIndexOf('/');
if (lastSlash < 0 || lastSlash == path.Length - 1)
{
return null;
}
return path[(lastSlash + 1)..];
}
/// <summary>
/// Write-only forwarding <see cref="Stream"/> wrapper that mirrors every
/// write to the inner ASP.NET <see cref="HttpResponse.Body"/> (so the real
/// client receives all bytes) while capturing AT MOST <c>cap + 1</c> bytes
/// into a private bounded <see cref="MemoryStream"/> for the audit copy.
/// </summary>
/// <remarks>
/// <para>
/// The inner sink is owned by the framework and is NOT disposed when this
/// wrapper is disposed — we only own the capture <see cref="MemoryStream"/>.
/// </para>
/// <para>
/// All Write overloads forward to the inner stream FIRST, then capture the
/// remaining quota. If the inner sink throws (e.g. the client disconnects),
/// the exception is allowed to propagate — capture is best-effort, the
/// real I/O is authoritative. The handler-throws-mid-response test
/// (<c>ResponseBody_OnHandlerThrow_BodyCapturedUpToTheThrow</c>) verifies
/// that captured bytes up to the throw are still recoverable.
/// </para>
/// </remarks>
private sealed class CapturedResponseStream : Stream
{
private readonly Stream _inner;
private readonly int _capBytes;
private readonly MemoryStream _captured;
private bool _disposed;
/// <summary>
/// Initializes the capturing stream wrapping an inner sink.
/// </summary>
/// <param name="inner">The underlying response stream to forward writes to.</param>
/// <param name="capBytes">Maximum number of bytes to capture for the audit copy.</param>
public CapturedResponseStream(Stream inner, int capBytes)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_capBytes = Math.Max(0, capBytes);
// Capture up to cap + 1 bytes so we can detect the over-cap case
// without growing the buffer further.
_captured = new MemoryStream();
}
/// <inheritdoc />
public override bool CanRead => false;
/// <inheritdoc />
public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => true;
/// <inheritdoc />
public override long Length =>
throw new NotSupportedException("CapturedResponseStream is write-only.");
/// <inheritdoc />
public override long Position
{
get => throw new NotSupportedException("CapturedResponseStream is write-only.");
set => throw new NotSupportedException("CapturedResponseStream is write-only.");
}
/// <inheritdoc />
public override void Flush() => _inner.Flush();
/// <inheritdoc />
public override Task FlushAsync(CancellationToken cancellationToken) =>
_inner.FlushAsync(cancellationToken);
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count) =>
throw new NotSupportedException("CapturedResponseStream is write-only.");
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin) =>
throw new NotSupportedException("CapturedResponseStream is write-only.");
/// <inheritdoc />
public override void SetLength(long value) =>
throw new NotSupportedException("CapturedResponseStream is write-only.");
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count)
{
// Forward to the real sink FIRST — the client must never miss
// bytes if capture throws.
_inner.Write(buffer, offset, count);
CaptureBytes(buffer.AsSpan(offset, count));
}
/// <inheritdoc />
public override void Write(ReadOnlySpan<byte> buffer)
{
_inner.Write(buffer);
CaptureBytes(buffer);
}
/// <inheritdoc />
public override async Task WriteAsync(
byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
await _inner.WriteAsync(buffer.AsMemory(offset, count), cancellationToken)
.ConfigureAwait(false);
CaptureBytes(buffer.AsSpan(offset, count));
}
/// <inheritdoc />
public override async ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
await _inner.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
CaptureBytes(buffer.Span);
}
/// <summary>
/// Capture up to <c>cap + 1</c> bytes total into the private
/// <see cref="MemoryStream"/>. Once the cap quota is reached, further
/// bytes are silently dropped from the audit copy (the real sink has
/// already received them upstream of this call).
/// </summary>
private void CaptureBytes(ReadOnlySpan<byte> span)
{
if (span.Length == 0)
{
return;
}
var quota = (_capBytes + 1) - (int)_captured.Length;
if (quota <= 0)
{
return;
}
var take = Math.Min(quota, span.Length);
_captured.Write(span[..take]);
}
/// <summary>
/// Returns the captured response body as a UTF-8 string (byte-safe
/// truncated to <c>cap</c> bytes) and a flag indicating whether the
/// audit copy hit the cap. Returns <c>(null, false)</c> when no bytes
/// were captured, mirroring the request-body empty contract.
/// </summary>
public (string? body, bool truncated) GetCapturedBody()
{
var length = (int)_captured.Length;
if (length == 0)
{
return (null, false);
}
var truncated = length > _capBytes;
var bytes = _captured.GetBuffer();
var bytesForString = truncated ? _capBytes : length;
var content = DecodeUtf8Bounded(bytes, bytesForString, cutAtValidBytes: truncated);
return (string.IsNullOrEmpty(content) ? null : content, truncated);
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// Own only the capture stream; the inner sink belongs to
// the framework's response pipeline.
_captured.Dispose();
}
_disposed = true;
}
base.Dispose(disposing);
}
}
}
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Builder;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
/// <summary>
/// <see cref="IApplicationBuilder"/> extensions for wiring
/// <see cref="AuditWriteMiddleware"/> into the ASP.NET Core request pipeline.
/// See <see cref="AuditWriteMiddleware"/> for the placement contract (must run
/// after auth so the resolved API key name is available on
/// <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>, and before the
/// inbound-API endpoint handler that owns script execution).
/// </summary>
public static class AuditWriteMiddlewareExtensions
{
/// <summary>
/// Registers <see cref="AuditWriteMiddleware"/> in the pipeline.
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralAuditWriter"/>
/// must be registered in DI (typically via <c>AddAuditLog</c>) before this
/// middleware runs.
/// </summary>
/// <param name="app">The application builder to add the middleware to.</param>
public static IApplicationBuilder UseAuditWriteMiddleware(this IApplicationBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
return app.UseMiddleware<AuditWriteMiddleware>();
}
}
@@ -0,0 +1,173 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// WP-2: Validates and deserializes JSON request body against method parameter definitions.
/// Extended type system: Boolean, Integer, Float, String, Object, List.
/// </summary>
public static class ParameterValidator
{
/// <summary>
/// Validates the request body against the method's parameter definitions.
/// Returns deserialized parameters or an error message.
/// </summary>
/// <param name="body">The parsed JSON request body; null or undefined if no body was supplied.</param>
/// <param name="parameterDefinitions">JSON-serialized list of <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi.ParameterDefinition"/>; null or empty means no parameters are defined.</param>
public static ParameterValidationResult Validate(
JsonElement? body,
string? parameterDefinitions)
{
if (string.IsNullOrEmpty(parameterDefinitions))
{
// No parameters defined — body should be empty or null
return ParameterValidationResult.Valid(new Dictionary<string, object?>());
}
List<ParameterDefinition> definitions;
try
{
definitions = JsonSerializer.Deserialize<List<ParameterDefinition>>(
parameterDefinitions,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? [];
}
catch (JsonException)
{
return ParameterValidationResult.Invalid("Invalid parameter definitions in method configuration");
}
if (definitions.Count == 0)
{
return ParameterValidationResult.Valid(new Dictionary<string, object?>());
}
if (body == null || body.Value.ValueKind == JsonValueKind.Null || body.Value.ValueKind == JsonValueKind.Undefined)
{
// Check if all parameters are optional
var required = definitions.Where(d => d.Required).ToList();
if (required.Count > 0)
{
return ParameterValidationResult.Invalid(
$"Missing required parameters: {string.Join(", ", required.Select(r => r.Name))}");
}
return ParameterValidationResult.Valid(new Dictionary<string, object?>());
}
if (body.Value.ValueKind != JsonValueKind.Object)
{
return ParameterValidationResult.Invalid("Request body must be a JSON object");
}
var result = new Dictionary<string, object?>();
var errors = new List<string>();
// InboundAPI-010: report top-level body fields that do not match any defined
// parameter, so a caller learns about a typo'd parameter name instead of
// having the field silently ignored.
var defined = new HashSet<string>(definitions.Select(d => d.Name), StringComparer.Ordinal);
var unexpected = body.Value.EnumerateObject()
.Select(p => p.Name)
.Where(name => !defined.Contains(name))
.ToList();
if (unexpected.Count > 0)
{
errors.Add($"Unexpected parameter(s): {string.Join(", ", unexpected)}");
}
foreach (var def in definitions)
{
if (body.Value.TryGetProperty(def.Name, out var prop))
{
var (value, error) = CoerceValue(prop, def.Type, def.Name);
if (error != null)
{
errors.Add(error);
}
else
{
result[def.Name] = value;
}
}
else if (def.Required)
{
errors.Add($"Missing required parameter: {def.Name}");
}
}
if (errors.Count > 0)
{
return ParameterValidationResult.Invalid(string.Join("; ", errors));
}
return ParameterValidationResult.Valid(result);
}
/// <summary>
/// Coerces a JSON element to the declared parameter type. InboundAPI-010: the
/// <c>Object</c> and <c>List</c> extended types are validated for JSON <em>shape</em>
/// only (object vs. array) — there is no field-level or element-level type
/// validation. A method script that needs a specific nested structure must
/// validate it itself; invalid nested data surfaces as a runtime script error.
/// </summary>
private static (object? value, string? error) CoerceValue(JsonElement element, string expectedType, string paramName)
{
return expectedType.ToLowerInvariant() switch
{
"boolean" => element.ValueKind == JsonValueKind.True || element.ValueKind == JsonValueKind.False
? (element.GetBoolean(), null)
: (null, $"Parameter '{paramName}' must be a Boolean"),
"integer" => element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out var intVal)
? (intVal, null)
: (null, $"Parameter '{paramName}' must be an Integer"),
"float" => element.ValueKind == JsonValueKind.Number
? (element.GetDouble(), null)
: (null, $"Parameter '{paramName}' must be a Float"),
"string" => element.ValueKind == JsonValueKind.String
? (element.GetString(), null)
: (null, $"Parameter '{paramName}' must be a String"),
"object" => element.ValueKind == JsonValueKind.Object
? (JsonSerializer.Deserialize<Dictionary<string, object?>>(element.GetRawText()), null)
: (null, $"Parameter '{paramName}' must be an Object"),
"list" => element.ValueKind == JsonValueKind.Array
? (JsonSerializer.Deserialize<List<object?>>(element.GetRawText()), null)
: (null, $"Parameter '{paramName}' must be a List"),
_ => (null, $"Unknown parameter type '{expectedType}' for parameter '{paramName}'")
};
}
}
/// <summary>
/// Result of parameter validation.
/// </summary>
public class ParameterValidationResult
{
/// <summary>Gets a value indicating whether validation succeeded.</summary>
public bool IsValid { get; private init; }
/// <summary>Gets the error message when <see cref="IsValid"/> is false; null on success.</summary>
public string? ErrorMessage { get; private init; }
/// <summary>Gets the validated and type-coerced parameter values keyed by parameter name.</summary>
public IReadOnlyDictionary<string, object?> Parameters { get; private init; } = new Dictionary<string, object?>();
/// <summary>
/// Creates a successful validation result with the given parameters.
/// </summary>
/// <param name="parameters">The validated and coerced parameter values.</param>
public static ParameterValidationResult Valid(Dictionary<string, object?> parameters) =>
new() { IsValid = true, Parameters = parameters };
/// <summary>
/// Creates a failed validation result with the given error message.
/// </summary>
/// <param name="message">Description of the validation failure.</param>
public static ParameterValidationResult Invalid(string message) =>
new() { IsValid = false, ErrorMessage = message };
}
@@ -0,0 +1,153 @@
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// InboundAPI-014: validates a method script's return value against the method's
/// declared <c>ReturnDefinition</c>. <c>Component-InboundAPI.md</c> ("Return Value
/// Definition" / "Response Format") states the success body has "fields matching
/// the return value definition"; this is the response-side mirror of
/// <see cref="ParameterValidator"/>.
///
/// <para>
/// The return definition is a JSON array of <see cref="ReturnFieldDefinition"/>
/// (the same <c>{name,type}</c> shape as a parameter definition). A method whose
/// <c>ReturnDefinition</c> is null/empty is unconstrained — its return value is
/// serialized as-is (backward compatible). Primitive fields (Boolean / Integer /
/// Float / String) are type-checked; the extended <c>Object</c>/<c>List</c> types
/// are shape-checked only (object vs. array), consistent with how
/// <see cref="ParameterValidator"/> treats inbound extended types.
/// </para>
/// </summary>
public static class ReturnValueValidator
{
/// <summary>
/// Validates the serialized script result JSON against the method's return
/// definition. Returns <see cref="ReturnValidationResult.Valid"/> when no
/// definition is configured or the result conforms to it.
/// </summary>
/// <param name="resultJson">The JSON-serialized script return value to validate.</param>
/// <param name="returnDefinition">JSON-serialized list of <see cref="ReturnFieldDefinition"/> entries, or null/empty to skip validation.</param>
public static ReturnValidationResult Validate(string? resultJson, string? returnDefinition)
{
if (string.IsNullOrWhiteSpace(returnDefinition))
{
// No declared return shape — the script's return value is unconstrained.
return ReturnValidationResult.Valid();
}
List<ReturnFieldDefinition> fields;
try
{
fields = JsonSerializer.Deserialize<List<ReturnFieldDefinition>>(
returnDefinition,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? [];
}
catch (JsonException)
{
return ReturnValidationResult.Invalid(
"Invalid return definition in method configuration");
}
if (fields.Count == 0)
{
return ReturnValidationResult.Valid();
}
if (string.IsNullOrWhiteSpace(resultJson))
{
return ReturnValidationResult.Invalid(
"Method declares a return structure but the script returned no value");
}
JsonElement root;
try
{
using var doc = JsonDocument.Parse(resultJson);
root = doc.RootElement.Clone();
}
catch (JsonException)
{
return ReturnValidationResult.Invalid("Script return value is not valid JSON");
}
if (root.ValueKind != JsonValueKind.Object)
{
return ReturnValidationResult.Invalid(
"Method declares a return structure but the script did not return an object");
}
var errors = new List<string>();
foreach (var field in fields)
{
if (!root.TryGetProperty(field.Name, out var value))
{
errors.Add($"missing return field '{field.Name}'");
continue;
}
var typeError = CheckFieldType(value, field.Type, field.Name);
if (typeError != null)
errors.Add(typeError);
}
return errors.Count > 0
? ReturnValidationResult.Invalid(
$"Return value does not match the declared return definition: {string.Join("; ", errors)}")
: ReturnValidationResult.Valid();
}
private static string? CheckFieldType(JsonElement value, string declaredType, string fieldName)
{
// A null value satisfies any field type — the script may legitimately omit
// optional data; only a missing field (handled by the caller) is an error.
if (value.ValueKind == JsonValueKind.Null)
return null;
var ok = declaredType.ToLowerInvariant() switch
{
"boolean" => value.ValueKind is JsonValueKind.True or JsonValueKind.False,
"integer" => value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out _),
"float" => value.ValueKind == JsonValueKind.Number,
"string" => value.ValueKind == JsonValueKind.String,
"object" => value.ValueKind == JsonValueKind.Object,
"list" => value.ValueKind == JsonValueKind.Array,
_ => true, // unknown declared type — do not block the response
};
return ok ? null : $"return field '{fieldName}' must be {declaredType}";
}
}
/// <summary>
/// InboundAPI-014: one field of a method's declared return structure — the
/// deserialized form of an entry in <c>ApiMethod.ReturnDefinition</c>. Defined in
/// this module (not Commons) because the inbound API is currently its only consumer.
/// </summary>
public sealed class ReturnFieldDefinition
{
/// <summary>Field name as it must appear in the script return object.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Expected JSON type of this field (e.g., "string", "integer", "boolean", "object", "list").</summary>
public string Type { get; set; } = "String";
}
/// <summary>
/// Result of validating a script return value against a method's return definition.
/// </summary>
public sealed class ReturnValidationResult
{
/// <summary>True when the return value conforms to the declared return definition.</summary>
public bool IsValid { get; private init; }
/// <summary>Human-readable error message when <see cref="IsValid"/> is false; empty otherwise.</summary>
public string ErrorMessage { get; private init; } = string.Empty;
/// <summary>Returns a successful validation result.</summary>
public static ReturnValidationResult Valid() => new() { IsValid = true };
/// <summary>Returns a failed validation result with the specified error message.</summary>
/// <param name="message">Human-readable description of the validation failure.</param>
public static ReturnValidationResult Invalid(string message) =>
new() { IsValid = false, ErrorMessage = message };
}
@@ -0,0 +1,267 @@
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// WP-4: Route.To() helper for cross-site calls from inbound API scripts.
/// Resolves instance to site, routes via <see cref="IInstanceRouter"/>, blocks until
/// response or timeout. Site unreachable returns error (no store-and-forward).
///
/// InboundAPI-016: the helper carries the executing method's <see cref="CancellationToken"/>
/// (the method-level timeout). Routed calls inherit that deadline by default, so a
/// natural script — <c>Route.To("inst").Call("doWork", p)</c> — is timeout-bounded
/// without the script having to thread a token explicitly.
/// </summary>
public class RouteHelper
{
private readonly IInstanceLocator _instanceLocator;
private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
private readonly Guid? _parentExecutionId;
/// <summary>
/// Initializes a new <see cref="RouteHelper"/> with no deadline token and no parent execution id.
/// </summary>
/// <param name="instanceLocator">Service to resolve the site id for a given instance code.</param>
/// <param name="instanceRouter">Service to route cross-site calls to the resolved site.</param>
public RouteHelper(
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter)
: this(instanceLocator, instanceRouter, CancellationToken.None, parentExecutionId: null)
{
}
private RouteHelper(
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
CancellationToken deadlineToken,
Guid? parentExecutionId)
{
_instanceLocator = instanceLocator;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
_parentExecutionId = parentExecutionId;
}
/// <summary>
/// InboundAPI-016: returns a <see cref="RouteHelper"/> whose routed calls inherit
/// <paramref name="deadlineToken"/> (the executing method's timeout) by default.
/// <see cref="InboundScriptExecutor"/> calls this when it builds the script
/// context so the method timeout actually covers routed calls, as the design doc
/// requires.
/// </summary>
/// <param name="deadlineToken">The executing method's timeout cancellation token to inherit for routed calls.</param>
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
new(_instanceLocator, _instanceRouter, deadlineToken, _parentExecutionId);
/// <summary>
/// Audit Log #23 (ParentExecutionId): returns a <see cref="RouteHelper"/> whose
/// routed <see cref="RouteTarget.Call"/> requests carry
/// <paramref name="parentExecutionId"/> as <see cref="RouteToCallRequest.ParentExecutionId"/>.
/// For an inbound API request this is the inbound request's own per-request
/// execution id, so the routed site script records the inbound request as its
/// parent. <see cref="InboundScriptExecutor"/> calls this when it builds the
/// script context.
/// </summary>
/// <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>
public RouteHelper WithParentExecutionId(Guid? parentExecutionId) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, parentExecutionId);
/// <summary>
/// Creates a route target for the specified instance.
/// </summary>
/// <param name="instanceCode">The unique code of the instance to route calls to.</param>
public RouteTarget To(string instanceCode)
{
return new RouteTarget(
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _parentExecutionId);
}
}
/// <summary>
/// WP-4: Represents a route target (an instance) for cross-site calls.
/// </summary>
public class RouteTarget
{
private readonly string _instanceCode;
private readonly IInstanceLocator _instanceLocator;
private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
private readonly Guid? _parentExecutionId;
/// <summary>
/// Initializes a new <see cref="RouteTarget"/> for the specified instance.
/// </summary>
/// <param name="instanceCode">The unique code of the target instance.</param>
/// <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="parentExecutionId">Optional parent execution id for audit correlation on routed calls.</param>
internal RouteTarget(
string instanceCode,
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
CancellationToken deadlineToken,
Guid? parentExecutionId)
{
_instanceCode = instanceCode;
_instanceLocator = instanceLocator;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
_parentExecutionId = parentExecutionId;
}
/// <summary>
/// Calls a script on the remote instance. Synchronous from API caller's
/// perspective. <paramref name="parameters"/> may be a dictionary or an
/// anonymous object (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
///
/// InboundAPI-016: when <paramref name="cancellationToken"/> is not supplied the
/// routed call inherits the executing method's timeout, so the call is bounded by
/// the method-level deadline with no token argument.
/// </summary>
/// <param name="scriptName">Name of the script to call on the remote instance.</param>
/// <param name="parameters">Optional parameters passed to the script; may be a dictionary or anonymous object.</param>
/// <param name="cancellationToken">Optional cancellation token; defaults to the method deadline when not supplied.</param>
public async Task<object?> Call(
string scriptName,
object? parameters = null,
CancellationToken cancellationToken = default)
{
var token = Effective(cancellationToken);
var siteId = await ResolveSiteAsync(token);
var correlationId = Guid.NewGuid().ToString();
// Audit Log #23 (ParentExecutionId): stamp the spawning execution's id
// (the inbound API request's ExecutionId) so the routed site script
// records this call's parent. CorrelationId above is a separate concern
// — the per-operation lifecycle id, freshly minted per routed call.
var request = new RouteToCallRequest(
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters),
DateTimeOffset.UtcNow, _parentExecutionId);
var response = await _instanceRouter.RouteToCallAsync(siteId, request, token);
if (!response.Success)
{
throw new InvalidOperationException(
response.ErrorMessage ?? "Remote script call failed");
}
return response.ReturnValue;
}
/// <summary>
/// Gets a single attribute value from the remote instance.
/// </summary>
/// <param name="attributeName">Name of the attribute to read.</param>
/// <param name="cancellationToken">Optional cancellation token; defaults to the method deadline.</param>
public async Task<object?> GetAttribute(
string attributeName,
CancellationToken cancellationToken = default)
{
var result = await GetAttributes(new[] { attributeName }, cancellationToken);
return result.TryGetValue(attributeName, out var value) ? value : null;
}
/// <summary>
/// Gets multiple attribute values from the remote instance (batch read).
/// </summary>
/// <param name="attributeNames">Names of the attributes to read.</param>
/// <param name="cancellationToken">Optional cancellation token; defaults to the method deadline.</param>
public async Task<IReadOnlyDictionary<string, object?>> GetAttributes(
IEnumerable<string> attributeNames,
CancellationToken cancellationToken = default)
{
var token = Effective(cancellationToken);
var siteId = await ResolveSiteAsync(token);
var correlationId = Guid.NewGuid().ToString();
// Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the
// spawning inbound request's ExecutionId so future site-side audit
// emission for routed reads can record this read's parent. Symmetric
// with RouteToCallRequest so script authors get the same correlation
// across Call / GetAttributes / SetAttributes.
var request = new RouteToGetAttributesRequest(
correlationId, _instanceCode, attributeNames.ToList(), DateTimeOffset.UtcNow,
_parentExecutionId);
var response = await _instanceRouter.RouteToGetAttributesAsync(siteId, request, token);
if (!response.Success)
{
throw new InvalidOperationException(
response.ErrorMessage ?? "Remote attribute read failed");
}
return response.Values;
}
/// <summary>
/// Sets a single attribute value on the remote instance.
/// </summary>
/// <param name="attributeName">Name of the attribute to write.</param>
/// <param name="value">Value to set on the attribute.</param>
/// <param name="cancellationToken">Optional cancellation token; defaults to the method deadline.</param>
public async Task SetAttribute(
string attributeName,
string value,
CancellationToken cancellationToken = default)
{
await SetAttributes(
new Dictionary<string, string> { { attributeName, value } },
cancellationToken);
}
/// <summary>
/// Sets multiple attribute values on the remote instance (batch write).
/// </summary>
/// <param name="attributeValues">Map of attribute names to values to write.</param>
/// <param name="cancellationToken">Optional cancellation token; defaults to the method deadline.</param>
public async Task SetAttributes(
IReadOnlyDictionary<string, string> attributeValues,
CancellationToken cancellationToken = default)
{
var token = Effective(cancellationToken);
var siteId = await ResolveSiteAsync(token);
var correlationId = Guid.NewGuid().ToString();
// Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the
// spawning inbound request's ExecutionId so future site-side audit
// emission for routed writes can record this write's parent. Symmetric
// with RouteToCallRequest so script authors get the same correlation
// across Call / GetAttributes / SetAttributes.
var request = new RouteToSetAttributesRequest(
correlationId, _instanceCode, attributeValues, DateTimeOffset.UtcNow,
_parentExecutionId);
var response = await _instanceRouter.RouteToSetAttributesAsync(siteId, request, token);
if (!response.Success)
{
throw new InvalidOperationException(
response.ErrorMessage ?? "Remote attribute write failed");
}
}
/// <summary>
/// InboundAPI-016: a routed call with no explicit token inherits the executing
/// method's deadline. An explicitly supplied token (for a tighter bound) wins.
/// </summary>
private CancellationToken Effective(CancellationToken explicitToken) =>
explicitToken.CanBeCanceled ? explicitToken : _deadlineToken;
private async Task<string> ResolveSiteAsync(CancellationToken cancellationToken)
{
var siteId = await _instanceLocator.GetSiteIdForInstanceAsync(_instanceCode, cancellationToken);
if (siteId == null)
{
throw new InvalidOperationException(
$"Instance '{_instanceCode}' not found or has no assigned site");
}
return siteId;
}
}
@@ -0,0 +1,41 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers all inbound API services (API key validator, script executor, route helper, and endpoint filter).
/// </summary>
/// <param name="services">The service collection to register into.</param>
public static IServiceCollection AddInboundAPI(this IServiceCollection services)
{
services.AddScoped<ApiKeyValidator>();
services.AddSingleton<InboundScriptExecutor>();
services.AddScoped<RouteHelper>();
// ConfigurationDatabase-012: API keys are persisted as a deterministic HMAC
// hash, never as plaintext. The hasher is keyed with a server-side pepper
// bound from configuration (InboundApiOptions.ApiKeyPepper). Constructing
// ApiKeyHasher throws if the pepper is missing or weak — so a misconfigured
// deployment fails fast the first time the hasher is resolved rather than
// silently hashing with no pepper.
services.AddSingleton<IApiKeyHasher>(sp =>
{
var options = sp.GetRequiredService<IOptions<InboundApiOptions>>().Value;
return new ApiKeyHasher(options.ApiKeyPepper);
});
// InboundAPI-017: routed calls go through the IInstanceRouter seam; the
// production implementation delegates to CommunicationService.
services.AddScoped<IInstanceRouter, CommunicationServiceInstanceRouter>();
// InboundAPI-006 / InboundAPI-008: endpoint filter enforcing the request
// body size cap and active-node gating for POST /api/{methodName}.
services.AddSingleton<InboundApiEndpointFilter>();
return services;
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj" />
<!-- AuditWriteMiddleware reads AuditLogOptions.InboundMaxBytes to bound
per-request request/response audit capture at the source. -->
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.InboundAPI.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" />
</ItemGroup>
</Project>