diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor
index d3e6c0c..9874d9a 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor
@@ -39,6 +39,15 @@ else
}
+
+
@if (IsCreateDialogOpen)
{
@@ -171,18 +180,22 @@ else
revoked key is not un-revoked as a side effect of rotation. *@
}
else
{
- No actions
+
}
@@ -252,24 +265,80 @@ else
.ConfigureAwait(false);
}
- private async Task RevokeApiKeyAsync(string keyId)
+ private PendingConfirm? PendingAction { get; set; }
+
+ private void RequestRotate(string keyId)
{
- await RunManagementActionAsync(user => ApiKeyManagementService.RevokeAsync(
- user,
- keyId,
- CancellationToken.None))
- .ConfigureAwait(false);
+ if (IsBusy)
+ {
+ return;
+ }
+
+ PendingAction = new PendingConfirm(
+ Title: "Rotate API key?",
+ Message: $"Rotate the secret for key {keyId}? Any client still using the previous secret will start failing immediately.",
+ ConfirmLabel: "Rotate",
+ ConfirmButtonClass: "btn-warning",
+ Action: user => ApiKeyManagementService.RotateAsync(user, keyId, CancellationToken.None));
}
- private async Task RotateApiKeyAsync(string keyId)
+ private void RequestRevoke(string keyId)
{
- await RunManagementActionAsync(user => ApiKeyManagementService.RotateAsync(
- user,
- keyId,
- CancellationToken.None))
- .ConfigureAwait(false);
+ if (IsBusy)
+ {
+ return;
+ }
+
+ PendingAction = new PendingConfirm(
+ Title: "Revoke API key?",
+ Message: $"Revoke key {keyId}? Clients using it will be rejected on the next request.",
+ ConfirmLabel: "Revoke",
+ ConfirmButtonClass: "btn-danger",
+ Action: user => ApiKeyManagementService.RevokeAsync(user, keyId, CancellationToken.None));
}
+ private void RequestDelete(string keyId)
+ {
+ if (IsBusy)
+ {
+ return;
+ }
+
+ PendingAction = new PendingConfirm(
+ Title: "Delete API key?",
+ Message: $"Permanently delete revoked key {keyId}? This removes the row from the auth database — only the audit log will retain the history.",
+ ConfirmLabel: "Delete",
+ ConfirmButtonClass: "btn-danger",
+ Action: user => ApiKeyManagementService.DeleteAsync(user, keyId, CancellationToken.None));
+ }
+
+ private void CancelPending()
+ {
+ if (!IsBusy)
+ {
+ PendingAction = null;
+ }
+ }
+
+ private async Task ConfirmPendingAsync()
+ {
+ if (IsBusy || PendingAction is null)
+ {
+ return;
+ }
+
+ Func> action = PendingAction.Action;
+ PendingAction = null;
+ await RunManagementActionAsync(action).ConfigureAwait(false);
+ }
+
+ private sealed record PendingConfirm(
+ string Title,
+ string Message,
+ string ConfirmLabel,
+ string ConfirmButtonClass,
+ Func> Action);
+
private async Task RunManagementActionAsync(
Func> action)
{
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs
index 5fc8236..56c8b6f 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs
@@ -143,6 +143,39 @@ public sealed class DashboardApiKeyManagementService(
}
}
+ public async Task DeleteAsync(
+ ClaimsPrincipal user,
+ string keyId,
+ CancellationToken cancellationToken)
+ {
+ if (!CanManage(user))
+ {
+ return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
+ }
+
+ string? validation = ValidateKeyId(keyId);
+ if (validation is not null)
+ {
+ return DashboardApiKeyManagementResult.Fail(validation);
+ }
+
+ string normalizedKeyId = keyId.Trim();
+ bool deleted = await adminStore
+ .DeleteAsync(normalizedKeyId, cancellationToken)
+ .ConfigureAwait(false);
+
+ await AppendAuditAsync(
+ normalizedKeyId,
+ "dashboard-delete-key",
+ deleted ? "deleted" : "not-found-or-active",
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ return deleted
+ ? DashboardApiKeyManagementResult.Success("API key deleted.")
+ : DashboardApiKeyManagementResult.Fail("API key was not found, or is still active. Revoke it before deleting.");
+ }
+
private async Task AppendAuditAsync(
string? keyId,
string eventType,
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs
index 5c83c13..4164976 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs
@@ -20,4 +20,9 @@ public interface IDashboardApiKeyManagementService
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken);
+
+ Task DeleteAsync(
+ ClaimsPrincipal user,
+ string keyId,
+ CancellationToken cancellationToken);
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs
index 4f66e8c..a5d7c10 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs
@@ -39,4 +39,15 @@ public interface IApiKeyAdminStore
byte[] secretHash,
DateTimeOffset rotatedUtc,
CancellationToken cancellationToken);
+
+ ///
+ /// Permanently deletes an API key, but only if it is already revoked. Active keys are
+ /// untouched (returns false) so an admin cannot delete a working credential without
+ /// first revoking it — that preserves the audit trail and forces the revoke event to
+ /// land in the audit log before the row disappears.
+ ///
+ /// Key identifier.
+ /// Cancellation token.
+ /// True if a revoked key was deleted; false if the key is missing or active.
+ Task DeleteAsync(string keyId, CancellationToken cancellationToken);
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs
index 63c0234..38cf68a 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs
@@ -109,6 +109,23 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
return rows > 0;
}
+ ///
+ public async Task DeleteAsync(string keyId, CancellationToken cancellationToken)
+ {
+ await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
+
+ await using SqliteCommand command = connection.CreateCommand();
+ command.CommandText = """
+ DELETE FROM api_keys
+ WHERE key_id = $key_id AND revoked_utc IS NOT NULL;
+ """;
+ command.Parameters.AddWithValue("$key_id", keyId);
+
+ int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+
+ return rows > 0;
+ }
+
private static void AddCreateParameters(SqliteCommand command, ApiKeyCreateRequest request)
{
command.Parameters.AddWithValue("$key_id", request.KeyId);
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs
index 105f058..cef0222 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs
@@ -112,6 +112,56 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.Details == "rotated");
}
+ [Fact]
+ public async Task DeleteAsync_UnauthorizedUser_DoesNotCallStore()
+ {
+ FakeApiKeyAdminStore adminStore = new();
+ DashboardApiKeyManagementService service = CreateService(adminStore);
+
+ DashboardApiKeyManagementResult result = await service.DeleteAsync(
+ new ClaimsPrincipal(new ClaimsIdentity()),
+ "operator01",
+ CancellationToken.None);
+
+ Assert.False(result.Succeeded);
+ Assert.Equal(0, adminStore.DeleteCount);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
+ {
+ FakeApiKeyAdminStore adminStore = new() { DeleteResult = true };
+ FakeApiKeyAuditStore auditStore = new();
+ DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
+
+ DashboardApiKeyManagementResult result = await service.DeleteAsync(
+ CreateAuthorizedUser(),
+ "operator01",
+ CancellationToken.None);
+
+ Assert.True(result.Succeeded);
+ Assert.Equal("operator01", adminStore.LastDeletedKeyId);
+ Assert.Contains(auditStore.Entries, entry =>
+ entry.EventType == "dashboard-delete-key"
+ && entry.KeyId == "operator01"
+ && entry.Details == "deleted");
+ }
+
+ [Fact]
+ public async Task DeleteAsync_WhenStoreRefuses_ReportsFriendlyError()
+ {
+ FakeApiKeyAdminStore adminStore = new() { DeleteResult = false };
+ DashboardApiKeyManagementService service = CreateService(adminStore);
+
+ DashboardApiKeyManagementResult result = await service.DeleteAsync(
+ CreateAuthorizedUser(),
+ "operator01",
+ CancellationToken.None);
+
+ Assert.False(result.Succeeded);
+ Assert.Contains("Revoke", result.Message, StringComparison.Ordinal);
+ }
+
///
/// Server-004 regression: the dashboard create path must reject a request
/// carrying a non-canonical scope string rather than persisting a key whose
@@ -184,12 +234,18 @@ public sealed class DashboardApiKeyManagementServiceTests
public int RevokeCount { get; private set; }
+ public int DeleteCount { get; private set; }
+
public bool RevokeResult { get; init; }
public bool RotateResult { get; init; }
+ public bool DeleteResult { get; init; }
+
public string? LastRevokedKeyId { get; private set; }
+ public string? LastDeletedKeyId { get; private set; }
+
public byte[]? LastRotatedSecretHash { get; private set; }
public List CreatedRequests { get; } = [];
@@ -225,6 +281,13 @@ public sealed class DashboardApiKeyManagementServiceTests
LastRotatedSecretHash = secretHash;
return Task.FromResult(RotateResult);
}
+
+ public Task DeleteAsync(string keyId, CancellationToken cancellationToken)
+ {
+ DeleteCount++;
+ LastDeletedKeyId = keyId;
+ return Task.FromResult(DeleteResult);
+ }
}
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs
index 6875e0a..c0794c9 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs
@@ -447,6 +447,11 @@ public sealed class DashboardSnapshotServiceTests
{
return Task.FromResult(false);
}
+
+ public Task DeleteAsync(string keyId, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(false);
+ }
}
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore