Files
scadaproj/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs
T
Joseph Doherty 544a6ddb77 Fix all baseline code-review findings across the six shared libraries
Resolves the 35 findings from the 2026-06-01 baseline (commit 26ba1c7),
test-first for every behavioral change. +51 tests (331 -> 382 passing, 0 failed).

- Telemetry-001 (HIGH): RedactionEnricher now honours property removal, so a
  redactor that drops a key actually scrubs the secret from the event.
- Auth: LDAP validator ValidateOnStart; API-key verify no longer fails on a
  best-effort MarkUsed write or a corrupt scopes column (fail-closed); LDAP cert
  validation hook; KeyPrefix persistence aligned; README algorithm corrected.
- Health: Akka checks return Degraded (not throw) when the cluster isn't up yet;
  GrpcDependencyHealthCheck catch-all; null 'description' rendered; composite
  endpoint builder; XML docs shipped.
- Audit: CompositeAuditWriter no longer re-throws OperationCanceledException;
  TruncatingAuditRedactor over-redact scrubs Target + safe negative max; options
  record; XML docs shipped.
- Configuration: TryAddEnumerable idempotent registration; consistent port
  quoting; strict invariant port parsing; XML docs + README packaged.
- Theme: mobile toggle is now CSS-only (no Bootstrap JS); token/CSS hygiene;
  XML docs on the public parameter surface.

Shared-contract/spec docs updated where the code was the source of truth
(observability service.instance.id, MapZbMetrics, redactor reach). All changes
additive/back-compatible at v0.1.0. code-reviews bookkeeping follows separately.
2026-06-01 11:22:14 -04:00

210 lines
9.3 KiB
C#

