feat(auth): ScadaBridge re-pin Auth 0.1.3 + add IInboundApiKeyAdmin seam over library admin facade (re-arch C1, additive)

This commit is contained in:
Joseph Doherty
2026-06-02 03:32:25 -04:00
parent 1fcc4f5c2b
commit d09def2be0
7 changed files with 411 additions and 4 deletions
+4 -4
View File
@@ -80,10 +80,10 @@
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.3" />
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.3" />
<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.3" />
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.3" />
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
</ItemGroup>
@@ -0,0 +1,66 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
/// <summary>
/// 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 <see cref="InboundApiKeyCreated"/>.
/// </summary>
/// <param name="KeyId">Stable key identifier (the middle segment of the token).</param>
/// <param name="Name">Operator-facing display name.</param>
/// <param name="Enabled">True while the key is active (not revoked/disabled).</param>
/// <param name="Methods">The API-method names this key is scoped to call, sorted ordinally.</param>
/// <param name="CreatedUtc">When the key was created.</param>
/// <param name="LastUsedUtc">When the key last authenticated a request, if ever.</param>
public sealed record InboundApiKeyInfo(
string KeyId,
string Name,
bool Enabled,
IReadOnlyList<string> Methods,
DateTimeOffset CreatedUtc,
DateTimeOffset? LastUsedUtc);
/// <summary>
/// Result of creating a key. <see cref="Token"/> is the assembled bearer token
/// (<c>sbk_&lt;keyId&gt;_&lt;secret&gt;</c>) and is the ONLY moment the secret is available —
/// it is never retrievable afterwards.
/// </summary>
/// <param name="KeyId">The new key's identifier.</param>
/// <param name="Token">The bearer token, shown once.</param>
public sealed record InboundApiKeyCreated(string KeyId, string Token);
/// <summary>
/// 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.
/// </summary>
public interface IInboundApiKeyAdmin
{
/// <summary>Creates a new key scoped to <paramref name="methods"/> and returns its
/// identifier plus the bearer token (shown once).</summary>
Task<InboundApiKeyCreated> CreateAsync(
string name, IReadOnlyCollection<string> methods, CancellationToken ct = default);
/// <summary>Lists all inbound keys (hash-free projection).</summary>
Task<IReadOnlyList<InboundApiKeyInfo>> ListAsync(CancellationToken ct = default);
/// <summary>Enables or disables a key without changing its secret. Returns false if
/// the key does not exist.</summary>
Task<bool> SetEnabledAsync(string keyId, bool enabled, CancellationToken ct = default);
/// <summary>Replaces the method-scope set on a key without changing its secret.
/// Returns false if the key does not exist.</summary>
Task<bool> SetMethodsAsync(
string keyId, IReadOnlyCollection<string> methods, CancellationToken ct = default);
/// <summary>Removes a key (revoke-then-delete). Returns false if the key could not be
/// deleted.</summary>
Task<bool> DeleteAsync(string keyId, CancellationToken ct = default);
/// <summary>Returns the method-scope set for a key, or an empty list if not found.</summary>
Task<IReadOnlyList<string>> GetMethodsForKeyAsync(string keyId, CancellationToken ct = default);
/// <summary>Returns the identifiers of all keys whose scopes contain
/// <paramref name="methodName"/>.</summary>
Task<IReadOnlyList<string>> GetKeysForMethodAsync(string methodName, CancellationToken ct = default);
}
+20
View File
@@ -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<Microsoft.Extensions.Options.IOptions<ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyOptions>>().Value,
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAdminStore>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAuditStore>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.IApiKeyPepperProvider>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>(),
TimeProvider.System));
builder.Services.AddSingleton<
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security.IInboundApiKeyAdmin,
LibraryInboundApiKeyAdmin>();
builder.Services.AddManagementService();
var configDbConnectionString = configuration["ScadaBridge:Database:ConfigurationDb"]
@@ -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;
/// <summary>
/// Implements the Commons <see cref="IInboundApiKeyAdmin"/> management seam over the shared
/// <c>ZB.MOM.WW.Auth.ApiKeys</c> admin facade (<see cref="ApiKeyAdminCommands"/>). 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.
/// </summary>
/// <remarks>
/// Mapping from the library projection to the app DTO:
/// <list type="bullet">
/// <item>A key is "enabled" iff its <c>RevokedUtc</c> is null.</item>
/// <item>"Methods" are the library's <c>Scopes</c>, sorted ordinally for a stable display order.</item>
/// <item>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.</item>
/// </list>
/// </remarks>
public sealed class LibraryInboundApiKeyAdmin : IInboundApiKeyAdmin
{
private readonly ApiKeyAdminCommands _admin;
/// <summary>Creates the seam over the supplied library admin command set.</summary>
/// <param name="admin">The shared library admin facade.</param>
public LibraryInboundApiKeyAdmin(ApiKeyAdminCommands admin)
{
ArgumentNullException.ThrowIfNull(admin);
_admin = admin;
}
/// <inheritdoc />
public async Task<InboundApiKeyCreated> CreateAsync(
string name, IReadOnlyCollection<string> 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!);
}
/// <inheritdoc />
public async Task<IReadOnlyList<InboundApiKeyInfo>> ListAsync(CancellationToken ct = default)
{
var items = await _admin.ListKeysAsync(ct).ConfigureAwait(false);
var result = new List<InboundApiKeyInfo>(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;
}
/// <inheritdoc />
public async Task<bool> SetEnabledAsync(string keyId, bool enabled, CancellationToken ct = default) =>
(await _admin.SetEnabledAsync(keyId, enabled, remoteAddress: null, ct).ConfigureAwait(false)).Succeeded;
/// <inheritdoc />
public async Task<bool> SetMethodsAsync(
string keyId, IReadOnlyCollection<string> methods, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(methods);
return (await _admin.SetScopesAsync(
keyId, methods.ToHashSet(StringComparer.Ordinal), remoteAddress: null, ct)
.ConfigureAwait(false)).Succeeded;
}
/// <inheritdoc />
public async Task<bool> 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;
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> 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<string>)Array.Empty<string>();
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> 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();
}
}
@@ -16,6 +16,12 @@
<PackageReference Include="Novell.Directory.Ldap.NETStandard" />
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" />
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" />
<!-- Inbound-API key re-arch (C1): LibraryInboundApiKeyAdmin implements the
Commons IInboundApiKeyAdmin management seam over the shared admin facade
(ApiKeyAdminCommands). Security is the one project referenced by BOTH the
Host (ManagementActor, via ManagementService) and CentralUI, and it already
carries the rest of the Auth family — so the impl lives here. -->
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" />
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
@@ -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;
/// <summary>
/// Inbound-API key re-arch (C1): unit tests for <see cref="LibraryInboundApiKeyAdmin"/>, the
/// app-side management seam that maps the Commons <see cref="IInboundApiKeyAdmin"/> contract
/// onto the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> admin facade (<see cref="ApiKeyAdminCommands"/>).
/// These run the REAL library against a per-test temp SQLite database (mirroring the library's
/// own <c>ApiKeyAdminCommandsTests</c>), so the mapping — token assembly, enabled flag from
/// <c>RevokedUtc</c>, scope ↔ method translation, revoke-then-delete — is verified end-to-end.
/// Tokens are only ever minted via <see cref="IInboundApiKeyAdmin.CreateAsync"/>.
/// </summary>
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<InboundApiKeyInfo> 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<string> 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<string> inScope = await _sut.GetKeysForMethodAsync("New2", CancellationToken.None);
Assert.Contains(created.KeyId, inScope);
IReadOnlyList<string> 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<string> 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<string> 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<InboundApiKeyInfo> SingleAsync(string keyId)
{
IReadOnlyList<InboundApiKeyInfo> 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;
}
}
@@ -22,6 +22,10 @@
<!-- Task 1.2: the LDAP tests construct the shared library service / options directly. -->
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" />
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" />
<!-- C1: LibraryInboundApiKeyAdmin tests construct the real library Sqlite stores +
ApiKeyAdminCommands against a temp SQLite DB (mirroring the library's own
ApiKeyAdminCommands tests). -->
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" />
</ItemGroup>
<ItemGroup>