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 @@
+
+