using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.Admin;
/// <summary>
/// Result of a verb that yields a freshly assembled token (create-key / rotate-key).
/// The <see cref="Token"/> is the ONLY moment the secret is ever available; it is never
/// retrievable afterwards. A <c>null</c> <see cref="Token"/> indicates the verb failed
/// (for example, rotating a key that does not exist).
/// </summary>
public sealed record CreateKeyResult(string KeyId, string? Token);
/// <summary>Result of a mutating verb that succeeds or fails without yielding a token.</summary>
public sealed record KeyActionResult(bool Succeeded, string? Message);
/// <summary>
/// Reusable, front-end-agnostic API-key administration command set. Each verb returns a
/// structured result and performs no console I/O, so consumers can wire their own CLI or HTTP
/// front-end on top. Audit is wired here (the command layer): every mutating verb appends an
/// <see cref="ApiKeyAuditEntry"/> via <see cref="IApiKeyAuditStore"/>.
/// </summary>
/// <remarks>
/// <para>
/// <c>create-key</c> and <c>rotate-key</c> return the assembled token EXACTLY ONCE — the only
/// time the secret is ever available. No other result carries the secret or its hash;
/// <see cref="ApiKeyListItem"/> is a hash-free projection by construction.
/// </para>
/// </remarks>
public sealed class ApiKeyAdminCommands
{
private readonly ApiKeyOptions _options;
private readonly IApiKeyAdminStore _adminStore;
private readonly IApiKeyAuditStore _auditStore;
private readonly IApiKeyPepperProvider _pepperProvider;
private readonly SqliteAuthStoreMigrator _migrator;
private readonly TimeProvider _clock;
/// <summary>Creates the command set over the supplied stores and options.</summary>
/// <param name="options">API-key options (token prefix, store path, ...).</param>
/// <param name="adminStore">Mutating store (create / revoke / rotate / delete / list).</param>
/// <param name="auditStore">Append-only audit store wired into every mutating verb.</param>
/// <param name="pepperProvider">Resolves the pepper used to hash secrets.</param>
/// <param name="migrator">Schema migrator used by <see cref="InitDbAsync"/>.</param>
/// <param name="clock">Optional clock; defaults to <see cref="TimeProvider.System"/>.</param>
public ApiKeyAdminCommands(
ApiKeyOptions options,
IApiKeyAdminStore adminStore,
IApiKeyAuditStore auditStore,
IApiKeyPepperProvider pepperProvider,
SqliteAuthStoreMigrator migrator,
TimeProvider? clock = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(adminStore);
ArgumentNullException.ThrowIfNull(auditStore);
ArgumentNullException.ThrowIfNull(pepperProvider);
ArgumentNullException.ThrowIfNull(migrator);
_options = options;
_adminStore = adminStore;
_auditStore = auditStore;
_pepperProvider = pepperProvider;
_migrator = migrator;
_clock = clock ?? TimeProvider.System;
}
/// <summary>
/// init-db: applies the schema migration, then appends an <c>init-db</c> audit entry.
/// </summary>
public async Task InitDbAsync(string? remoteAddress, CancellationToken ct)
{
await _migrator.MigrateAsync(ct).ConfigureAwait(false);
await AppendAuditAsync(keyId: null, "init-db", remoteAddress, details: null, ct).ConfigureAwait(false);
}
/// <summary>
/// create-key: generates a secret, persists its hash, appends a <c>create-key</c> audit entry,
/// and returns the assembled token <c>&lt;prefix&gt;_&lt;keyId&gt;_&lt;secret&gt;</c> EXACTLY ONCE.
/// </summary>
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
public async Task<CreateKeyResult> CreateKeyAsync(
string keyId,
string displayName,
IReadOnlySet<string> scopes,
string? constraintsJson,
string? remoteAddress,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (keyId.Contains('_'))
throw new ArgumentException("keyId must not contain '_'.", nameof(keyId));
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
ArgumentNullException.ThrowIfNull(scopes);
string pepper = RequirePepper();
string secret = ApiKeySecretGenerator.NewSecret();
byte[] secretHash = ApiKeySecretHasher.Hash(secret, pepper);
DateTimeOffset now = _clock.GetUtcNow();
var record = new ApiKeyRecord(
KeyId: keyId,
// KeyPrefix is the bare token prefix (e.g. "mxgw"), NOT prefix_keyId — the key id is
// already its own column. Embedding it here produced a self-referential value that
// confused admin tooling and disagreed with the read/test paths (see Auth-005).
KeyPrefix: _options.TokenPrefix,
SecretHash: secretHash,
DisplayName: displayName,
Scopes: scopes,
ConstraintsJson: constraintsJson,
CreatedUtc: now,
LastUsedUtc: null,
RevokedUtc: null);
await _adminStore.CreateAsync(record, ct).ConfigureAwait(false);
await AppendAuditAsync(keyId, "create-key", remoteAddress, details: null, ct).ConfigureAwait(false);
return new CreateKeyResult(keyId, AssembleToken(keyId, secret));
}
/// <summary>
/// list-keys: returns the hash-free <see cref="ApiKeyListItem"/> projection of every key,
/// newest first. This is a read, so it appends no audit entry and never carries secret material.
/// </summary>
public Task<IReadOnlyList<ApiKeyListItem>> ListKeysAsync(CancellationToken ct) =>
_adminStore.ListAsync(ct);
/// <summary>
/// revoke-key: marks the key revoked and appends a <c>revoke-key</c> audit entry.
/// All attempts are audited, including failures (key not found or already revoked) — this is
/// intentional to maintain a complete security trail.
/// </summary>
public async Task<KeyActionResult> RevokeKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
DateTimeOffset now = _clock.GetUtcNow();
bool revoked = await _adminStore.RevokeAsync(keyId, now, ct).ConfigureAwait(false);
string status = revoked ? "revoked" : "not-found-or-already-revoked";
await AppendAuditAsync(keyId, "revoke-key", remoteAddress, status, ct).ConfigureAwait(false);
return new KeyActionResult(revoked, status);
}
/// <summary>
/// rotate-key: replaces the stored secret with a freshly generated one and appends a
/// <c>rotate-key</c> audit entry. Returns a <see cref="CreateKeyResult"/> whose token is the new
/// secret (shown once); a <c>null</c> token indicates the key did not exist.
/// All attempts are audited, including failures (key not found) — this is intentional to
/// maintain a complete security trail.
/// </summary>
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
public async Task<CreateKeyResult> RotateKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
string pepper = RequirePepper();
string secret = ApiKeySecretGenerator.NewSecret();
byte[] newHash = ApiKeySecretHasher.Hash(secret, pepper);
bool rotated = await _adminStore.RotateAsync(keyId, newHash, ct).ConfigureAwait(false);
string status = rotated ? "rotated" : "not-found";
await AppendAuditAsync(keyId, "rotate-key", remoteAddress, status, ct).ConfigureAwait(false);
return new CreateKeyResult(keyId, rotated ? AssembleToken(keyId, secret) : null);
}
/// <summary>
/// delete-key: removes the key (only succeeds once it has been revoked) and appends a
/// <c>delete-key</c> audit entry.
/// All attempts are audited, including failures (key not found or not yet revoked) — this is
/// intentional to maintain a complete security trail.
/// </summary>
public async Task<KeyActionResult> DeleteKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
bool deleted = await _adminStore.DeleteAsync(keyId, ct).ConfigureAwait(false);
string status = deleted ? "deleted" : "not-found-or-not-revoked";
await AppendAuditAsync(keyId, "delete-key", remoteAddress, status, ct).ConfigureAwait(false);
return new KeyActionResult(deleted, status);
}
private string RequirePepper()
{
string? pepper = _pepperProvider.GetPepper();
if (string.IsNullOrWhiteSpace(pepper))
{
throw new InvalidOperationException("pepper unavailable");
}
return pepper;
}
private string AssembleToken(string keyId, string secret) =>
$"{_options.TokenPrefix}_{keyId}_{secret}";
private Task AppendAuditAsync(
string? keyId, string eventType, string? remoteAddress, string? details, CancellationToken ct) =>
_auditStore.AppendAsync(
new ApiKeyAuditEntry(keyId, eventType, remoteAddress, _clock.GetUtcNow(), details),
ct);
}