diff --git a/ZB.MOM.WW.Auth/Directory.Build.props b/ZB.MOM.WW.Auth/Directory.Build.props index c48c525..566ed20 100644 --- a/ZB.MOM.WW.Auth/Directory.Build.props +++ b/ZB.MOM.WW.Auth/Directory.Build.props @@ -5,7 +5,7 @@ enable enable latest - 0.1.2 + 0.1.3 true diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs index f0583ba..05ad04e 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs @@ -55,6 +55,12 @@ public interface IApiKeyAdminStore Task RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct); Task DeleteAsync(string keyId, CancellationToken ct); + /// Replaces the scope set on an existing key. Does not touch the secret. Returns false if the key does not exist. + Task SetScopesAsync(string keyId, IReadOnlySet scopes, CancellationToken ct); + + /// Enables (clears revoked_utc) or disables (sets revoked_utc) a key WITHOUT changing its secret. Returns false if the key does not exist. + Task SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct); + /// /// Enumerates all API keys as hash-free projections, newest first. /// The secret hash is never selected, so callers cannot use this to recover secret material. diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs index 5aeab04..3d9d071 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs @@ -187,6 +187,53 @@ public sealed class ApiKeyAdminCommands return new KeyActionResult(deleted, status); } + /// + /// set-scopes: replaces the scope set on an existing key WITHOUT touching its secret, and + /// appends a set-scopes audit entry. Only the scope count is recorded in the audit + /// details — the scope values themselves are not logged verbatim. + /// All attempts are audited, including failures (key not found) — this is intentional to + /// maintain a complete security trail. + /// + public async Task SetScopesAsync( + string keyId, IReadOnlySet scopes, string? remoteAddress, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + ArgumentNullException.ThrowIfNull(scopes); + + bool updated = await _adminStore.SetScopesAsync(keyId, scopes, ct).ConfigureAwait(false); + + string status = updated ? "scopes-set" : "not-found"; + // Record only the count, never the scope contents, to avoid leaking authority detail into audit. + await AppendAuditAsync(keyId, "set-scopes", remoteAddress, $"{status}; count={scopes.Count}", ct) + .ConfigureAwait(false); + + return new KeyActionResult(updated, status); + } + + /// + /// enable-key / disable-key: reversibly toggles a key's active state WITHOUT changing its + /// secret, and appends an enable-key (when enabling) or disable-key (when + /// disabling) audit entry. + /// All attempts are audited, including failures (key not found) — this is intentional to + /// maintain a complete security trail. + /// + public async Task SetEnabledAsync( + string keyId, bool enabled, string? remoteAddress, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + + DateTimeOffset now = _clock.GetUtcNow(); + bool updated = await _adminStore.SetEnabledAsync(keyId, enabled, now, ct).ConfigureAwait(false); + + string eventType = enabled ? "enable-key" : "disable-key"; + string status = updated + ? (enabled ? "enabled" : "disabled") + : "not-found"; + await AppendAuditAsync(keyId, eventType, remoteAddress, status, ct).ConfigureAwait(false); + + return new KeyActionResult(updated, status); + } + private string RequirePepper() { string? pepper = _pepperProvider.GetPepper(); diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs index 9607c23..a7261a6 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs @@ -4,7 +4,8 @@ using ZB.MOM.WW.Auth.Abstractions.ApiKeys; namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite; /// -/// SQLite-backed administration store for API keys (create, revoke, rotate, delete). +/// SQLite-backed administration store for API keys (create, revoke, rotate, delete, +/// set-scopes, enable/disable). /// public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore { @@ -85,6 +86,67 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio return rows > 0; } + /// + public async Task SetScopesAsync(string keyId, IReadOnlySet scopes, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + ArgumentNullException.ThrowIfNull(scopes); + + await using SqliteConnection connection = + await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false); + + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = """ + UPDATE api_keys + SET scopes = $scopes + WHERE key_id = $key_id; + """; + command.Parameters.AddWithValue("$key_id", keyId); + command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(scopes)); + + int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + + return rows > 0; + } + + /// + public async Task SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + + await using SqliteConnection connection = + await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false); + + await using SqliteCommand command = connection.CreateCommand(); + + // Reversible toggle: NO `revoked_utc IS NULL` guard (unlike RevokeAsync), so it works + // regardless of current state. Deliberately leaves secret_hash and last_used_utc untouched + // — that is what distinguishes re-enable from RotateAsync. + if (enabled) + { + command.CommandText = """ + UPDATE api_keys + SET revoked_utc = NULL + WHERE key_id = $key_id; + """; + command.Parameters.AddWithValue("$key_id", keyId); + } + else + { + command.CommandText = """ + UPDATE api_keys + SET revoked_utc = $revoked_utc + WHERE key_id = $key_id; + """; + command.Parameters.AddWithValue("$key_id", keyId); + command.Parameters.AddWithValue("$revoked_utc", whenUtc.ToString("O")); + } + + int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + + return rows > 0; + } + /// public async Task DeleteAsync(string keyId, CancellationToken ct) { diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs index 6e00ba4..58fb904 100644 --- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs @@ -292,6 +292,59 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime Assert.Equal(auditCountBefore, auditCountAfter); } + // --- set-scopes / enable-disable --- + + [Fact] + public async Task SetEnabledAsync_And_SetScopesAsync_AppendAuditEntries() + { + ApiKeyAdminCommands commands = BuildCommands(); + await commands.InitDbAsync(null, CancellationToken.None); + await commands.CreateKeyAsync( + "key-1", + "Service A", + new HashSet(["read"], StringComparer.Ordinal), + null, + null, + CancellationToken.None); + + // Disable, then re-enable, then replace scopes. + KeyActionResult disabled = + await commands.SetEnabledAsync("key-1", enabled: false, "10.0.0.1", CancellationToken.None); + Assert.True(disabled.Succeeded); + Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None)); + + KeyActionResult enabled = + await commands.SetEnabledAsync("key-1", enabled: true, "10.0.0.1", CancellationToken.None); + Assert.True(enabled.Succeeded); + Assert.NotNull(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None)); + + KeyActionResult scoped = await commands.SetScopesAsync( + "key-1", + new HashSet(["read", "write"], StringComparer.Ordinal), + "10.0.0.1", + CancellationToken.None); + Assert.True(scoped.Succeeded); + + IReadOnlyList recent = await _audit.ListRecentAsync(50, CancellationToken.None); + Assert.Single(recent, e => e.EventType == "disable-key"); + Assert.Single(recent, e => e.EventType == "enable-key"); + Assert.Single(recent, e => e.EventType == "set-scopes"); + + IReadOnlyList listed = await commands.ListKeysAsync(CancellationToken.None); + ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1"); + Assert.True(item.Scopes.SetEquals(new HashSet(["read", "write"], StringComparer.Ordinal))); + } + + [Fact] + public async Task SetScopesAsync_NullScopes_Throws() + { + ApiKeyAdminCommands commands = BuildCommands(); + await commands.InitDbAsync(null, CancellationToken.None); + + await Assert.ThrowsAnyAsync(() => + commands.SetScopesAsync("key-1", null!, null, CancellationToken.None)); + } + // --- delete-key --- [Fact] diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs index da4af84..b1692c3 100644 --- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs @@ -105,6 +105,87 @@ public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime Assert.False(result); } + // --- SetScopes --- + + [Fact] + public async Task SetScopesAsync_ReplacesScopes_AndReturnsTrue() + { + await _admin.CreateAsync( + SampleRecord("key-1") with { Scopes = new HashSet(["a"], StringComparer.Ordinal) }, + CancellationToken.None); + + bool result = await _admin.SetScopesAsync( + "key-1", + new HashSet(["b", "c"], StringComparer.Ordinal), + CancellationToken.None); + + Assert.True(result); + IReadOnlyList listed = await _admin.ListAsync(CancellationToken.None); + ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1"); + Assert.True(item.Scopes.SetEquals(new HashSet(["b", "c"], StringComparer.Ordinal))); + } + + [Fact] + public async Task SetScopesAsync_UnknownKey_ReturnsFalse() + { + bool result = await _admin.SetScopesAsync( + "missing", + new HashSet(["b"], StringComparer.Ordinal), + CancellationToken.None); + + Assert.False(result); + } + + // --- SetEnabled --- + + [Fact] + public async Task SetEnabledAsync_False_DisablesKey() + { + await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None); + var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero); + + bool result = await _admin.SetEnabledAsync("key-1", enabled: false, when, CancellationToken.None); + + Assert.True(result); + Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None)); + ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None); + Assert.Equal(when, found!.RevokedUtc); + } + + [Fact] + public async Task SetEnabledAsync_True_ReenablesKey_WithoutChangingSecret() + { + ApiKeyRecord original = SampleRecord("key-1"); + await _admin.CreateAsync(original, CancellationToken.None); + // Record some usage so we can prove last_used_utc is left untouched on re-enable. + var used = new DateTimeOffset(2026, 5, 20, 12, 0, 0, TimeSpan.Zero); + await _read.MarkUsedAsync("key-1", used, CancellationToken.None); + + // Disable, then re-enable. + await _admin.SetEnabledAsync( + "key-1", enabled: false, new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero), CancellationToken.None); + bool result = await _admin.SetEnabledAsync( + "key-1", enabled: true, new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero), CancellationToken.None); + + Assert.True(result); + + // Active again, and the secret hash + last-used timestamp are unchanged. + ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None); + Assert.NotNull(active); + Assert.True(active!.SecretHash.SequenceEqual(original.SecretHash)); + Assert.Null(active.RevokedUtc); + Assert.Equal(used, active.LastUsedUtc); + } + + [Fact] + public async Task SetEnabledAsync_UnknownKey_ReturnsFalse() + { + bool result = await _admin.SetEnabledAsync( + "missing", enabled: false, DateTimeOffset.UtcNow, CancellationToken.None); + + Assert.False(result); + } + // --- Delete --- [Fact] diff --git a/components/auth/shared-contract/ZB.MOM.WW.Auth.md b/components/auth/shared-contract/ZB.MOM.WW.Auth.md index 89a2da4..648e3fc 100644 --- a/components/auth/shared-contract/ZB.MOM.WW.Auth.md +++ b/components/auth/shared-contract/ZB.MOM.WW.Auth.md @@ -99,7 +99,10 @@ public interface IApiKeyStore { // default: SQLite (hash, scope Task FindByKeyIdAsync(string keyId, CancellationToken ct); Task MarkUsedAsync(string keyId, CancellationToken ct); } -public interface IApiKeyAdminStore { /* create / revoke / rotate / delete + audit */ } +public interface IApiKeyAdminStore { /* create / revoke / rotate / delete + audit */ + Task SetScopesAsync(string keyId, IReadOnlySet scopes, CancellationToken ct); // 0.1.3: replace scope set; secret untouched + Task SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct); // 0.1.3: reversible enable/disable toggle; secret untouched +} ``` - Constraints are carried as an **opaque `object`** (project supplies the policy: mxaccessgw @@ -107,6 +110,22 @@ public interface IApiKeyAdminStore { /* create / revoke / rotate / delete + audi parse→lookup→peppered-HMAC→constant-time-compare→audit pipeline; it does **not** interpret constraints. - Ships the `apikey` admin verbs as a reusable command set. +### 0.1.3 admin additions + +`0.1.3` adds **editable scopes** and a **reversible enable/disable toggle** with **no schema +change** (still `CurrentVersion = 2`). Both land on `IApiKeyAdminStore` and the +`ApiKeyAdminCommands` facade: + +- `IApiKeyAdminStore.SetScopesAsync(keyId, scopes, ct)` — replaces a key's scope set; never + touches the secret. Returns `false` if the key is unknown. +- `IApiKeyAdminStore.SetEnabledAsync(keyId, enabled, whenUtc, ct)` — clears (`enabled: true`) or + sets (`enabled: false`) `revoked_utc` regardless of current state; leaves `secret_hash` and + `last_used_utc` untouched (the distinction from rotate). Returns `false` if the key is unknown. +- `ApiKeyAdminCommands.SetScopesAsync(...)` — audited `set-scopes` verb (records scope **count**, + not contents); returns `KeyActionResult`. +- `ApiKeyAdminCommands.SetEnabledAsync(...)` — audited `enable-key` / `disable-key` verb; + returns `KeyActionResult`. + ## `ZB.MOM.WW.Auth.AspNetCore` - Canonical `ClaimTypes` constants (name, display, username, role, scope-id).