using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.Auth.ApiKeys.Sqlite; namespace ZB.MOM.WW.Auth.ApiKeys.Admin; /// /// Result of a verb that yields a freshly assembled token (create-key / rotate-key). /// The is the ONLY moment the secret is ever available; it is never /// retrievable afterwards. A null indicates the verb failed /// (for example, rotating a key that does not exist). /// public sealed record CreateKeyResult(string KeyId, string? Token); /// Result of a mutating verb that succeeds or fails without yielding a token. public sealed record KeyActionResult(bool Succeeded, string? Message); /// /// 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 /// via . /// /// /// /// create-key and rotate-key return the assembled token EXACTLY ONCE — the only /// time the secret is ever available. No other result carries the secret or its hash; /// is a hash-free projection by construction. /// /// 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; /// Creates the command set over the supplied stores and options. /// API-key options (token prefix, store path, ...). /// Mutating store (create / revoke / rotate / delete / list). /// Append-only audit store wired into every mutating verb. /// Resolves the pepper used to hash secrets. /// Schema migrator used by . /// Optional clock; defaults to . 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; } /// /// init-db: applies the schema migration, then appends an init-db audit entry. /// 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); } /// /// create-key: generates a secret, persists its hash, appends a create-key audit entry, /// and returns the assembled token <prefix>_<keyId>_<secret> EXACTLY ONCE. /// /// The pepper is unavailable; nothing is persisted or audited. public async Task CreateKeyAsync( string keyId, string displayName, IReadOnlySet 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)); } /// /// list-keys: returns the hash-free projection of every key, /// newest first. This is a read, so it appends no audit entry and never carries secret material. /// public Task> ListKeysAsync(CancellationToken ct) => _adminStore.ListAsync(ct); /// /// revoke-key: marks the key revoked and appends a revoke-key audit entry. /// All attempts are audited, including failures (key not found or already revoked) — this is /// intentional to maintain a complete security trail. /// public async Task 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); } /// /// rotate-key: replaces the stored secret with a freshly generated one and appends a /// rotate-key audit entry. Returns a whose token is the new /// secret (shown once); a null 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. /// /// The pepper is unavailable; nothing is persisted or audited. public async Task 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); } /// /// delete-key: removes the key (only succeeds once it has been revoked) and appends a /// delete-key audit entry. /// All attempts are audited, including failures (key not found or not yet revoked) — this is /// intentional to maintain a complete security trail. /// public async Task 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); }