feat(auth): ScadaBridge inbound API — adopt ZB.MOM.WW.Auth.ApiKeys verifier + Bearer + scope=method (re-arch A+B); additive, old path retired later

This commit is contained in:
Joseph Doherty
2026-06-02 02:40:18 -04:00
parent 4db8c373af
commit a94558c289
7 changed files with 451 additions and 157 deletions
@@ -5,6 +5,8 @@ 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;
@@ -18,6 +20,24 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// </summary>
public static class EndpointExtensions
{
/// <summary>
/// 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.
/// </summary>
private const string NotApprovedMessage = "API key not approved for this method";
/// <summary>
/// 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.
/// </summary>
private const string UnauthorizedMessage = "Invalid or missing API key";
/// <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)
@@ -33,39 +53,65 @@ public static class EndpointExtensions
HttpContext httpContext,
string methodName)
{
var logger = httpContext.RequestServices.GetRequiredService<ILogger<ApiKeyValidator>>();
var validator = httpContext.RequestServices.GetRequiredService<ApiKeyValidator>();
var logger = httpContext.RequestServices.GetRequiredService<ILogger<IApiKeyVerifier>>();
var verifier = httpContext.RequestServices.GetRequiredService<IApiKeyVerifier>();
var repository = httpContext.RequestServices.GetRequiredService<IInboundApiRepository>();
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);
// Auth re-arch (A+B): the inbound credential is now a Bearer token
// (Authorization: Bearer sbk_<keyId>_<secret>) verified by the shared
// ZB.MOM.WW.Auth.ApiKeys verifier — peppered-HMAC constant-time secret
// compare is handled inside the library verifier. The raw X-API-Key header
// and the in-repo ApiKeyValidator are retired on this path.
var authorizationHeader = httpContext.Request.Headers.Authorization.ToString();
var verification = await verifier.VerifyAsync(
authorizationHeader, httpContext.RequestAborted);
if (!validationResult.IsValid)
if (!verification.Succeeded)
{
// Telemetry follow-on: count every inbound request, including auth
// failures. The raw {methodName} route value is arbitrary caller input
// and would be high-cardinality, so failures are tagged with a small
// bounded set of sentinels keyed off the validator's status code rather
// than the unvalidated name (401 → "<unauthorized>", 403 → "<forbidden>").
ScadaBridgeTelemetry.RecordInboundApiRequest(
validationResult.StatusCode == StatusCodes.Status401Unauthorized
? "<unauthorized>"
: "<forbidden>");
// 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("<unauthorized>");
// WP-5: Failures-only logging
// WP-5: Failures-only logging.
logger.LogWarning(
"Inbound API auth failure for method {Method}: {Error} (status {StatusCode})",
methodName, validationResult.ErrorMessage, validationResult.StatusCode);
"Inbound API auth failure for method {Method}: {Failure} (status 401)",
methodName, verification.Failure);
return Results.Json(
new { error = validationResult.ErrorMessage },
statusCode: validationResult.StatusCode);
new { error = UnauthorizedMessage },
statusCode: StatusCodes.Status401Unauthorized);
}
var method = validationResult.Method!;
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.
// The method is resolved for execution (script / parameters / timeout), but
// the not-found and not-in-scope branches both return 403 with the SAME
// body. Resolving the method first and 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.
var method = await repository.GetMethodByNameAsync(methodName, httpContext.RequestAborted);
var inScope = identity.Scopes.Contains(methodName);
if (method == null || !inScope)
{
ScadaBridgeTelemetry.RecordInboundApiRequest("<forbidden>");
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
@@ -73,12 +119,12 @@ public static class EndpointExtensions
// set of configured API methods — never the raw caller-supplied route value.
ScadaBridgeTelemetry.RecordInboundApiRequest(method.Name);
// Audit Log (#23 M4 Bundle D): publish the resolved API key name so
// 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 validation succeeded — auth failures leave the
// 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] =
validationResult.ApiKey!.Name;
identity.DisplayName;
// WP-2: Deserialize and validate parameters
JsonElement? body = null;
@@ -25,6 +25,12 @@
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" />
<!-- Inbound-API auth re-arch (A+B): the POST /api/{methodName} auth path is
served by the shared ZB.MOM.WW.Auth.ApiKeys verifier (Bearer sbk_<keyId>_<secret>,
scope = method name) instead of the legacy peppered-HMAC X-API-Key path.
AddZbApiKeyAuth (DI helper) lives in this package and brings the
Abstractions contracts (IApiKeyVerifier / ApiKeyOptions) transitively. -->
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" />
</ItemGroup>
</Project>