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);
}