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