diff --git a/src/ScadaLink.Commons/Messages/InboundApi/RouteToInstanceRequest.cs b/src/ScadaLink.Commons/Messages/InboundApi/RouteToInstanceRequest.cs index 1a020b4..90bc6f1 100644 --- a/src/ScadaLink.Commons/Messages/InboundApi/RouteToInstanceRequest.cs +++ b/src/ScadaLink.Commons/Messages/InboundApi/RouteToInstanceRequest.cs @@ -4,12 +4,21 @@ namespace ScadaLink.Commons.Messages.InboundApi; /// Request routed from Inbound API to a site to invoke a script on an instance. /// Used by Route.To("instanceCode").Call("scriptName", params). /// +/// +/// Audit Log #23 (ParentExecutionId): the spawning execution's ExecutionId +/// — for an inbound-API-routed call this is the inbound request's per-request +/// execution id. The site records it as the routed script execution's +/// ParentExecutionId so a spawned execution points back at its spawner. +/// Additive trailing member — null for requests built before the field existed +/// or for routed calls with no spawning execution (e.g. the Central UI sandbox). +/// public record RouteToCallRequest( string CorrelationId, string InstanceUniqueName, string ScriptName, IReadOnlyDictionary? Parameters, - DateTimeOffset Timestamp); + DateTimeOffset Timestamp, + Guid? ParentExecutionId = null); /// /// Response from a Route.To() call. diff --git a/src/ScadaLink.InboundAPI/EndpointExtensions.cs b/src/ScadaLink.InboundAPI/EndpointExtensions.cs index 4daec2c..3319800 100644 --- a/src/ScadaLink.InboundAPI/EndpointExtensions.cs +++ b/src/ScadaLink.InboundAPI/EndpointExtensions.cs @@ -92,8 +92,21 @@ public static class EndpointExtensions ? 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); + method, paramResult.Parameters, routeHelper, timeout, + httpContext.RequestAborted, parentExecutionId); if (!scriptResult.Success) { diff --git a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs index ad969f2..2a68abe 100644 --- a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs +++ b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs @@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Entities.InboundApi; +using ScadaLink.Commons.Messages.InboundApi; using ScadaLink.Commons.Types; namespace ScadaLink.InboundAPI; @@ -156,12 +157,22 @@ public class InboundScriptExecutor /// /// Executes the script for the given method with the provided context. /// + /// + /// Audit Log #23 (ParentExecutionId): the inbound API request's per-request + /// ExecutionId (minted early by AuditWriteMiddleware and stashed + /// on HttpContext.Items). When supplied, a routed + /// Route.To(...).Call(...) inside the script carries it as + /// so the spawned site + /// script execution points back at this inbound request. Null when the script + /// runs outside an inbound API request flow. + /// public async Task ExecuteAsync( ApiMethod method, IReadOnlyDictionary parameters, RouteHelper route, TimeSpan timeout, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Guid? parentExecutionId = null) { // InboundAPI-004: keep the timeout source and the request-abort source // separable. A single linked CTS makes a genuine client disconnect @@ -177,7 +188,14 @@ public class InboundScriptExecutor // 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. - var context = new InboundScriptContext(parameters, route.WithDeadline(cts.Token), cts.Token); + // + // 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)) { diff --git a/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs b/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs index b4b8410..71ce437 100644 --- a/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs +++ b/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs @@ -59,6 +59,18 @@ public sealed class AuditWriteMiddleware /// public const string AuditActorItemKey = "ScadaLink.InboundAPI.AuditActor"; + /// + /// Audit Log #23 (ParentExecutionId): key under + /// which this middleware stashes the inbound request's per-request + /// ExecutionId (a ) 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 RouteToCallRequest.ParentExecutionId, and the + /// middleware's own inbound audit row uses the same id for its + /// . Exposed as a constant so the handler + /// and middleware share a single source of truth (no stringly-typed coupling). + /// + public const string InboundExecutionIdItemKey = "ScadaLink.InboundAPI.InboundExecutionId"; + private readonly RequestDelegate _next; private readonly ICentralAuditWriter _auditWriter; private readonly ILogger _logger; @@ -77,6 +89,17 @@ public sealed class AuditWriteMiddleware { var sw = Stopwatch.StartNew(); + // 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 @@ -145,17 +168,14 @@ public sealed class AuditWriteMiddleware OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiInbound, Kind = kind, - // Audit Log #23: a fresh per-request execution id so the - // inbound row carries a request identifier (closes the design - // gap that inbound rows should be correlatable). - // - // This id is intentionally request-local: it is NOT bridged to - // RouteHelper's routed-call correlation id or to - // HttpContext.TraceIdentifier. Threading an inbound request's - // execution id through to the routed script execution (so an - // inbound call and the outbound API/DB rows it triggers share - // one id) is a deliberate future follow-up, out of scope here. - ExecutionId = Guid.NewGuid(), + // 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. @@ -225,6 +245,24 @@ public sealed class AuditWriteMiddleware } } + /// + /// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request + /// ExecutionId that minted and stashed on + /// under . + /// Falls back to a fresh id only if the slot is somehow absent — the inbound + /// audit row must always carry an execution id. + /// + private static Guid ResolveInboundExecutionId(HttpContext ctx) + { + if (ctx.Items.TryGetValue(InboundExecutionIdItemKey, out var stashed) + && stashed is Guid id) + { + return id; + } + + return Guid.NewGuid(); + } + /// /// Reads the API key name the endpoint handler stashed on /// after successful auth. Falls back to diff --git a/src/ScadaLink.InboundAPI/RouteHelper.cs b/src/ScadaLink.InboundAPI/RouteHelper.cs index dc83d63..6380d13 100644 --- a/src/ScadaLink.InboundAPI/RouteHelper.cs +++ b/src/ScadaLink.InboundAPI/RouteHelper.cs @@ -19,22 +19,25 @@ public class RouteHelper private readonly IInstanceLocator _instanceLocator; private readonly IInstanceRouter _instanceRouter; private readonly CancellationToken _deadlineToken; + private readonly Guid? _parentExecutionId; public RouteHelper( IInstanceLocator instanceLocator, IInstanceRouter instanceRouter) - : this(instanceLocator, instanceRouter, CancellationToken.None) + : this(instanceLocator, instanceRouter, CancellationToken.None, parentExecutionId: null) { } private RouteHelper( IInstanceLocator instanceLocator, IInstanceRouter instanceRouter, - CancellationToken deadlineToken) + CancellationToken deadlineToken, + Guid? parentExecutionId) { _instanceLocator = instanceLocator; _instanceRouter = instanceRouter; _deadlineToken = deadlineToken; + _parentExecutionId = parentExecutionId; } /// @@ -45,14 +48,27 @@ public class RouteHelper /// requires. /// public RouteHelper WithDeadline(CancellationToken deadlineToken) => - new(_instanceLocator, _instanceRouter, deadlineToken); + new(_instanceLocator, _instanceRouter, deadlineToken, _parentExecutionId); + + /// + /// Audit Log #23 (ParentExecutionId): returns a whose + /// routed requests carry + /// as . + /// 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. calls this when it builds the + /// script context. + /// + public RouteHelper WithParentExecutionId(Guid? parentExecutionId) => + new(_instanceLocator, _instanceRouter, _deadlineToken, parentExecutionId); /// /// Creates a route target for the specified instance. /// public RouteTarget To(string instanceCode) { - return new RouteTarget(instanceCode, _instanceLocator, _instanceRouter, _deadlineToken); + return new RouteTarget( + instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _parentExecutionId); } } @@ -65,17 +81,20 @@ public class RouteTarget private readonly IInstanceLocator _instanceLocator; private readonly IInstanceRouter _instanceRouter; private readonly CancellationToken _deadlineToken; + private readonly Guid? _parentExecutionId; internal RouteTarget( string instanceCode, IInstanceLocator instanceLocator, IInstanceRouter instanceRouter, - CancellationToken deadlineToken) + CancellationToken deadlineToken, + Guid? parentExecutionId) { _instanceCode = instanceCode; _instanceLocator = instanceLocator; _instanceRouter = instanceRouter; _deadlineToken = deadlineToken; + _parentExecutionId = parentExecutionId; } /// @@ -96,8 +115,13 @@ public class RouteTarget 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); + correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), + DateTimeOffset.UtcNow, _parentExecutionId); var response = await _instanceRouter.RouteToCallAsync(siteId, request, token); diff --git a/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs b/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs index 5fbc6ee..e8cbf85 100644 --- a/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs +++ b/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ScadaLink.Commons.Entities.InboundApi; using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Messages.InboundApi; namespace ScadaLink.InboundAPI.Tests; @@ -427,6 +428,71 @@ public class InboundScriptExecutorTests Assert.Contains("whatever", result.ResultJson!); } + // --- Audit Log #23 (ParentExecutionId, T3): the inbound request's + // ExecutionId is threaded through ExecuteAsync onto routed calls --- + + [Fact] + public async Task ExecuteAsync_WithParentExecutionId_RoutedCallCarriesItAsParentExecutionId() + { + // The endpoint hands ExecuteAsync the inbound request's ExecutionId; a + // routed Route.To(...).Call(...) inside the script must stamp that id onto + // the RouteToCallRequest as ParentExecutionId. + var inboundExecutionId = Guid.NewGuid(); + + var locator = Substitute.For(); + locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any()).Returns("SiteA"); + var router = Substitute.For(); + RouteToCallRequest? captured = null; + router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) + .Returns(ci => new RouteToCallResponse( + ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); + var route = new RouteHelper(locator, router); + + var method = new ApiMethod("routes", "return 1;") { Id = 1, TimeoutSeconds = 10 }; + _executor.RegisterHandler("routes", async ctx => + { + await ctx.Route.To("inst-1").Call("doWork"); + return 1; + }); + + var result = await _executor.ExecuteAsync( + method, new Dictionary(), route, TimeSpan.FromSeconds(10), + parentExecutionId: inboundExecutionId); + + Assert.True(result.Success, result.ErrorMessage); + Assert.NotNull(captured); + Assert.Equal(inboundExecutionId, captured!.ParentExecutionId); + } + + [Fact] + public async Task ExecuteAsync_WithoutParentExecutionId_RoutedCallHasNullParentExecutionId() + { + // ExecuteAsync called without a parent execution id (the default) routes + // calls with ParentExecutionId null. + var locator = Substitute.For(); + locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any()).Returns("SiteA"); + var router = Substitute.For(); + RouteToCallRequest? captured = null; + router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) + .Returns(ci => new RouteToCallResponse( + ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); + var route = new RouteHelper(locator, router); + + var method = new ApiMethod("routes2", "return 1;") { Id = 1, TimeoutSeconds = 10 }; + _executor.RegisterHandler("routes2", async ctx => + { + await ctx.Route.To("inst-1").Call("doWork"); + return 1; + }); + + var result = await _executor.ExecuteAsync( + method, new Dictionary(), route, TimeSpan.FromSeconds(10)); + + Assert.True(result.Success, result.ErrorMessage); + Assert.NotNull(captured); + Assert.Null(captured!.ParentExecutionId); + } + private sealed class CompileLogCounter { public int CompilationFailures; diff --git a/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs b/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs index 436507f..a28a6f6 100644 --- a/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs +++ b/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs @@ -395,6 +395,78 @@ public class AuditWriteMiddlewareTests Assert.NotEqual(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId); } + // --------------------------------------------------------------------- + // ParentExecutionId — Audit Log #23 (ParentExecutionId feature, T3): the + // inbound request's ExecutionId is minted ONCE, early, and stashed on + // HttpContext.Items so the endpoint handler can carry it onto the routed + // RouteToCallRequest as ParentExecutionId. The inbound row that the + // middleware itself emits stays top-level — its own ParentExecutionId is + // NEVER set. + // --------------------------------------------------------------------- + + [Fact] + public async Task InboundExecutionId_IsStashedOnHttpItems_BeforeEndpointRuns() + { + var writer = new RecordingAuditWriter(); + var ctx = BuildContext(); + object? observedDuringHandler = null; + var mw = CreateMiddleware(hc => + { + // The endpoint handler must be able to read the early-minted id — + // it is stashed before _next so a downstream reader sees it. + hc.Items.TryGetValue(AuditWriteMiddleware.InboundExecutionIdItemKey, out observedDuringHandler); + hc.Response.StatusCode = 200; + return Task.CompletedTask; + }, writer); + + await mw.InvokeAsync(ctx); + + var stashed = Assert.IsType(observedDuringHandler); + Assert.NotEqual(Guid.Empty, stashed); + } + + [Fact] + public async Task InboundRow_ExecutionId_Equals_TheEarlyMintedStashedId() + { + var writer = new RecordingAuditWriter(); + var ctx = BuildContext(); + Guid stashedDuringHandler = Guid.Empty; + var mw = CreateMiddleware(hc => + { + stashedDuringHandler = + (Guid)hc.Items[AuditWriteMiddleware.InboundExecutionIdItemKey]!; + hc.Response.StatusCode = 200; + return Task.CompletedTask; + }, writer); + + await mw.InvokeAsync(ctx); + + // The inbound audit row's ExecutionId must be the SAME id minted early + // and shared with the endpoint handler — not a second, late mint. + var evt = Assert.Single(writer.Events); + Assert.Equal(stashedDuringHandler, evt.ExecutionId); + } + + [Fact] + public async Task InboundRow_OwnParentExecutionId_StaysNull() + { + var writer = new RecordingAuditWriter(); + var ctx = BuildContext(); + var mw = CreateMiddleware(_ => + { + ctx.Response.StatusCode = 200; + return Task.CompletedTask; + }, writer); + + await mw.InvokeAsync(ctx); + + // The inbound request is itself top-level — only the spawn id flows + // OUT on RouteToCallRequest. The inbound row's own ParentExecutionId + // is never set. + var evt = Assert.Single(writer.Events); + Assert.Null(evt.ParentExecutionId); + } + [Fact] public async Task DurationMs_IsRecorded() { diff --git a/tests/ScadaLink.InboundAPI.Tests/RouteHelperTests.cs b/tests/ScadaLink.InboundAPI.Tests/RouteHelperTests.cs index 3feaaaf..8dabaa7 100644 --- a/tests/ScadaLink.InboundAPI.Tests/RouteHelperTests.cs +++ b/tests/ScadaLink.InboundAPI.Tests/RouteHelperTests.cs @@ -233,6 +233,71 @@ public class RouteHelperTests Assert.Equal(explicitCts.Token, seen); } + // --- Audit Log #23 (ParentExecutionId, T3): a routed call carries the + // inbound request's ExecutionId as RouteToCallRequest.ParentExecutionId --- + + [Fact] + public async Task Call_WithoutParentExecutionId_LeavesParentExecutionIdNull() + { + // A RouteHelper not bound to an inbound execution id (e.g. the Central UI + // sandbox path) builds requests with ParentExecutionId null. + SiteResolves("inst-1", "SiteA"); + RouteToCallRequest? captured = null; + _router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) + .Returns(ci => new RouteToCallResponse( + ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); + + await CreateHelper().To("inst-1").Call("doWork"); + + Assert.NotNull(captured); + Assert.Null(captured!.ParentExecutionId); + } + + [Fact] + public async Task Call_WithParentExecutionId_CarriesItOnRouteToCallRequest() + { + // A RouteHelper bound to the inbound request's ExecutionId must stamp that + // id onto the routed RouteToCallRequest so the site script records it as + // its ParentExecutionId. + SiteResolves("inst-1", "SiteA"); + var inboundExecutionId = Guid.NewGuid(); + RouteToCallRequest? captured = null; + _router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) + .Returns(ci => new RouteToCallResponse( + ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); + + var bound = CreateHelper().WithParentExecutionId(inboundExecutionId); + await bound.To("inst-1").Call("doWork"); + + Assert.NotNull(captured); + Assert.Equal(inboundExecutionId, captured!.ParentExecutionId); + // ParentExecutionId is a separate concern from the per-op CorrelationId — + // the helper still mints its own routed-call correlation id. + Assert.True(Guid.TryParse(captured.CorrelationId, out _)); + } + + [Fact] + public async Task WithParentExecutionId_PreservesDeadlineToken() + { + // The two builder methods compose — binding a parent execution id must + // not drop a previously-bound deadline token. + SiteResolves("inst-1", "SiteA"); + using var deadline = new CancellationTokenSource(); + CancellationToken seen = default; + RouteToCallRequest? captured = null; + _router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Do(t => seen = t)) + .Returns(ci => new RouteToCallResponse( + ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); + + var bound = CreateHelper() + .WithDeadline(deadline.Token) + .WithParentExecutionId(Guid.NewGuid()); + await bound.To("inst-1").Call("doWork"); + + Assert.Equal(deadline.Token, seen); + Assert.NotNull(captured!.ParentExecutionId); + } + [Fact] public async Task GetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken() {