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).