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