feat(auth)!: ScadaBridge retire SQL Server ApiKey entity + ApprovedApiKeyIds + legacy hashing; EF migration RetireInboundApiKeyStore; re-issue runbook + CHANGELOG (re-arch C5/E) — BREAKING: X-API-Key -> Bearer sbk_, keys re-issued
This commit is contained in:
@@ -116,8 +116,9 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
||||
// Mirror production order: routing → auth → audit
|
||||
// middleware → endpoint. The auth scheme always
|
||||
// succeeds; per-request auth-failure semantics are
|
||||
// produced INSIDE the endpoint handler (mirroring
|
||||
// ApiKeyValidator's in-handler short-circuit).
|
||||
// produced INSIDE the endpoint handler (mirroring the
|
||||
// shared ZB.MOM.WW.Auth.ApiKeys verifier's in-handler
|
||||
// short-circuit).
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
@@ -241,9 +242,9 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
||||
|
||||
using var host = await BuildHostAsync(async ctx =>
|
||||
{
|
||||
// The production ApiKeyValidator returns 401 from inside the
|
||||
// handler when the X-API-Key header is missing or invalid; the
|
||||
// handler must NOT stash an actor name in that case so the
|
||||
// The production inbound endpoint returns 401 from inside the
|
||||
// handler when the Bearer token is missing or fails verification;
|
||||
// the handler must NOT stash an actor name in that case so the
|
||||
// middleware emits Actor=null on the resulting audit row.
|
||||
ctx.Response.StatusCode = 401;
|
||||
await ctx.Response.WriteAsync("unauthorized");
|
||||
|
||||
@@ -71,8 +71,6 @@ public class TransportExportPageTests : BunitContext
|
||||
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList>()));
|
||||
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration>()));
|
||||
_inboundApiRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiKey>>(new List<ApiKey>()));
|
||||
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: the <see cref="ApiKey"/> entity must never carry the
|
||||
/// plaintext bearer credential as a persisted field — only its deterministic hash.
|
||||
/// </summary>
|
||||
public class ApiKeyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ApiKey_HasNoPlaintextKeyValueProperty()
|
||||
{
|
||||
// The plaintext key is shown to the operator once at creation and is never
|
||||
// persisted. The entity must therefore expose KeyHash, not KeyValue.
|
||||
var properties = typeof(ApiKey).GetProperties().Select(p => p.Name).ToArray();
|
||||
|
||||
Assert.DoesNotContain("KeyValue", properties);
|
||||
Assert.Contains("KeyHash", properties);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_FromPlaintext_StoresHashNotPlaintext()
|
||||
{
|
||||
var key = new ApiKey("MES-Production", "the-secret-key-value");
|
||||
|
||||
Assert.NotEqual("the-secret-key-value", key.KeyHash);
|
||||
Assert.Equal(ApiKeyHasher.Default.Hash("the-secret-key-value"), key.KeyHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromHash_StoresHashVerbatim()
|
||||
{
|
||||
var key = ApiKey.FromHash("RecipeManager-Dev", "precomputed-hash-value");
|
||||
|
||||
Assert.Equal("RecipeManager-Dev", key.Name);
|
||||
Assert.Equal("precomputed-hash-value", key.KeyHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullArguments_Throw()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new ApiKey(null!, "value"));
|
||||
Assert.Throws<ArgumentNullException>(() => new ApiKey("name", (string)null!));
|
||||
Assert.Throws<ArgumentNullException>(() => ApiKey.FromHash(null!, "hash"));
|
||||
Assert.Throws<ArgumentNullException>(() => ApiKey.FromHash("name", null!));
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: the inbound-API bearer credential is stored as a
|
||||
/// deterministic keyed hash (HMAC-SHA256 with a server-side pepper) rather than
|
||||
/// plaintext. These tests pin the hasher contract that the entity, the validator,
|
||||
/// and the management create-path all depend on.
|
||||
/// </summary>
|
||||
public class ApiKeyHasherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Hash_IsDeterministic_SameInputSameOutput()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
var first = hasher.Hash("some-api-key-value");
|
||||
var second = hasher.Hash("some-api-key-value");
|
||||
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DoesNotEqualPlaintext()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
var hash = hasher.Hash("some-api-key-value");
|
||||
|
||||
Assert.NotEqual("some-api-key-value", hash);
|
||||
Assert.DoesNotContain("some-api-key-value", hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DifferentInputs_ProduceDifferentHashes()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
Assert.NotEqual(hasher.Hash("key-one"), hasher.Hash("key-two"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DifferentPeppers_ProduceDifferentHashes()
|
||||
{
|
||||
var a = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
var b = new ApiKeyHasher("a-different-but-equally-long-pepper-val");
|
||||
|
||||
// The pepper binds the hash to the server: a stolen DB dump is useless
|
||||
// without the pepper because the same key hashes differently under it.
|
||||
Assert.NotEqual(a.Hash("same-api-key"), b.Hash("same-api-key"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("too-short")]
|
||||
public void Constructor_MissingOrWeakPepper_FailsFast(string? pepper)
|
||||
{
|
||||
// The pepper must be present and of meaningful length; a missing or weak
|
||||
// pepper is a deployment misconfiguration and must fail loudly.
|
||||
Assert.Throws<ArgumentException>(() => new ApiKeyHasher(pepper!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_NullInput_Throws()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => hasher.Hash(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_IsUsableWithoutAPepper()
|
||||
{
|
||||
// The unpeppered default exists for tests and non-production wiring; it is
|
||||
// still a one-way HMAC-SHA256, just without the server-binding pepper.
|
||||
var hash = ApiKeyHasher.Default.Hash("some-api-key-value");
|
||||
|
||||
Assert.NotEqual("some-api-key-value", hash);
|
||||
Assert.Equal(ApiKeyHasher.Default.Hash("some-api-key-value"), hash);
|
||||
}
|
||||
}
|
||||
+6
-129
@@ -1,21 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
// Auth re-arch (C5): the SQL Server ApiKey entity and the repository's key methods
|
||||
// (Add/Get/Update/Delete/GetApprovedKeysForMethod) were retired — inbound API keys
|
||||
// now live in the shared ZB.MOM.WW.Auth.ApiKeys SQLite store. The former key
|
||||
// round-trip, peppered-hasher, and ApprovedApiKeyIds CSV tests were removed with
|
||||
// them; only the API-method catalogue remains here.
|
||||
public class InboundApiRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly CapturingLogger<InboundApiRepository> _logger = new();
|
||||
private readonly InboundApiRepository _repository;
|
||||
|
||||
public InboundApiRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new InboundApiRepository(_context, hasherAccessor: null, logger: _logger);
|
||||
_repository = new InboundApiRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -24,53 +27,6 @@ public class InboundApiRepositoryTests : IDisposable
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddApiKey_AndGetById_RoundTrips()
|
||||
{
|
||||
var key = new ApiKey("Key1", "secret-value-1") { IsEnabled = true };
|
||||
await _repository.AddApiKeyAsync(key);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetApiKeyByIdAsync(key.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Key1", loaded!.Name);
|
||||
|
||||
var byValue = await _repository.GetApiKeyByValueAsync("secret-value-1");
|
||||
Assert.NotNull(byValue);
|
||||
Assert.Equal(key.Id, byValue!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CD016_GetApiKeyByValue_UsesInjectedPepperedHasher_NotDefault()
|
||||
{
|
||||
// CD-016 regression: stored KeyHash is produced by a peppered hasher.
|
||||
// A repository whose lookup uses ApiKeyHasher.Default (the pre-fix
|
||||
// behaviour) would compute a different digest and return null. With the
|
||||
// pepper-aware hasherAccessor wired in, the lookup must round-trip.
|
||||
var peppered = new Commons.Types.InboundApi.ApiKeyHasher("a-strong-test-pepper-of-sufficient-length");
|
||||
var pepperedHash = peppered.Hash("secret-with-pepper");
|
||||
var key = ApiKey.FromHash("Peppered", pepperedHash);
|
||||
key.IsEnabled = true;
|
||||
|
||||
using var ctx = SqliteTestHelper.CreateInMemoryContext();
|
||||
var repo = new InboundApiRepository(ctx, hasherAccessor: () => peppered, logger: _logger);
|
||||
await repo.AddApiKeyAsync(key);
|
||||
await repo.SaveChangesAsync();
|
||||
|
||||
var byValue = await repo.GetApiKeyByValueAsync("secret-with-pepper");
|
||||
Assert.NotNull(byValue);
|
||||
Assert.Equal(key.Id, byValue!.Id);
|
||||
|
||||
// And: a repository wired with the Default (unpeppered) hasher MUST
|
||||
// NOT find the same key — proving the lookup actually uses the
|
||||
// injected hasher and the original bug shape.
|
||||
var defaultRepo = new InboundApiRepository(ctx,
|
||||
hasherAccessor: () => Commons.Types.InboundApi.ApiKeyHasher.Default,
|
||||
logger: _logger);
|
||||
var missByDefault = await defaultRepo.GetApiKeyByValueAsync("secret-with-pepper");
|
||||
Assert.Null(missByDefault);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddApiMethod_AndGetByName_RoundTrips()
|
||||
{
|
||||
@@ -83,60 +39,6 @@ public class InboundApiRepositoryTests : IDisposable
|
||||
Assert.Equal(method.Id, loaded!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovedKeysForMethod_WithValidCsv_ReturnsAllKeys()
|
||||
{
|
||||
var k1 = new ApiKey("K1", "v1");
|
||||
var k2 = new ApiKey("K2", "v2");
|
||||
await _repository.AddApiKeyAsync(k1);
|
||||
await _repository.AddApiKeyAsync(k2);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var method = new ApiMethod("M", "return 1;") { ApprovedApiKeyIds = $"{k1.Id}, {k2.Id}" };
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||
|
||||
Assert.Equal(2, keys.Count);
|
||||
Assert.Empty(_logger.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovedKeysForMethod_WithMalformedCsvToken_LogsWarningAndDropsToken()
|
||||
{
|
||||
// Regression guard for ConfigurationDatabase-008: a corrupt token (a name where an
|
||||
// integer id is expected) must not be dropped silently — the corruption must be
|
||||
// observable via a logged warning, while the valid ids still resolve.
|
||||
var k1 = new ApiKey("K1", "v1");
|
||||
await _repository.AddApiKeyAsync(k1);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var method = new ApiMethod("M", "return 1;") { ApprovedApiKeyIds = $"{k1.Id},not-an-id" };
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||
|
||||
Assert.Single(keys);
|
||||
Assert.Equal(k1.Id, keys[0].Id);
|
||||
Assert.Single(_logger.Warnings);
|
||||
Assert.Contains("not-an-id", _logger.Warnings[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovedKeysForMethod_WithNullOrEmptyCsv_ReturnsEmptyWithoutWarning()
|
||||
{
|
||||
var method = new ApiMethod("M", "return 1;");
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||
|
||||
Assert.Empty(keys);
|
||||
Assert.Empty(_logger.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteApiMethod_RemovesEntity()
|
||||
{
|
||||
@@ -156,28 +58,3 @@ public class InboundApiRepositoryTests : IDisposable
|
||||
Assert.Throws<ArgumentNullException>(() => new InboundApiRepository(null!));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimal ILogger that captures warning-level messages for assertions.</summary>
|
||||
internal sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<string> Warnings { get; } = new();
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (logLevel == LogLevel.Warning)
|
||||
{
|
||||
Warnings.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
+4
-25
@@ -135,29 +135,8 @@ public class SplitQueryBehaviourTests : IDisposable
|
||||
Assert.Single(loaded.Scripts);
|
||||
}
|
||||
|
||||
// ConfigurationDatabase-012: the ApiKey table must persist the bearer credential
|
||||
// as a hash column (KeyHash) and must NOT carry a plaintext KeyValue column.
|
||||
|
||||
[Fact]
|
||||
public void ApiKey_KeyHashColumn_IsMappedAndUniquelyIndexed()
|
||||
{
|
||||
var entityType = _context.Model.FindEntityType(typeof(ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiKey))!;
|
||||
|
||||
var keyHash = entityType.FindProperty("KeyHash");
|
||||
Assert.NotNull(keyHash);
|
||||
Assert.False(keyHash!.IsNullable);
|
||||
|
||||
var hashIndex = entityType.GetIndexes()
|
||||
.FirstOrDefault(i => i.Properties.Any(p => p.Name == "KeyHash"));
|
||||
Assert.NotNull(hashIndex);
|
||||
Assert.True(hashIndex!.IsUnique);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiKey_HasNoPlaintextKeyValueColumn()
|
||||
{
|
||||
var entityType = _context.Model.FindEntityType(typeof(ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiKey))!;
|
||||
|
||||
Assert.Null(entityType.FindProperty("KeyValue"));
|
||||
}
|
||||
// Auth re-arch (C5): the SQL Server ApiKey entity was retired (inbound keys now
|
||||
// live in the shared ZB.MOM.WW.Auth.ApiKeys SQLite store), so the former
|
||||
// ApiKey_KeyHashColumn_IsMappedAndUniquelyIndexed and
|
||||
// ApiKey_HasNoPlaintextKeyValueColumn schema assertions were removed with it.
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ public class DbContextTests : IDisposable
|
||||
Assert.NotNull(_context.SharedScripts);
|
||||
Assert.NotNull(_context.LdapGroupMappings);
|
||||
Assert.NotNull(_context.SiteScopeRules);
|
||||
Assert.NotNull(_context.ApiKeys);
|
||||
// Auth re-arch (C5): the ApiKeys DbSet was retired (inbound keys moved to the
|
||||
// shared ZB.MOM.WW.Auth.ApiKeys SQLite store); only ApiMethods remains.
|
||||
Assert.NotNull(_context.ApiMethods);
|
||||
Assert.NotNull(_context.AuditLogEntries);
|
||||
|
||||
@@ -264,16 +265,16 @@ public class DbContextTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InboundApi_ApiKeyAndMethod()
|
||||
public void InboundApi_Method()
|
||||
{
|
||||
var key = new ApiKey("TestKey", "sk-test-123") { IsEnabled = true };
|
||||
// Auth re-arch (C5): the SQL Server ApiKey entity was retired (inbound keys
|
||||
// now live in the shared ZB.MOM.WW.Auth.ApiKeys SQLite store), so this case
|
||||
// covers only the surviving ApiMethod catalogue.
|
||||
var method = new ApiMethod("GetStatus", "return \"ok\";") { TimeoutSeconds = 30 };
|
||||
|
||||
_context.ApiKeys.Add(key);
|
||||
_context.ApiMethods.Add(method);
|
||||
_context.SaveChanges();
|
||||
|
||||
Assert.Single(_context.ApiKeys.Where(k => k.Name == "TestKey"));
|
||||
Assert.Single(_context.ApiMethods.Where(m => m.Name == "GetStatus"));
|
||||
}
|
||||
|
||||
|
||||
@@ -118,10 +118,11 @@ public class CentralCompositionRootTests : IDisposable
|
||||
["ScadaBridge:Security:Ldap:AllowInsecure"] = "true",
|
||||
["ScadaBridge:Security:Ldap:SearchBase"] = "dc=scadabridge,dc=local",
|
||||
["ScadaBridge:Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=scadabridge,dc=local",
|
||||
// ConfigurationDatabase-012: inbound-API keys are hashed
|
||||
// with a server-side HMAC pepper; ApiKeyHasher fails fast
|
||||
// if it is missing or weak, so resolving ApiKeyValidator
|
||||
// requires a configured pepper.
|
||||
// Auth re-arch (C5): inbound-API keys live in the shared
|
||||
// ZB.MOM.WW.Auth.ApiKeys SQLite store. The verifier reuses
|
||||
// this same config key as its pepper secret (PepperSecretName),
|
||||
// and AddZbApiKeyAuth fails fast if it is missing/weak — so a
|
||||
// configured pepper is still required for the host to start.
|
||||
["ScadaBridge:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
|
||||
});
|
||||
});
|
||||
@@ -211,8 +212,8 @@ public class CentralCompositionRootTests : IDisposable
|
||||
// Security (ILdapAuthService is now a singleton — see CentralSingletonServices)
|
||||
new object[] { typeof(JwtTokenService) },
|
||||
new object[] { typeof(RoleMapper) },
|
||||
// InboundAPI
|
||||
new object[] { typeof(ApiKeyValidator) },
|
||||
// InboundAPI — auth re-arch (C5): the legacy ApiKeyValidator was retired;
|
||||
// inbound auth runs through the shared ZB.MOM.WW.Auth.ApiKeys verifier.
|
||||
new object[] { typeof(RouteHelper) },
|
||||
// ExternalSystemGateway
|
||||
new object[] { typeof(ExternalSystemClient) },
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: <see cref="ApiKeyValidator"/> must authenticate by
|
||||
/// hashing the presented candidate with the same HMAC-SHA256 pepper used at
|
||||
/// creation, then comparing against the stored <see cref="ApiKey.KeyHash"/> — never
|
||||
/// against a plaintext key. The comparison stays constant-time.
|
||||
/// </summary>
|
||||
public class ApiKeyHashValidationTests
|
||||
{
|
||||
private const string Pepper = "a-sufficiently-long-server-side-pepper-value";
|
||||
|
||||
private readonly IInboundApiRepository _repository = Substitute.For<IInboundApiRepository>();
|
||||
|
||||
private static ApiKey StoredKey(ApiKeyHasher hasher, string liveKey, int id = 1, bool enabled = true)
|
||||
{
|
||||
var key = ApiKey.FromHash("MES-Production", hasher.Hash(liveKey));
|
||||
key.Id = id;
|
||||
key.IsEnabled = enabled;
|
||||
return key;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WithPepperedHasher_AcceptsKeyHashedWithSamePepper()
|
||||
{
|
||||
var hasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(hasher, "live-secret-key");
|
||||
var method = new ApiMethod("ingest", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
_repository.GetMethodByNameAsync("ingest").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { stored });
|
||||
|
||||
var validator = new ApiKeyValidator(_repository, hasher);
|
||||
|
||||
var result = await validator.ValidateAsync("live-secret-key", "ingest");
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(200, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WrongKey_FailsEvenWhenItHashesToSomethingNonNull()
|
||||
{
|
||||
var hasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(hasher, "the-real-key");
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
|
||||
var validator = new ApiKeyValidator(_repository, hasher);
|
||||
|
||||
var result = await validator.ValidateAsync("a-wrong-key", "ingest");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_StoredHashIsNotThePlaintextKey()
|
||||
{
|
||||
// Sanity guard: the value the validator compares against must be a hash, not
|
||||
// the live secret — a DB dump must not yield a usable credential.
|
||||
var hasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(hasher, "live-secret-key");
|
||||
|
||||
Assert.NotEqual("live-secret-key", stored.KeyHash);
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
var validator = new ApiKeyValidator(_repository, hasher);
|
||||
|
||||
// Presenting the stored hash itself must NOT authenticate — only the live key does.
|
||||
var result = await validator.ValidateAsync(stored.KeyHash, "ingest");
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_KeyHashedUnderADifferentPepper_DoesNotAuthenticate()
|
||||
{
|
||||
var creationHasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(creationHasher, "live-secret-key");
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
|
||||
// A validator running with a different pepper cannot recognise the key.
|
||||
var otherHasher = new ApiKeyHasher("a-totally-different-server-side-pepper-val");
|
||||
var validator = new ApiKeyValidator(_repository, otherHasher);
|
||||
|
||||
var result = await validator.ValidateAsync("live-secret-key", "ingest");
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-1: Tests for API key validation — X-API-Key header, enabled/disabled keys,
|
||||
/// method approval.
|
||||
/// </summary>
|
||||
public class ApiKeyValidatorTests
|
||||
{
|
||||
private readonly IInboundApiRepository _repository = Substitute.For<IInboundApiRepository>();
|
||||
private readonly ApiKeyValidator _validator;
|
||||
|
||||
public ApiKeyValidatorTests()
|
||||
{
|
||||
_validator = new ApiKeyValidator(_repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingApiKey_Returns401()
|
||||
{
|
||||
var result = await _validator.ValidateAsync(null, "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyApiKey_Returns401()
|
||||
{
|
||||
var result = await _validator.ValidateAsync("", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidApiKey_Returns401()
|
||||
{
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey>());
|
||||
|
||||
var result = await _validator.ValidateAsync("bad-key", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledApiKey_Returns401()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = false };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_MethodNotFound_IsIndistinguishableFromNotApproved()
|
||||
{
|
||||
// InboundAPI-011: a "method not found" response must not be observably
|
||||
// different from a "key not approved" response, or a caller holding any
|
||||
// valid key could enumerate which method names exist on the central API.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("realMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("nonExistent").Returns((ApiMethod?)null);
|
||||
_repository.GetMethodByNameAsync("realMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey>());
|
||||
|
||||
var notFound = await _validator.ValidateAsync("valid-key", "nonExistent");
|
||||
var notApproved = await _validator.ValidateAsync("valid-key", "realMethod");
|
||||
|
||||
Assert.False(notFound.IsValid);
|
||||
Assert.False(notApproved.IsValid);
|
||||
// Status code and error message must be identical so existence is not observable.
|
||||
Assert.Equal(notApproved.StatusCode, notFound.StatusCode);
|
||||
Assert.Equal(notApproved.ErrorMessage, notFound.ErrorMessage);
|
||||
Assert.Equal(403, notFound.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_MethodNotFound_ErrorMessageDoesNotEchoMethodName()
|
||||
{
|
||||
// InboundAPI-011: the error body must not echo the caller-supplied method
|
||||
// name back verbatim (reflected-input) and must not confirm non-existence.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("probe-XYZ").Returns((ApiMethod?)null);
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "probe-XYZ");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.DoesNotContain("probe-XYZ", result.ErrorMessage ?? string.Empty);
|
||||
Assert.DoesNotContain("not found", result.ErrorMessage ?? string.Empty,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_NotApprovedForMethod_Returns403()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey>());
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_ApprovedForMethod_ReturnsValid()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(200, result.StatusCode);
|
||||
Assert.Equal(key, result.ApiKey);
|
||||
Assert.Equal(method, result.Method);
|
||||
}
|
||||
|
||||
// --- InboundAPI-003: API key must not be matched with a non-constant-time
|
||||
// (timing-oracle) secret-equality lookup. ---
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DoesNotUseSecretEqualityLookup()
|
||||
{
|
||||
// GetApiKeyByValueAsync translates to a SQL "WHERE KeyValue = @secret" early-exit
|
||||
// comparison — a timing side-channel. The validator must not call it.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
||||
|
||||
await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
|
||||
await _repository.DidNotReceive()
|
||||
.GetApiKeyByValueAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WrongKey_ConstantTimePath_Returns401()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("wrong-key", "testMethod");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_KeyOfDifferentLength_Returns401()
|
||||
{
|
||||
// FixedTimeEquals over UTF-8 bytes must reject length mismatches without leaking.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key-with-extra", "testMethod");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -262,7 +262,8 @@ public class AuditWriteMiddlewareTests
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
// The endpoint handler is expected to stash the resolved API key
|
||||
// name here once ApiKeyValidator.ValidateAsync has succeeded.
|
||||
// name here once the shared ZB.MOM.WW.Auth.ApiKeys verifier has
|
||||
// authenticated the Bearer token.
|
||||
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
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.InboundAPI;
|
||||
|
||||
@@ -16,40 +11,11 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
|
||||
/// </summary>
|
||||
public class IntegrationSurfaceTests
|
||||
{
|
||||
// ── Inbound API: auth + routing + parameter validation + error codes ──
|
||||
|
||||
[Fact]
|
||||
public async Task InboundAPI_ApiKeyValidator_FullFlow_EndToEnd()
|
||||
{
|
||||
// Validates that ApiKeyValidator correctly chains all checks.
|
||||
var repository = Substitute.For<IInboundApiRepository>();
|
||||
var key = new ApiKey("test-key", "key-value-123") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("getStatus", "return 1;")
|
||||
{
|
||||
Id = 10,
|
||||
ParameterDefinitions = "[{\"Name\":\"deviceId\",\"Type\":\"String\",\"Required\":true}]",
|
||||
TimeoutSeconds = 30
|
||||
};
|
||||
|
||||
// ConfigurationDatabase-012: the validator fetches every key and matches
|
||||
// the candidate by HMAC hash in constant time (no secret-equality lookup).
|
||||
repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
repository.GetMethodByNameAsync("getStatus").Returns(method);
|
||||
repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
||||
|
||||
var validator = new ApiKeyValidator(repository);
|
||||
|
||||
// Valid key + approved method
|
||||
var result = await validator.ValidateAsync("key-value-123", "getStatus");
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(method, result.Method);
|
||||
|
||||
// Then validate parameters
|
||||
using var doc = JsonDocument.Parse("{\"deviceId\": \"pump-01\"}");
|
||||
var paramResult = ParameterValidator.Validate(doc.RootElement.Clone(), method.ParameterDefinitions);
|
||||
Assert.True(paramResult.IsValid);
|
||||
Assert.Equal("pump-01", paramResult.Parameters["deviceId"]);
|
||||
}
|
||||
// ── Inbound API: parameter validation + error codes ──
|
||||
// Auth re-arch (C5): the in-repo ApiKeyValidator full-flow case was removed
|
||||
// with the entity. Inbound auth now runs through the shared
|
||||
// ZB.MOM.WW.Auth.ApiKeys verifier (Bearer sbk_<keyId>_<secret>); its coverage
|
||||
// lives in the InboundAPI endpoint + Auth-library tests.
|
||||
|
||||
[Fact]
|
||||
public void InboundAPI_ParameterValidation_ExtendedTypes()
|
||||
|
||||
@@ -1206,8 +1206,6 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var inboundRepo = Substitute.For<IInboundApiRepository>();
|
||||
inboundRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.InboundApi.ApiKey>());
|
||||
inboundRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.InboundApi.ApiMethod>());
|
||||
_services.AddScoped(_ => inboundRepo);
|
||||
|
||||
+5
-5
@@ -822,14 +822,14 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert — no keys created, the method WAS created, the ignored count is
|
||||
// surfaced, and the import did not fault.
|
||||
// Assert — the method WAS created, the ignored count is surfaced, and the
|
||||
// import did not fault. Auth re-arch (C5): the SQL Server ApiKey store was
|
||||
// retired, so "no keys created" is now structural — the importer has no key
|
||||
// sink at all; the legacy ApiKeys section is counted (ApiKeysIgnored) and
|
||||
// discarded.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var inboundRepo = scope.ServiceProvider.GetRequiredService<IInboundApiRepository>();
|
||||
var keys = await inboundRepo.GetAllApiKeysAsync();
|
||||
Assert.Empty(keys);
|
||||
|
||||
var methods = await inboundRepo.GetAllApiMethodsAsync();
|
||||
Assert.Single(methods);
|
||||
Assert.Equal("CreateOrder", methods[0].Name);
|
||||
|
||||
Reference in New Issue
Block a user