From a94558c28944f760e9b8ceb7783bd704013201d4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 02:40:18 -0400 Subject: [PATCH] =?UTF-8?q?feat(auth):=20ScadaBridge=20inbound=20API=20?= =?UTF-8?q?=E2=80=94=20adopt=20ZB.MOM.WW.Auth.ApiKeys=20verifier=20+=20Bea?= =?UTF-8?q?rer=20+=20scope=3Dmethod=20(re-arch=20A+B);=20additive,=20old?= =?UTF-8?q?=20path=20retired=20later?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 8 +- src/ZB.MOM.WW.ScadaBridge.Host/Program.cs | 38 ++ .../EndpointExtensions.cs | 94 +++-- .../ZB.MOM.WW.ScadaBridge.InboundAPI.csproj | 6 + .../EndpointContentTypeTests.cs | 114 ++++-- .../EndpointExtensionsTests.cs | 343 +++++++++++++----- ...MOM.WW.ScadaBridge.InboundAPI.Tests.csproj | 5 + 7 files changed, 451 insertions(+), 157 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a81b4c54..51f96913 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -80,10 +80,10 @@ - - - - + + + + diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index deedecf9..07fa8e26 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection; using ZB.MOM.WW.Health; using ZB.MOM.WW.Health.Akka; using ZB.MOM.WW.Health.EntityFrameworkCore; @@ -106,6 +107,43 @@ try builder.Services.AddSecurity(builder.Configuration); builder.Services.AddCentralUI(); builder.Services.AddInboundAPI(); + + // Inbound-API auth re-arch (A+B), additive: stand up the shared + // ZB.MOM.WW.Auth.ApiKeys verifier + SQLite store + startup migration + // ALONGSIDE the legacy peppered-HMAC X-API-Key path. The POST + // /api/{methodName} endpoint now authenticates Bearer tokens + // (sbk__) and authorizes by scope == method name through + // this verifier. The legacy ApiKeyValidator/IApiKeyHasher remain + // registered (unused by the endpoint) until a later sub-task retires the + // SQL Server ApiKey entity. + // + // ApiKeyOptions is an init-only record, so the contract-mandated values + // are injected as in-memory configuration UNDER the bound section path + // (ScadaBridge:InboundApi:ApiKeyStore) rather than mutated post-bind: + // - TokenPrefix = "sbk" (the inbound token prefix) + // - PepperSecretName points at the EXISTING inbound-API pepper config key + // (reused so one secret backs both the legacy and new path during the + // additive window) + // - RunMigrationsOnStartup = true (hosted service creates the schema) + // - SqlitePath defaults under the content root's data/ directory, but any + // value already supplied via appsettings/env wins (AddInMemoryCollection + // is registered last, but only fills keys the operator did not set + // because we read the existing value first). + const string apiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore"; + var configuredSqlitePath = builder.Configuration[$"{apiKeyStoreSection}:SqlitePath"]; + var apiKeyStoreDefaults = new Dictionary + { + [$"{apiKeyStoreSection}:TokenPrefix"] = "sbk", + [$"{apiKeyStoreSection}:PepperSecretName"] = "ScadaBridge:InboundApi:ApiKeyPepper", + [$"{apiKeyStoreSection}:RunMigrationsOnStartup"] = "true", + [$"{apiKeyStoreSection}:SqlitePath"] = string.IsNullOrWhiteSpace(configuredSqlitePath) + ? Path.Combine(builder.Environment.ContentRootPath, "data", "inbound-api-keys.sqlite") + : configuredSqlitePath, + }; + builder.Configuration.AddInMemoryCollection(apiKeyStoreDefaults); + + builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection); + builder.Services.AddManagementService(); var configDbConnectionString = configuration["ScadaBridge:Database:ConfigurationDb"] diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs index a04c3f2e..fe34b238 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs @@ -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; /// 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. public static IEndpointRouteBuilder MapInboundAPI(this IEndpointRouteBuilder endpoints) @@ -33,39 +53,65 @@ public static class EndpointExtensions HttpContext httpContext, string methodName) { - var logger = httpContext.RequestServices.GetRequiredService>(); - var validator = httpContext.RequestServices.GetRequiredService(); + 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; - // 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__) 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 → "", 403 → ""). - ScadaBridgeTelemetry.RecordInboundApiRequest( - validationResult.StatusCode == StatusCodes.Status401Unauthorized - ? "" - : ""); + // 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 + // 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(""); + + 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; diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj index 3af1480c..b6307013 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj @@ -25,6 +25,12 @@ + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointContentTypeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointContentTypeTests.cs index 31050b49..5c4a3a29 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointContentTypeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointContentTypeTests.cs @@ -1,15 +1,20 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using NSubstitute; +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; +using ZB.MOM.WW.Auth.ApiKeys; +using ZB.MOM.WW.Auth.ApiKeys.Admin; +using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection; +using ZB.MOM.WW.Auth.ApiKeys.Sqlite; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; -using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; using System.Net; using System.Net.Http.Headers; using System.Text; @@ -24,18 +29,22 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// was case-sensitive so a capitalised value silently skipped body parsing /// and any required parameters surfaced as a 400 even though the caller /// sent a valid JSON body. +/// +/// +/// Auth re-arch (A+B): the request carries a Bearer token verified by the shared +/// ZB.MOM.WW.Auth.ApiKeys verifier (scope == method name), not the legacy X-API-Key +/// header. The content-type behaviour under test is downstream of auth and unchanged. +/// /// -public class EndpointContentTypeTests +public sealed class EndpointContentTypeTests : IDisposable { - /// - /// Stub hasher that returns its input unchanged. Lets the test pre-seed the - /// repository with a known "hash" value without depending on the real - /// HMAC-with-pepper hasher. - /// - private sealed class IdentityHasher : IApiKeyHasher - { - public string Hash(string keyValue) => keyValue; - } + private const string Pepper = "test-pepper-at-least-16-chars-long"; + private const string PepperConfigKey = "ScadaBridge:InboundApi:ApiKeyPepper"; + private const string TokenPrefix = "sbk"; + private const string ApiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore"; + + private readonly string _sqlitePath = + Path.Combine(Path.GetTempPath(), $"inbound-api-keys-ct-{Guid.NewGuid():N}.sqlite"); [Theory] [InlineData("application/json")] @@ -44,13 +53,8 @@ public class EndpointContentTypeTests [InlineData("APPLICATION/JSON")] public async Task ContentTypeCheck_IsCaseInsensitive_ParsesBodyForAnyCasing(string contentType) { - const string apiKeyValue = "test-key"; const string methodName = "echoParam"; - var key = ApiKey.FromHash("test", apiKeyValue); - key.IsEnabled = true; - key.Id = 1; - var method = new ApiMethod(methodName, "return Parameters[\"value\"];") { Id = 1, @@ -62,25 +66,22 @@ public class EndpointContentTypeTests }; var repo = Substitute.For(); - repo.GetAllApiKeysAsync(Arg.Any()) - .Returns(new List { key }); repo.GetMethodByNameAsync(methodName, Arg.Any()) .Returns(method); - repo.GetApprovedKeysForMethodAsync(method.Id, Arg.Any()) - .Returns(new List { key }); using var host = await BuildHostAsync(repo); + var token = await SeedKeyAsync(host, methodName); var client = host.GetTestClient(); var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName) { // Bypass HttpClient's MediaTypeHeaderValue auto-normalization by - // setting the header through TryAddWithoutValidation — we need the - // exact casing reach the server intact. + // setting the header through MediaTypeHeaderValue.Parse — we need the + // exact casing to reach the server intact. Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}")) }; request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); - request.Headers.Add("X-API-Key", apiKeyValue); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); @@ -91,33 +92,56 @@ public class EndpointContentTypeTests Assert.Contains("42", body); } - private static async Task BuildHostAsync(IInboundApiRepository repo) + /// Seeds a key scoped for and returns its Bearer token. + private static async Task SeedKeyAsync(IHost host, string methodName) { + var services = host.Services; + var commands = new ApiKeyAdminCommands( + services.GetRequiredService>().Value, + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + + var result = await commands.CreateKeyAsync( + "key1", "ct-caller", new HashSet { methodName }, + constraintsJson: null, remoteAddress: null, CancellationToken.None); + return result.Token!; + } + + private async Task BuildHostAsync(IInboundApiRepository repo) + { + // The pepper provider reads the HOST's IConfiguration (AddZbApiKeyAuth only + // TryAdds its own), so the api-key settings — pepper included — must live in + // the host configuration. + var apiKeySettings = new Dictionary + { + [PepperConfigKey] = Pepper, + [$"{ApiKeyStoreSection}:TokenPrefix"] = TokenPrefix, + [$"{ApiKeyStoreSection}:PepperSecretName"] = PepperConfigKey, + [$"{ApiKeyStoreSection}:SqlitePath"] = _sqlitePath, + [$"{ApiKeyStoreSection}:RunMigrationsOnStartup"] = "true", + }; + var hostBuilder = new HostBuilder() + .ConfigureAppConfiguration(config => config.AddInMemoryCollection(apiKeySettings)) .ConfigureWebHost(webBuilder => { webBuilder .UseTestServer() - .ConfigureServices(services => + .ConfigureServices((context, services) => { services.AddRouting(); services.AddSingleton(repo); // RouteHelper depends on IInstanceLocator + IInstanceRouter // (InboundAPI-017). Tests for content-type handling never - // route, so both can be no-op stubs — the production - // CommunicationServiceInstanceRouter would need a real - // CommunicationService which isn't wired here. + // route, so both can be no-op stubs. services.AddSingleton(Substitute.For()); services.Configure(_ => { }); services.AddInboundAPI(); + services.AddZbApiKeyAuth(context.Configuration, ApiKeyStoreSection); services.RemoveAll(); services.AddSingleton(Substitute.For()); - // The production AddInboundAPI registration of IApiKeyHasher - // requires a configured pepper. Replace it with the identity - // stub so the seeded ApiKey.KeyHash matches "test-key" - // deterministically without depending on configuration. - services.RemoveAll(); - services.AddSingleton(new IdentityHasher()); services.AddLogging(); }) .Configure(app => @@ -129,4 +153,24 @@ public class EndpointContentTypeTests return await hostBuilder.StartAsync(); } + + public void Dispose() + { + try + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + foreach (var suffix in new[] { "", "-wal", "-shm" }) + { + var path = _sqlitePath + suffix; + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + catch + { + // Best-effort cleanup. + } + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs index a5a14fcb..526be1e4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs @@ -2,50 +2,66 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using NSubstitute; +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; +using ZB.MOM.WW.Auth.ApiKeys; +using ZB.MOM.WW.Auth.ApiKeys.Admin; +using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection; +using ZB.MOM.WW.Auth.ApiKeys.Sqlite; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Observability; -using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware; using System.Diagnostics.Metrics; using System.Net; +using System.Net.Http.Headers; using System.Text; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// -/// InboundAPI-023: is -/// the composition wiring that ties validator → JSON parse → ParameterValidator → -/// InboundScriptExecutor → response shaping together. Each composed component -/// has its own unit tests, but the wiring itself was uncovered. These tests -/// drive the end-to-end POST /api/{methodName} flow through a TestServer so a -/// regression in any of the seams below would be caught here: +/// Auth re-arch (A+B): now +/// authenticates with the shared ZB.MOM.WW.Auth.ApiKeys verifier — a Bearer +/// token (Authorization: Bearer sbk_<keyId>_<secret>) replaces the +/// raw X-API-Key header, and a key's Scopes set (the API-method names it may +/// call) replaces the per-method approval table for authorization. These tests drive +/// the end-to-end POST /api/{methodName} flow through a TestServer, seeding the +/// library SQLite store via , so a regression in any +/// of the seams below would be caught here: /// -/// 1. happy path — 200 + script result body -/// 2. auth failures — validator status code propagates verbatim -/// 3. invalid JSON body — 400 + sanitized error -/// 4. parameter validation failure — 400 + ParameterValidator's error message -/// 5. script failure — 500 + ErrorMessage in body -/// 6. successful auth must publish the resolved API key name into +/// 1. happy path — valid Bearer + in-scope → 200 + script result body. +/// 2. valid key, method NOT in scope → 403 (authz). +/// 3. unknown method → 403 with the SAME body as not-in-scope +/// (enumeration-safety — neither the status nor the message reveals which). +/// 4. missing / garbage Bearer → 401 (generic, no stage leak). +/// 5. revoked key → 401. +/// 6. invalid JSON body → 400 + sanitized error. +/// 7. parameter validation failure → 400 + ParameterValidator's error message. +/// 8. script failure → 500 + ErrorMessage in body. +/// 9. successful auth must publish the verified key's DISPLAY NAME into /// HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] (so the /// AuditWriteMiddleware sees a non-null Actor when it emits the audit row). /// -public class EndpointExtensionsTests +public sealed class EndpointExtensionsTests : IDisposable { - /// - /// Stub hasher that returns its input unchanged. Same pattern as - /// — lets us seed an ApiKey with a - /// known "hash" without depending on the configured HMAC pepper. - /// - private sealed class IdentityHasher : IApiKeyHasher - { - public string Hash(string keyValue) => keyValue; - } + // The pepper used to back the seeded keys. The verifier resolves it from + // configuration under the name ApiKeyOptions.PepperSecretName; the seed path + // resolves it through the same IApiKeyPepperProvider, so both agree. + private const string Pepper = "test-pepper-at-least-16-chars-long"; + private const string PepperConfigKey = "ScadaBridge:InboundApi:ApiKeyPepper"; + private const string TokenPrefix = "sbk"; + private const string ApiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore"; + + // Each test gets its own throwaway SQLite database so seeded keys never leak + // between tests; the file is deleted on Dispose. + private readonly string _sqlitePath = + Path.Combine(Path.GetTempPath(), $"inbound-api-keys-{Guid.NewGuid():N}.sqlite"); /// /// Inline middleware that captures the value at @@ -58,16 +74,6 @@ public class EndpointExtensionsTests public string? CapturedActor { get; set; } } - private const string ApiKeyValue = "test-key"; - - private static ApiKey SeedKey(int id = 1, string name = "test") - { - var key = ApiKey.FromHash(name, ApiKeyValue); - key.IsEnabled = true; - key.Id = id; - return key; - } - private static ApiMethod SeedMethod( int id, string name, string script, string? paramDefs = null) { @@ -80,16 +86,18 @@ public class EndpointExtensionsTests } [Fact] - public async Task HappyPath_Returns200WithScriptResultJson() + public async Task HappyPath_ValidBearerInScope_Returns200WithScriptResultJson() { - var key = SeedKey(); var method = SeedMethod(1, "echo", "return Parameters[\"value\"];", """[{"name":"value","type":"Integer","required":true}]"""); - using var host = await BuildHostAsync(key, method); + using var host = await BuildHostAsync(method); + // Seed a key whose scope set contains the method name "echo". + var token = await SeedKeyAsync(host, keyId: "key1", displayName: "echo-caller", + scopes: new[] { "echo" }); var client = host.GetTestClient(); - var request = BuildPost("echo", """{"value":7}"""); + var request = BuildPost("echo", """{"value":7}""", token); var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); @@ -98,15 +106,15 @@ public class EndpointExtensionsTests } [Fact] - public async Task MissingApiKey_Returns401() + public async Task MissingBearer_Returns401() { - var key = SeedKey(); var method = SeedMethod(1, "noKey", "return 1;"); - using var host = await BuildHostAsync(key, method); + using var host = await BuildHostAsync(method); + await SeedKeyAsync(host, "key1", "noKey-caller", new[] { "noKey" }); var client = host.GetTestClient(); - // No X-API-Key header — auth should reject with 401. + // No Authorization header — auth should reject with 401. var request = new HttpRequestMessage(HttpMethod.Post, "/api/noKey") { Content = new StringContent("{}", Encoding.UTF8, "application/json"), @@ -117,32 +125,104 @@ public class EndpointExtensionsTests } [Fact] - public async Task UnknownMethod_Returns403_IndistinguishableFromNotApproved() + public async Task GarbageBearer_Returns401() { - // InboundAPI-011: method existence is intentionally not observable — - // both "method not found" and "key not approved" surface as 403. - var key = SeedKey(); - var method = SeedMethod(1, "knownMethod", "return 1;"); + var method = SeedMethod(1, "noKey", "return 1;"); - using var host = await BuildHostAsync(key, method); + using var host = await BuildHostAsync(method); + await SeedKeyAsync(host, "key1", "noKey-caller", new[] { "noKey" }); var client = host.GetTestClient(); - var request = BuildPost("unknownMethod", "{}"); + // A well-formed Authorization header carrying a non-parseable / wrong-prefix + // token is indistinguishable from a missing credential — still 401. + var request = new HttpRequestMessage(HttpMethod.Post, "/api/noKey") + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-a-real-token"); + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task RevokedKey_Returns401() + { + var method = SeedMethod(1, "echo", "return 1;"); + + using var host = await BuildHostAsync(method); + // Seed-then-revoke: the token is well-formed and the secret matches, but + // the key is revoked — the verifier fails closed and the endpoint maps it + // to the generic 401 (no "revoked" stage leak). + var token = await SeedKeyAsync(host, "key1", "echo-caller", new[] { "echo" }); + await RevokeKeyAsync(host, "key1"); + var client = host.GetTestClient(); + + var request = BuildPost("echo", "{}", token); + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task ValidKey_MethodNotInScope_Returns403() + { + // The key authenticates fine, but its scope set does NOT contain the + // requested method name — authorization fails with 403. + var method = SeedMethod(1, "secured", "return 1;"); + + using var host = await BuildHostAsync(method); + // Scope grants "otherMethod", not "secured". + var token = await SeedKeyAsync(host, "key1", "limited-caller", new[] { "otherMethod" }); + var client = host.GetTestClient(); + + var request = BuildPost("secured", "{}", token); var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] - public async Task InvalidJsonBody_Returns400() + public async Task UnknownMethod_Returns403_WithIdenticalBodyToNotInScope() { - var key = SeedKey(); - var method = SeedMethod(1, "badJson", "return 1;"); + // Enumeration-safety: "method not found" and "key not in scope" must be + // indistinguishable — same 403 status AND same response body. We drive both + // cases through the same valid key and assert byte-identical bodies. + var method = SeedMethod(1, "knownMethod", "return 1;"); - using var host = await BuildHostAsync(key, method); + using var host = await BuildHostAsync(method); + // Key is in scope for "knownMethod" only — so: + // - posting "unknownMethod" → method-not-found 403 + // - posting "knownMethod" with a key scoped elsewhere would be not-in-scope 403 + var unknownToken = await SeedKeyAsync(host, "key1", "caller", new[] { "unknownMethod" }); + var notInScopeToken = await SeedKeyAsync(host, "key2", "caller2", new[] { "somethingElse" }); var client = host.GetTestClient(); - var request = BuildPost("badJson", "{ not json"); + // (a) unknown method — the key IS in scope for "unknownMethod" (so scope + // passes) but no such method exists in the repository → method-not-found 403. + var unknownResponse = await client.SendAsync(BuildPost("unknownMethod", "{}", unknownToken)); + var unknownBody = await unknownResponse.Content.ReadAsStringAsync(); + + // (b) known method but key not in scope for it → not-in-scope 403. + var notInScopeResponse = await client.SendAsync(BuildPost("knownMethod", "{}", notInScopeToken)); + var notInScopeBody = await notInScopeResponse.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.Forbidden, unknownResponse.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, notInScopeResponse.StatusCode); + // The crux of the enumeration-safety invariant: identical bodies. + Assert.Equal(notInScopeBody, unknownBody); + } + + [Fact] + public async Task InvalidJsonBody_Returns400() + { + var method = SeedMethod(1, "badJson", "return 1;"); + + using var host = await BuildHostAsync(method); + var token = await SeedKeyAsync(host, "key1", "caller", new[] { "badJson" }); + var client = host.GetTestClient(); + + var request = BuildPost("badJson", "{ not json", token); var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); @@ -153,15 +233,15 @@ public class EndpointExtensionsTests [Fact] public async Task MissingRequiredParameter_Returns400_FromParameterValidator() { - var key = SeedKey(); var method = SeedMethod(1, "needsParam", "return Parameters[\"value\"];", """[{"name":"value","type":"Integer","required":true}]"""); - using var host = await BuildHostAsync(key, method); + using var host = await BuildHostAsync(method); + var token = await SeedKeyAsync(host, "key1", "caller", new[] { "needsParam" }); var client = host.GetTestClient(); // Body is empty object — required parameter "value" is missing. - var request = BuildPost("needsParam", "{}"); + var request = BuildPost("needsParam", "{}", token); var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); @@ -173,19 +253,19 @@ public class EndpointExtensionsTests [Fact] public async Task ScriptThrows_Returns500_WithSanitizedErrorBody() { - var key = SeedKey(); // Throws inside the script body — InboundScriptExecutor catches the // exception, logs it server-side, and surfaces the generic "Internal // script error" message to the caller (the executor deliberately does - // not leak raw exception details — see InboundScriptExecutor.ExecuteAsync's - // catch block). The endpoint maps the script failure to HTTP 500. + // not leak raw exception details). The endpoint maps the script failure + // to HTTP 500. var method = SeedMethod(1, "boom", """throw new System.InvalidOperationException("boom-msg");"""); - using var host = await BuildHostAsync(key, method); + using var host = await BuildHostAsync(method); + var token = await SeedKeyAsync(host, "key1", "caller", new[] { "boom" }); var client = host.GetTestClient(); - var request = BuildPost("boom", "{}"); + var request = BuildPost("boom", "{}", token); var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); @@ -197,19 +277,18 @@ public class EndpointExtensionsTests } [Fact] - public async Task SuccessfulAuth_StashesResolvedApiKeyNameOnHttpContextItems() + public async Task SuccessfulAuth_StashesVerifiedKeyDisplayNameOnHttpContextItems() { - // InboundAPI-023: the handler stashes the resolved API key's display name - // at HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] AFTER auth - // succeeded, so AuditWriteMiddleware sees a populated Actor when it - // emits the audit row. A capture middleware reads the slot once the - // endpoint finishes, proving the wiring still publishes it. - var key = SeedKey(id: 99, name: "audit-actor-name"); + // The handler stashes the VERIFIED key's display name at + // HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] AFTER auth+authz + // succeeded, so AuditWriteMiddleware sees a populated Actor when it emits + // the audit row. A capture middleware reads the slot once the endpoint + // finishes, proving the wiring still publishes it. var method = SeedMethod(1, "stamp", "return 1;"); var capture = new AuditActorCapture(); - using var host = await BuildHostAsync(key, method, customize: builder => + using var host = await BuildHostAsync(method, customize: builder => { builder.Use(async (ctx, next) => { @@ -225,9 +304,10 @@ public class EndpointExtensionsTests { services.AddSingleton(capture); }); + var token = await SeedKeyAsync(host, "key1", "audit-actor-name", new[] { "stamp" }); var client = host.GetTestClient(); - var request = BuildPost("stamp", "{}"); + var request = BuildPost("stamp", "{}", token); var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -241,16 +321,16 @@ public class EndpointExtensionsTests // scadabridge.inbound_api.requests once, tagged with the resolved, // registered method name (method.Name) — the bounded identifier, not the // raw route value. - var key = SeedKey(); var method = SeedMethod(1, "echo", "return Parameters[\"value\"];", """[{"name":"value","type":"Integer","required":true}]"""); using var collector = new InboundApiRequestCounterCollector(); - using var host = await BuildHostAsync(key, method); + using var host = await BuildHostAsync(method); + var token = await SeedKeyAsync(host, "key1", "caller", new[] { "echo" }); var client = host.GetTestClient(); - var response = await client.SendAsync(BuildPost("echo", """{"value":7}""")); + var response = await client.SendAsync(BuildPost("echo", """{"value":7}""", token)); Assert.Equal(HttpStatusCode.OK, response.StatusCode); // Filter by the method tag this test produced: the counter is a process-wide @@ -264,19 +344,21 @@ public class EndpointExtensionsTests [Fact] public async Task UnknownMethod_EmitsInboundApiRequestCounter_WithBoundedForbiddenSentinel() { - // Telemetry follow-on: an auth/authz failure is still counted, but the - // tag is a bounded sentinel ("") rather than the arbitrary - // caller-supplied route value — so an attacker posting random method - // names cannot blow up the `method` tag cardinality. - var key = SeedKey(); + // Telemetry follow-on: an authz failure is still counted, but the tag is a + // bounded sentinel ("") rather than the arbitrary caller-supplied + // route value — so an attacker posting random method names cannot blow up + // the `method` tag cardinality. var method = SeedMethod(1, "knownMethod", "return 1;"); using var collector = new InboundApiRequestCounterCollector(); - using var host = await BuildHostAsync(key, method); + using var host = await BuildHostAsync(method); + // Key is in scope for the made-up name, so scope passes and the request + // falls through to method-not-found (403) — exercising the forbidden path. + var token = await SeedKeyAsync(host, "key1", "caller", new[] { "totally-made-up-name" }); var client = host.GetTestClient(); - var response = await client.SendAsync(BuildPost("totally-made-up-name", "{}")); + var response = await client.SendAsync(BuildPost("totally-made-up-name", "{}", token)); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); var measurements = collector.Measurements; @@ -342,49 +424,100 @@ public class EndpointExtensionsTests public void Dispose() => _listener.Dispose(); } - private static HttpRequestMessage BuildPost(string methodName, string body) + private static HttpRequestMessage BuildPost(string methodName, string body, string bearerToken) { var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName) { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; - request.Headers.Add("X-API-Key", ApiKeyValue); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); return request; } - private static async Task BuildHostAsync( - ApiKey key, + /// + /// Seeds a key with the given scopes into the library SQLite store via + /// (the public admin seam) and + /// returns the assembled Bearer token (sbk_<keyId>_<secret>) — + /// the only moment the secret is ever available. The verifier registered in the + /// host's DI shares the same SQLite path + pepper, so it accepts this token. + /// + private static async Task SeedKeyAsync( + IHost host, string keyId, string displayName, IReadOnlyCollection scopes) + { + var commands = BuildAdminCommands(host); + var result = await commands.CreateKeyAsync( + keyId, displayName, new HashSet(scopes), + constraintsJson: null, remoteAddress: null, CancellationToken.None); + Assert.NotNull(result.Token); + return result.Token!; + } + + private static async Task RevokeKeyAsync(IHost host, string keyId) + { + var commands = BuildAdminCommands(host); + await commands.RevokeKeyAsync(keyId, remoteAddress: null, CancellationToken.None); + } + + /// + /// Builds an over the stores + pepper provider + + /// migrator that AddZbApiKeyAuth registered in the host's DI, so seeding writes to + /// the exact same database/pepper the verifier reads from. + /// + private static ApiKeyAdminCommands BuildAdminCommands(IHost host) + { + var services = host.Services; + return new ApiKeyAdminCommands( + services.GetRequiredService>().Value, + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + } + + private async Task BuildHostAsync( ApiMethod method, Action? customize = null, Action? additionalServices = null) { var repo = Substitute.For(); - repo.GetAllApiKeysAsync(Arg.Any()) - .Returns(new List { key }); repo.GetMethodByNameAsync(method.Name, Arg.Any()) .Returns(method); - repo.GetApprovedKeysForMethodAsync(method.Id, Arg.Any()) - .Returns(new List { key }); + + // The pepper provider (ConfigurationApiKeyPepperProvider) reads the HOST's + // IConfiguration, and AddZbApiKeyAuth only TryAdds its own config (so the + // host registration wins). The api-key settings — including the pepper — + // must therefore live in the host configuration, not a separate object. + var apiKeySettings = new Dictionary + { + [PepperConfigKey] = Pepper, + [$"{ApiKeyStoreSection}:TokenPrefix"] = TokenPrefix, + [$"{ApiKeyStoreSection}:PepperSecretName"] = PepperConfigKey, + [$"{ApiKeyStoreSection}:SqlitePath"] = _sqlitePath, + [$"{ApiKeyStoreSection}:RunMigrationsOnStartup"] = "true", + }; var hostBuilder = new HostBuilder() + .ConfigureAppConfiguration(config => config.AddInMemoryCollection(apiKeySettings)) .ConfigureWebHost(webBuilder => { webBuilder .UseTestServer() - .ConfigureServices(services => + .ConfigureServices((context, services) => { services.AddRouting(); services.AddSingleton(repo); services.AddSingleton(Substitute.For()); services.Configure(_ => { }); services.AddInboundAPI(); - // Replace the production CommunicationService-backed - // router and the configured HMAC hasher with test stubs - // (same pattern as EndpointContentTypeTests). + // Stand up the shared verifier + SQLite store + migration + // hosted service against the per-test database and pepper, + // binding from the host configuration the pepper provider reads. + services.AddZbApiKeyAuth(context.Configuration, ApiKeyStoreSection); + // Replace the production CommunicationService-backed router + // with a test stub (it would otherwise need a real + // CommunicationService which isn't wired here). services.RemoveAll(); services.AddSingleton(Substitute.For()); - services.RemoveAll(); - services.AddSingleton(new IdentityHasher()); services.AddLogging(); additionalServices?.Invoke(services); }) @@ -398,4 +531,26 @@ public class EndpointExtensionsTests return await hostBuilder.StartAsync(); } + + public void Dispose() + { + try + { + // SqliteConnection pooling can hold the file open; clear pools before + // deleting so the temp DB (and its -wal/-shm sidecars) are removed. + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + foreach (var suffix in new[] { "", "-wal", "-shm" }) + { + var path = _sqlitePath + suffix; + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + catch + { + // Best-effort cleanup; a leaked temp file is harmless. + } + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.csproj index 6a23f981..2af82258 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.csproj @@ -19,6 +19,11 @@ + +