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