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() { } } /// /// POST /api/{methodName} endpoint registration. /// Method routing and parameter validation. /// Script execution on central. /// Error handling — 401, 403, 400, 500. /// public static class EndpointExtensions { /// /// Auth re-arch (A+B), 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"; /// /// C3: builds the canonical inbound-API error body — { "error": message, "code": /// code } — at the given HTTP status. Every error return in /// routes through this helper so the /// machine-readable code field (the audience is external integrators) is /// present and consistent across all failure branches. The error field is /// unchanged, so callers that only read it are unaffected (additive field). /// /// The machine-readable error code (e.g. UNAUTHORIZED, SITE_UNREACHABLE). /// The human-readable, caller-safe error message. /// The HTTP status code for the response. /// A JSON result carrying the error + code at the given status. private static IResult Error(string code, string message, int status) => Results.Json(new { error = message, code }, statusCode: status); /// 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) // 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) { // 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(""); // Failures-only logging. logger.LogWarning( "Inbound API auth failure for method {Method}: {Failure} (status 401)", methodName, verification.Failure); return Error("UNAUTHORIZED", UnauthorizedMessage, 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. // // 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. // // 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 Error("NOT_APPROVED", NotApprovedMessage, 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); // 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; // N5: a body is present when Content-Length says so, OR when the request is // chunked — Transfer-Encoding present and Content-Length (necessarily) absent. // Computed once and used by BOTH the 415 guard and the JSON parse condition so // chunked and fixed-length bodies behave identically. var requestHeaders = httpContext.Request.Headers; var hasBody = httpContext.Request.ContentLength > 0 || (httpContext.Request.ContentLength is null && requestHeaders.TransferEncoding.Count > 0); // S9: a body sent with an explicit non-JSON Content-Type is a media-type // mismatch, not malformed JSON — reject it with 415 rather than a misleading // 400 "invalid JSON". A body with NO Content-Type header is still parsed // leniently as JSON below (preserving existing callers). The sniff is // ordinal-ignore-case so "application/JSON" etc. are treated as JSON. N5: the // body signal is `hasBody`, so a chunked (no Content-Length) non-JSON body is // also caught here instead of slipping through to a misleading 400. if (hasBody && !string.IsNullOrEmpty(httpContext.Request.ContentType) && httpContext.Request.ContentType.Contains("json", StringComparison.OrdinalIgnoreCase) == false) { return Error( "UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json", StatusCodes.Status415UnsupportedMediaType); } // Deserialize and validate parameters JsonElement? body = null; try { // 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 (hasBody || 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 Error("INVALID_JSON", "Invalid JSON in request body", StatusCodes.Status400BadRequest); } // Thread the JSON-Schema $ref resolution seam into parameter // validation so a method whose ParameterDefinitions use a {"$ref":"lib:Name"} // resolves the reference at RUNTIME (not just at deploy time). The shared-schema // library is pre-loaded ONCE per request into an in-memory map (the seam the // validator consumes is synchronous), and ONLY when the definition actually uses // a $ref — a $ref-free method skips the repository round-trip entirely. A dangling // ref surfaces as a clear, descriptive 400 naming the missing reference rather // than an opaque parse-error 400 (the deploy-passes/runtime-breaks defect). var sharedSchemaRepo = httpContext.RequestServices.GetService(); var resolveRef = await SchemaRefResolver.BuildAsync( sharedSchemaRepo, [method.ParameterDefinitions], httpContext.RequestAborted); var paramResult = ParameterValidator.Validate(body, method.ParameterDefinitions, resolveRef); if (!paramResult.IsValid) { return Error( "VALIDATION_FAILED", paramResult.ErrorMessage ?? "Parameter validation failed", StatusCodes.Status400BadRequest); } // Execute the method's script var timeout = method.TimeoutSeconds > 0 ? TimeSpan.FromSeconds(method.TimeoutSeconds) : options.DefaultMethodTimeout; // 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) { // 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; } // 500 for script failures, safe error message logger.LogWarning( "Inbound API script failure for method {Method}: {Error}", methodName, scriptResult.ErrorMessage); // C3: propagate the executor's machine-readable code (TIMEOUT, // SITE_UNREACHABLE, SCRIPT_COMPILE_FAILED, …); an unclassified failure // falls back to the SCRIPT_ERROR catch-all. return Error( scriptResult.ErrorCode ?? "SCRIPT_ERROR", scriptResult.ErrorMessage ?? "Internal server error", StatusCodes.Status500InternalServerError); } // Return the script result as JSON if (scriptResult.ResultJson != null) { return Results.Text(scriptResult.ResultJson, "application/json", statusCode: 200); } return Results.Ok(); } }