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.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Observability; using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware; namespace ZB.MOM.WW.ScadaBridge.InboundAPI; /// /// Review N1: log-category marker for the inbound API endpoint. Used as the type /// argument to so inbound-API auth/authz log /// lines are categorized under this ScadaBridge.InboundAPI type rather than under /// the shared ZB.MOM.WW.Auth library's IApiKeyVerifier. Exists only /// to name the log category ( is static and cannot /// be a generic type argument); it is never instantiated. /// public sealed class InboundApiEndpoint { private InboundApiEndpoint() { } } /// /// 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. /// public static class EndpointExtensions { /// /// Auth re-arch (A+B), InboundAPI-011 successor: the single message used for /// BOTH "method not found" and "key not in scope for this method" so the two /// outcomes are indistinguishable to the caller. A caller holding any valid key /// must not be able to enumerate which method names exist by observing a /// status/message difference, so both branches return 403 with this identical /// body and the caller-supplied method name is never echoed back. /// private const string NotApprovedMessage = "API key not approved for this method"; /// /// Auth re-arch (A+B): the generic 401 message. Every verifier failure reason /// (missing/malformed token, unknown key, revoked key, pepper unavailable, /// secret mismatch) maps to this one message so the auth stage is never leaked /// to the caller. /// private const string UnauthorizedMessage = "Invalid or missing API key"; /// Registers the POST /api/{methodName} inbound API endpoint with the active-node gate and body-size filter applied. /// The route builder to add the endpoint to. /// The same instance for chaining. 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(); return endpoints; } private static async Task HandleInboundApiRequest( HttpContext httpContext, string methodName) { // Review N1: log under an endpoint-owned category (a marker type in the // ScadaBridge.InboundAPI namespace) rather than ILogger, // so inbound-API auth log lines land under ScadaBridge — not the shared // ZB.MOM.WW.Auth library namespace — when operators filter by category. // (EndpointExtensions is static and cannot be an ILogger type argument, so // the namespace-local InboundApiEndpoint marker carries the category.) var logger = httpContext.RequestServices.GetRequiredService>(); var verifier = httpContext.RequestServices.GetRequiredService(); var repository = httpContext.RequestServices.GetRequiredService(); var executor = httpContext.RequestServices.GetRequiredService(); var routeHelper = httpContext.RequestServices.GetRequiredService(); var options = httpContext.RequestServices.GetRequiredService>().Value; // Auth re-arch (A+B) + X-API-Key restore: the inbound credential is accepted // from EITHER the Authorization header ("Bearer sbk__") OR the // alternate "X-API-Key: sbk__" header (raw token). Both are passed // to the SAME shared ZB.MOM.WW.Auth.ApiKeys verifier — the parser strips an // optional "Bearer " prefix and otherwise accepts a bare token, so the // peppered-HMAC constant-time secret compare is identical for both transports. // Authorization takes precedence when both headers are present. var authorizationHeader = httpContext.Request.Headers.Authorization.ToString(); var credential = !string.IsNullOrWhiteSpace(authorizationHeader) ? authorizationHeader : httpContext.Request.Headers["X-API-Key"].ToString(); var verification = await verifier.VerifyAsync( credential, httpContext.RequestAborted); if (!verification.Succeeded) { // WP-5: 401 for any verifier failure. The failure reason is // discriminated for our own logs/telemetry but NEVER surfaced to the // caller — every reason maps to the one generic message so the auth // stage (missing vs unknown-key vs revoked vs secret-mismatch) is not // leaked. ScadaBridgeTelemetry.RecordInboundApiRequest(""); // WP-5: Failures-only logging. logger.LogWarning( "Inbound API auth failure for method {Method}: {Failure} (status 401)", methodName, verification.Failure); return Results.Json( new { error = UnauthorizedMessage }, statusCode: StatusCodes.Status401Unauthorized); } var identity = verification.Identity!; // Auth re-arch (A+B), enumeration-safety: "method not found" and "key not // in scope for this method" MUST produce an indistinguishable response. // Both the not-found and not-in-scope branches return 403 with the SAME // body; folding both negatives into one branch keeps the two cases // byte-identical (status + message), so a caller holding a valid key // cannot probe which method names exist. // // Review #3 (scope-check-before-DB-lookup): the in-memory scope check runs // FIRST and the DB GetMethodByNameAsync only runs when the caller is in // scope. This removes a timing oracle — a not-in-scope caller no longer // pays a DB round-trip whose latency could distinguish "valid scope but no // such method" from "not in scope" — and saves the round-trip entirely on // the reject path. The combined `method == null || !inScope` guard still // emits the single identical 403 body, preserving enumeration-safety. // // Review #2 (scope case-policy): scope strings ARE the method names, and // identity.Scopes.Contains(methodName) is an ordinal, case-SENSITIVE // comparison (HashSet with the default StringComparer.Ordinal). A // scope must therefore match the registered method name's casing EXACTLY — // "Echo" does not grant "echo". This is the intended invariant: method // names are case-sensitive identifiers, and a key's granted scopes must be // provisioned with the exact casing of the methods they authorize. var inScope = identity.Scopes.Contains(methodName); var method = inScope ? await repository.GetMethodByNameAsync(methodName, httpContext.RequestAborted) : null; if (method == null || !inScope) { ScadaBridgeTelemetry.RecordInboundApiRequest(""); logger.LogWarning( "Inbound API authz failure for method {Method}: not approved (status 403)", methodName); return Results.Json( new { error = NotApprovedMessage }, statusCode: StatusCodes.Status403Forbidden); } // Telemetry follow-on: count this inbound request against the resolved, // registered method name. method.Name comes from the repository's method // catalogue (an exact-name lookup), so the `method` tag is bounded to the // set of configured API methods — never the raw caller-supplied route value. ScadaBridgeTelemetry.RecordInboundApiRequest(method.Name); // Audit Log (#23 M4 Bundle D): publish the verified key's display name so // AuditWriteMiddleware can populate AuditEvent.Actor in its finally // block. Done AFTER auth+authz succeeded — auth failures leave the // slot empty and the middleware records the row with Actor=null. httpContext.Items[AuditWriteMiddleware.AuditActorItemKey] = identity.DisplayName; // 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(); } }