diff --git a/Directory.Packages.props b/Directory.Packages.props index 51f96913..1b550f3a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -80,10 +80,10 @@ - - - - + + + + diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Security/IInboundApiKeyAdmin.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Security/IInboundApiKeyAdmin.cs new file mode 100644 index 00000000..64b26b1d --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Security/IInboundApiKeyAdmin.cs @@ -0,0 +1,66 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security; + +/// +/// Read-side projection of one inbound API key, as surfaced by the management seam. +/// Hash-free by construction — the secret is never carried here; it is shown ONCE at +/// creation via . +/// +/// Stable key identifier (the middle segment of the token). +/// Operator-facing display name. +/// True while the key is active (not revoked/disabled). +/// The API-method names this key is scoped to call, sorted ordinally. +/// When the key was created. +/// When the key last authenticated a request, if ever. +public sealed record InboundApiKeyInfo( + string KeyId, + string Name, + bool Enabled, + IReadOnlyList Methods, + DateTimeOffset CreatedUtc, + DateTimeOffset? LastUsedUtc); + +/// +/// Result of creating a key. is the assembled bearer token +/// (sbk_<keyId>_<secret>) and is the ONLY moment the secret is available — +/// it is never retrievable afterwards. +/// +/// The new key's identifier. +/// The bearer token, shown once. +public sealed record InboundApiKeyCreated(string KeyId, string Token); + +/// +/// App-facing management seam for inbound API keys. This is the single shared path CLI +/// and CentralUI use to create / list / enable / disable / delete inbound keys and edit +/// their method-scopes. The interface lives in Commons and is deliberately free of any +/// dependency on the underlying auth library, so consumers depend only on this contract. +/// +public interface IInboundApiKeyAdmin +{ + /// Creates a new key scoped to and returns its + /// identifier plus the bearer token (shown once). + Task CreateAsync( + string name, IReadOnlyCollection methods, CancellationToken ct = default); + + /// Lists all inbound keys (hash-free projection). + Task> ListAsync(CancellationToken ct = default); + + /// Enables or disables a key without changing its secret. Returns false if + /// the key does not exist. + Task SetEnabledAsync(string keyId, bool enabled, CancellationToken ct = default); + + /// Replaces the method-scope set on a key without changing its secret. + /// Returns false if the key does not exist. + Task SetMethodsAsync( + string keyId, IReadOnlyCollection methods, CancellationToken ct = default); + + /// Removes a key (revoke-then-delete). Returns false if the key could not be + /// deleted. + Task DeleteAsync(string keyId, CancellationToken ct = default); + + /// Returns the method-scope set for a key, or an empty list if not found. + Task> GetMethodsForKeyAsync(string keyId, CancellationToken ct = default); + + /// Returns the identifiers of all keys whose scopes contain + /// . + Task> GetKeysForMethodAsync(string methodName, CancellationToken ct = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 07fa8e26..ea9adfe8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -144,6 +144,26 @@ try builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection); + // Inbound-API key re-arch (C1), additive: expose the library admin facade + // (ApiKeyAdminCommands) and the app-side management seam (IInboundApiKeyAdmin) + // in the SAME container as AddZbApiKeyAuth, so CLI + CentralUI later create / + // list / enable / disable / delete inbound keys and edit their method-scopes + // through one shared path. AddZbApiKeyAuth registers the stores/pepper/migrator + // but NOT ApiKeyAdminCommands itself, so it is composed here. CentralUI resolves + // from this same provider (it is registered via AddCentralUI() above), so the + // seam is reachable from both the ManagementActor and CentralUI pages — exactly + // as IInboundApiRepository already is. + builder.Services.AddSingleton(sp => new ZB.MOM.WW.Auth.ApiKeys.Admin.ApiKeyAdminCommands( + sp.GetRequiredService>().Value, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + TimeProvider.System)); + builder.Services.AddSingleton< + ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security.IInboundApiKeyAdmin, + LibraryInboundApiKeyAdmin>(); + builder.Services.AddManagementService(); var configDbConnectionString = configuration["ScadaBridge:Database:ConfigurationDb"] diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/LibraryInboundApiKeyAdmin.cs b/src/ZB.MOM.WW.ScadaBridge.Security/LibraryInboundApiKeyAdmin.cs new file mode 100644 index 00000000..bfb02cb5 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Security/LibraryInboundApiKeyAdmin.cs @@ -0,0 +1,109 @@ +using ZB.MOM.WW.Auth.ApiKeys.Admin; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security; + +namespace ZB.MOM.WW.ScadaBridge.Security; + +/// +/// Implements the Commons management seam over the shared +/// ZB.MOM.WW.Auth.ApiKeys admin facade (). This is the +/// single shared path through which CLI and CentralUI create / list / enable / disable / delete +/// inbound keys and edit their method-scopes, so all front-ends drive identical library behaviour. +/// +/// +/// Mapping from the library projection to the app DTO: +/// +/// A key is "enabled" iff its RevokedUtc is null. +/// "Methods" are the library's Scopes, sorted ordinally for a stable display order. +/// Delete is best-effort revoke-then-delete: the library only deletes already-revoked keys, +/// so we revoke first (a harmless no-op when already revoked) and the delete is authoritative. +/// +/// +public sealed class LibraryInboundApiKeyAdmin : IInboundApiKeyAdmin +{ + private readonly ApiKeyAdminCommands _admin; + + /// Creates the seam over the supplied library admin command set. + /// The shared library admin facade. + public LibraryInboundApiKeyAdmin(ApiKeyAdminCommands admin) + { + ArgumentNullException.ThrowIfNull(admin); + _admin = admin; + } + + /// + public async Task CreateAsync( + string name, IReadOnlyCollection methods, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(methods); + + var keyId = Guid.NewGuid().ToString("N"); + var result = await _admin.CreateKeyAsync( + keyId, name, methods.ToHashSet(StringComparer.Ordinal), + constraintsJson: null, remoteAddress: null, ct).ConfigureAwait(false); + + // Token is non-null on create success; CreateKeyAsync throws rather than returning a + // null token on the failure path, so a successful return always carries the secret. + return new InboundApiKeyCreated(result.KeyId, result.Token!); + } + + /// + public async Task> ListAsync(CancellationToken ct = default) + { + var items = await _admin.ListKeysAsync(ct).ConfigureAwait(false); + var result = new List(items.Count); + foreach (var item in items) + { + result.Add(new InboundApiKeyInfo( + KeyId: item.KeyId, + Name: item.DisplayName, + Enabled: item.RevokedUtc is null, + Methods: item.Scopes.OrderBy(s => s, StringComparer.Ordinal).ToList(), + CreatedUtc: item.CreatedUtc, + LastUsedUtc: item.LastUsedUtc)); + } + + return result; + } + + /// + public async Task SetEnabledAsync(string keyId, bool enabled, CancellationToken ct = default) => + (await _admin.SetEnabledAsync(keyId, enabled, remoteAddress: null, ct).ConfigureAwait(false)).Succeeded; + + /// + public async Task SetMethodsAsync( + string keyId, IReadOnlyCollection methods, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(methods); + return (await _admin.SetScopesAsync( + keyId, methods.ToHashSet(StringComparer.Ordinal), remoteAddress: null, ct) + .ConfigureAwait(false)).Succeeded; + } + + /// + public async Task DeleteAsync(string keyId, CancellationToken ct = default) + { + // Best-effort revoke first so the library permits the delete (it only deletes + // already-revoked keys). Revoking an already-disabled key is a harmless no-op; + // the delete result is authoritative. + await _admin.RevokeKeyAsync(keyId, remoteAddress: null, ct).ConfigureAwait(false); + return (await _admin.DeleteKeyAsync(keyId, remoteAddress: null, ct).ConfigureAwait(false)).Succeeded; + } + + /// + public async Task> GetMethodsForKeyAsync(string keyId, CancellationToken ct = default) + { + var keys = await ListAsync(ct).ConfigureAwait(false); + var match = keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal)); + return match?.Methods ?? (IReadOnlyList)Array.Empty(); + } + + /// + public async Task> GetKeysForMethodAsync(string methodName, CancellationToken ct = default) + { + var keys = await ListAsync(ct).ConfigureAwait(false); + return keys + .Where(k => k.Methods.Contains(methodName, StringComparer.Ordinal)) + .Select(k => k.KeyId) + .ToList(); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj b/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj index bc8b1264..5708eeb2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj @@ -16,6 +16,12 @@ + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LibraryInboundApiKeyAdminTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LibraryInboundApiKeyAdminTests.cs new file mode 100644 index 00000000..31c8de26 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LibraryInboundApiKeyAdminTests.cs @@ -0,0 +1,202 @@ +using Microsoft.Data.Sqlite; +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.Sqlite; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security; +using ZB.MOM.WW.ScadaBridge.Security; + +namespace ZB.MOM.WW.ScadaBridge.Security.Tests; + +/// +/// Inbound-API key re-arch (C1): unit tests for , the +/// app-side management seam that maps the Commons contract +/// onto the shared ZB.MOM.WW.Auth.ApiKeys admin facade (). +/// These run the REAL library against a per-test temp SQLite database (mirroring the library's +/// own ApiKeyAdminCommandsTests), so the mapping — token assembly, enabled flag from +/// RevokedUtc, scope ↔ method translation, revoke-then-delete — is verified end-to-end. +/// Tokens are only ever minted via . +/// +public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime +{ + private const string Pepper = "test-pepper-at-least-16-chars-long"; + private const string TokenPrefix = "sbk"; + + private readonly string _dbPath = + Path.Combine(Path.GetTempPath(), $"inbound-key-admin-{Guid.NewGuid():N}.sqlite"); + + private AuthSqliteConnectionFactory _factory = null!; + private SqliteAuthStoreMigrator _migrator = null!; + private SqliteApiKeyAdminStore _adminStore = null!; + private SqliteApiKeyAuditStore _auditStore = null!; + private ApiKeyOptions _options = null!; + private IInboundApiKeyAdmin _sut = null!; + + public async Task InitializeAsync() + { + _factory = new AuthSqliteConnectionFactory(_dbPath); + _migrator = new SqliteAuthStoreMigrator(_factory); + _adminStore = new SqliteApiKeyAdminStore(_factory); + _auditStore = new SqliteApiKeyAuditStore(_factory); + _options = new ApiKeyOptions { TokenPrefix = TokenPrefix, SqlitePath = _dbPath }; + + // Create the schema once up front so every command runs against a ready store. + await _migrator.MigrateAsync(CancellationToken.None); + + var commands = new ApiKeyAdminCommands( + _options, + _adminStore, + _auditStore, + new FakePepperProvider(Pepper), + _migrator, + new FixedTimeProvider(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero))); + + _sut = new LibraryInboundApiKeyAdmin(commands); + } + + [Fact] + public async Task CreateAsync_ReturnsKeyIdAndToken_TokenStartsWith_sbk() + { + InboundApiKeyCreated created = await _sut.CreateAsync( + "Service A", new[] { "MethodA", "MethodB" }, CancellationToken.None); + + Assert.False(string.IsNullOrWhiteSpace(created.KeyId)); + Assert.StartsWith($"{TokenPrefix}_{created.KeyId}_", created.Token); + + // The created key then appears in ListAsync, enabled, with the given methods. + IReadOnlyList keys = await _sut.ListAsync(CancellationToken.None); + InboundApiKeyInfo info = Assert.Single(keys, k => k.KeyId == created.KeyId); + Assert.Equal("Service A", info.Name); + Assert.True(info.Enabled); + Assert.Equal(new[] { "MethodA", "MethodB" }, info.Methods); + } + + [Fact] + public async Task SetEnabledAsync_False_ThenTrue_Toggles_ListedEnabledFlag() + { + InboundApiKeyCreated created = await _sut.CreateAsync( + "Toggle Key", new[] { "MethodA" }, CancellationToken.None); + + Assert.True((await SingleAsync(created.KeyId)).Enabled); + + Assert.True(await _sut.SetEnabledAsync(created.KeyId, enabled: false, CancellationToken.None)); + Assert.False((await SingleAsync(created.KeyId)).Enabled); + + Assert.True(await _sut.SetEnabledAsync(created.KeyId, enabled: true, CancellationToken.None)); + Assert.True((await SingleAsync(created.KeyId)).Enabled); + } + + [Fact] + public async Task SetEnabledAsync_UnknownKey_ReturnsFalse() + { + Assert.False(await _sut.SetEnabledAsync("does-not-exist", enabled: false, CancellationToken.None)); + } + + [Fact] + public async Task SetMethodsAsync_ReplacesMethods() + { + InboundApiKeyCreated created = await _sut.CreateAsync( + "Scope Key", new[] { "Old1", "Old2" }, CancellationToken.None); + + Assert.True(await _sut.SetMethodsAsync( + created.KeyId, new[] { "New1", "New2", "New3" }, CancellationToken.None)); + + // ListAsync reflects the new (ordinally-sorted) methods. + InboundApiKeyInfo info = await SingleAsync(created.KeyId); + Assert.Equal(new[] { "New1", "New2", "New3" }, info.Methods); + + // GetMethodsForKeyAsync returns them. + IReadOnlyList methods = await _sut.GetMethodsForKeyAsync(created.KeyId, CancellationToken.None); + Assert.Equal(new[] { "New1", "New2", "New3" }, methods); + + // GetKeysForMethodAsync finds the key for an in-scope method, not for an out-of-scope one. + IReadOnlyList inScope = await _sut.GetKeysForMethodAsync("New2", CancellationToken.None); + Assert.Contains(created.KeyId, inScope); + + IReadOnlyList outOfScope = await _sut.GetKeysForMethodAsync("Old1", CancellationToken.None); + Assert.DoesNotContain(created.KeyId, outOfScope); + } + + [Fact] + public async Task DeleteAsync_RemovesKey() + { + InboundApiKeyCreated created = await _sut.CreateAsync( + "Delete Key", new[] { "MethodA" }, CancellationToken.None); + + // Sanity: present before delete. + Assert.Contains( + await _sut.ListAsync(CancellationToken.None), + k => k.KeyId == created.KeyId); + + Assert.True(await _sut.DeleteAsync(created.KeyId, CancellationToken.None)); + + // After delete, ListAsync no longer contains it. + Assert.DoesNotContain( + await _sut.ListAsync(CancellationToken.None), + k => k.KeyId == created.KeyId); + } + + [Fact] + public async Task GetKeysForMethodAsync_ReturnsOnlyKeysScopedToThatMethod() + { + InboundApiKeyCreated a = await _sut.CreateAsync( + "Key A", new[] { "Shared", "OnlyA" }, CancellationToken.None); + InboundApiKeyCreated b = await _sut.CreateAsync( + "Key B", new[] { "Shared", "OnlyB" }, CancellationToken.None); + InboundApiKeyCreated c = await _sut.CreateAsync( + "Key C", new[] { "Unrelated" }, CancellationToken.None); + + IReadOnlyList sharedKeys = await _sut.GetKeysForMethodAsync("Shared", CancellationToken.None); + Assert.Equal( + new[] { a.KeyId, b.KeyId }.OrderBy(x => x, StringComparer.Ordinal), + sharedKeys.OrderBy(x => x, StringComparer.Ordinal)); + Assert.DoesNotContain(c.KeyId, sharedKeys); + + IReadOnlyList onlyAKeys = await _sut.GetKeysForMethodAsync("OnlyA", CancellationToken.None); + Assert.Equal(new[] { a.KeyId }, onlyAKeys); + + // A method nobody is scoped to yields no keys. + Assert.Empty(await _sut.GetKeysForMethodAsync("Nobody", CancellationToken.None)); + } + + [Fact] + public async Task GetMethodsForKeyAsync_UnknownKey_ReturnsEmpty() + { + Assert.Empty(await _sut.GetMethodsForKeyAsync("does-not-exist", CancellationToken.None)); + } + + private async Task SingleAsync(string keyId) + { + IReadOnlyList keys = await _sut.ListAsync(CancellationToken.None); + return Assert.Single(keys, k => k.KeyId == keyId); + } + + public Task DisposeAsync() + { + // SqliteConnection pooling can hold the file open; clear pools before deleting + // so the temp DB (and its -wal/-shm sidecars) are removed. + SqliteConnection.ClearAllPools(); + foreach (var suffix in new[] { "", "-wal", "-shm" }) + { + var path = _dbPath + suffix; + if (File.Exists(path)) + { + File.Delete(path); + } + } + + return Task.CompletedTask; + } + + private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider + { + public string? GetPepper() => pepper; + } + + private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + private readonly DateTimeOffset _now = now; + + public override DateTimeOffset GetUtcNow() => _now; + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ZB.MOM.WW.ScadaBridge.Security.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ZB.MOM.WW.ScadaBridge.Security.Tests.csproj index 057bfee4..26459727 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ZB.MOM.WW.ScadaBridge.Security.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ZB.MOM.WW.ScadaBridge.Security.Tests.csproj @@ -22,6 +22,10 @@ + +