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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user