544a6ddb77
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.
210 lines
9.3 KiB
C#
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><prefix>_<keyId>_<secret></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);
|
|
}
|