Initial commit: scadaproj umbrella — sister-project index, auth component normalization (design + GAPS), and the built ZB.MOM.WW.Auth shared library (0.1.0, flattened in).
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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: $"{_options.TokenPrefix}_{keyId}",
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user