feat(apikeys): optional ExpiresUtc — create + verifier enforcement + sqlite v3 migration (archreview G-2)
ApiKeyRecord/ApiKeyListItem gain a nullable ExpiresUtc (NULL = never expires); ApiKeyFailure gains KeyExpired. ApiKeyVerifier rejects a key whose ExpiresUtc is at-or-before now (inclusive, injected clock), before the secret comparison. CreateKeyAsync gets an expiresUtc overload; rotate preserves existing expiry. SQLite schema bumps to v3: a nullable expires_utc column, added to fresh DBs via CREATE and to existing v1/v2 DBs via an idempotent guarded ALTER — donor v2 gateway-auth.db upgrades in place, no key invalidated. Version 0.1.3 -> 0.1.4 (not yet published; nuget push is human-gated). Consumers (HistorianGateway, mxaccessgw) bump to 0.1.4 to set/enforce expiry. 146 Auth.ApiKeys tests pass.
This commit is contained in:
@@ -79,11 +79,27 @@ public sealed class ApiKeyAdminCommands
|
||||
/// 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 Task<CreateKeyResult> CreateKeyAsync(
|
||||
string keyId,
|
||||
string displayName,
|
||||
IReadOnlySet<string> scopes,
|
||||
string? constraintsJson,
|
||||
string? remoteAddress,
|
||||
CancellationToken ct) =>
|
||||
CreateKeyAsync(keyId, displayName, scopes, constraintsJson, expiresUtc: null, remoteAddress, ct);
|
||||
|
||||
/// <summary>
|
||||
/// create-key with an optional absolute expiry. A non-null <paramref name="expiresUtc"/> is
|
||||
/// persisted on the record and enforced by <see cref="ApiKeyVerifier"/>; <c>null</c> means the
|
||||
/// key never expires (the same behaviour as the expiry-less overload).
|
||||
/// </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,
|
||||
DateTimeOffset? expiresUtc,
|
||||
string? remoteAddress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -111,7 +127,8 @@ public sealed class ApiKeyAdminCommands
|
||||
ConstraintsJson: constraintsJson,
|
||||
CreatedUtc: now,
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null);
|
||||
RevokedUtc: null,
|
||||
ExpiresUtc: expiresUtc);
|
||||
|
||||
await _adminStore.CreateAsync(record, ct).ConfigureAwait(false);
|
||||
await AppendAuditAsync(keyId, "create-key", remoteAddress, details: null, ct).ConfigureAwait(false);
|
||||
@@ -151,6 +168,11 @@ public sealed class ApiKeyAdminCommands
|
||||
/// All attempts are audited, including failures (key not found) — this is intentional to
|
||||
/// maintain a complete security trail.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rotation replaces only the secret (and clears revoked/last-used); it deliberately leaves any
|
||||
/// existing <see cref="ApiKeyRecord.ExpiresUtc"/> untouched, so a key's expiry survives a secret
|
||||
/// rotation. Changing a key's expiry is a create-time concern (use <see cref="CreateKeyAsync(string,string,IReadOnlySet{string},string?,DateTimeOffset?,string?,CancellationToken)"/>).
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
|
||||
public async Task<CreateKeyResult> RotateKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user