Dashboard: delete revoked API keys + confirm Rotate/Revoke/Delete
Add IApiKeyAdminStore.DeleteAsync that only deletes already-revoked rows (active keys must be revoked first so the revoke event lands in the audit log before the row disappears) and a matching admin-gated DashboardApiKeyManagementService.DeleteAsync. ApiKeysPage now shows Delete on revoked rows in place of the old "No actions" stub, and Rotate/Revoke/Delete all route through ConfirmDialog so each destructive action requires an explicit confirmation step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,15 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<ConfirmDialog IsOpen="@(PendingAction is not null)"
|
||||||
|
Title="@(PendingAction?.Title ?? string.Empty)"
|
||||||
|
Message="@(PendingAction?.Message ?? string.Empty)"
|
||||||
|
ConfirmLabel="@(PendingAction?.ConfirmLabel ?? "Confirm")"
|
||||||
|
ConfirmButtonClass="@(PendingAction?.ConfirmButtonClass ?? "btn-primary")"
|
||||||
|
IsBusy="IsBusy"
|
||||||
|
OnConfirm="ConfirmPendingAsync"
|
||||||
|
OnCancel="CancelPending" />
|
||||||
|
|
||||||
@if (IsCreateDialogOpen)
|
@if (IsCreateDialogOpen)
|
||||||
{
|
{
|
||||||
<div class="modal-backdrop fade show"></div>
|
<div class="modal-backdrop fade show"></div>
|
||||||
@@ -171,18 +180,22 @@ else
|
|||||||
revoked key is not un-revoked as a side effect of rotation. *@
|
revoked key is not un-revoked as a side effect of rotation. *@
|
||||||
<button type="button" class="btn btn-outline-secondary"
|
<button type="button" class="btn btn-outline-secondary"
|
||||||
disabled="@IsBusy"
|
disabled="@IsBusy"
|
||||||
@onclick="() => RotateApiKeyAsync(key.KeyId)">
|
@onclick="() => RequestRotate(key.KeyId)">
|
||||||
Rotate
|
Rotate
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-danger"
|
<button type="button" class="btn btn-outline-danger"
|
||||||
disabled="@IsBusy"
|
disabled="@IsBusy"
|
||||||
@onclick="() => RevokeApiKeyAsync(key.KeyId)">
|
@onclick="() => RequestRevoke(key.KeyId)">
|
||||||
Revoke
|
Revoke
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="text-muted small">No actions</span>
|
<button type="button" class="btn btn-outline-danger"
|
||||||
|
disabled="@IsBusy"
|
||||||
|
@onclick="() => RequestDelete(key.KeyId)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -252,24 +265,80 @@ else
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RevokeApiKeyAsync(string keyId)
|
private PendingConfirm? PendingAction { get; set; }
|
||||||
|
|
||||||
|
private void RequestRotate(string keyId)
|
||||||
{
|
{
|
||||||
await RunManagementActionAsync(user => ApiKeyManagementService.RevokeAsync(
|
if (IsBusy)
|
||||||
user,
|
{
|
||||||
keyId,
|
return;
|
||||||
CancellationToken.None))
|
}
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
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(
|
if (IsBusy)
|
||||||
user,
|
{
|
||||||
keyId,
|
return;
|
||||||
CancellationToken.None))
|
}
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
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<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> action = PendingAction.Action;
|
||||||
|
PendingAction = null;
|
||||||
|
await RunManagementActionAsync(action).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PendingConfirm(
|
||||||
|
string Title,
|
||||||
|
string Message,
|
||||||
|
string ConfirmLabel,
|
||||||
|
string ConfirmButtonClass,
|
||||||
|
Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> Action);
|
||||||
|
|
||||||
private async Task RunManagementActionAsync(
|
private async Task RunManagementActionAsync(
|
||||||
Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> action)
|
Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> action)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -143,6 +143,39 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<DashboardApiKeyManagementResult> 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(
|
private async Task AppendAuditAsync(
|
||||||
string? keyId,
|
string? keyId,
|
||||||
string eventType,
|
string eventType,
|
||||||
|
|||||||
@@ -20,4 +20,9 @@ public interface IDashboardApiKeyManagementService
|
|||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
string keyId,
|
string keyId,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<DashboardApiKeyManagementResult> DeleteAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
string keyId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,15 @@ public interface IApiKeyAdminStore
|
|||||||
byte[] secretHash,
|
byte[] secretHash,
|
||||||
DateTimeOffset rotatedUtc,
|
DateTimeOffset rotatedUtc,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="keyId">Key identifier.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>True if a revoked key was deleted; false if the key is missing or active.</returns>
|
||||||
|
Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,23 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
|||||||
return rows > 0;
|
return rows > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> 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)
|
private static void AddCreateParameters(SqliteCommand command, ApiKeyCreateRequest request)
|
||||||
{
|
{
|
||||||
command.Parameters.AddWithValue("$key_id", request.KeyId);
|
command.Parameters.AddWithValue("$key_id", request.KeyId);
|
||||||
|
|||||||
+63
@@ -112,6 +112,56 @@ public sealed class DashboardApiKeyManagementServiceTests
|
|||||||
&& entry.Details == "rotated");
|
&& 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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server-004 regression: the dashboard create path must reject a request
|
/// Server-004 regression: the dashboard create path must reject a request
|
||||||
/// carrying a non-canonical scope string rather than persisting a key whose
|
/// 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 RevokeCount { get; private set; }
|
||||||
|
|
||||||
|
public int DeleteCount { get; private set; }
|
||||||
|
|
||||||
public bool RevokeResult { get; init; }
|
public bool RevokeResult { get; init; }
|
||||||
|
|
||||||
public bool RotateResult { get; init; }
|
public bool RotateResult { get; init; }
|
||||||
|
|
||||||
|
public bool DeleteResult { get; init; }
|
||||||
|
|
||||||
public string? LastRevokedKeyId { get; private set; }
|
public string? LastRevokedKeyId { get; private set; }
|
||||||
|
|
||||||
|
public string? LastDeletedKeyId { get; private set; }
|
||||||
|
|
||||||
public byte[]? LastRotatedSecretHash { get; private set; }
|
public byte[]? LastRotatedSecretHash { get; private set; }
|
||||||
|
|
||||||
public List<ApiKeyCreateRequest> CreatedRequests { get; } = [];
|
public List<ApiKeyCreateRequest> CreatedRequests { get; } = [];
|
||||||
@@ -225,6 +281,13 @@ public sealed class DashboardApiKeyManagementServiceTests
|
|||||||
LastRotatedSecretHash = secretHash;
|
LastRotatedSecretHash = secretHash;
|
||||||
return Task.FromResult(RotateResult);
|
return Task.FromResult(RotateResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
DeleteCount++;
|
||||||
|
LastDeletedKeyId = keyId;
|
||||||
|
return Task.FromResult(DeleteResult);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
|
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
|
||||||
|
|||||||
@@ -447,6 +447,11 @@ public sealed class DashboardSnapshotServiceTests
|
|||||||
{
|
{
|
||||||
return Task.FromResult(false);
|
return Task.FromResult(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
|
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
|
||||||
|
|||||||
Reference in New Issue
Block a user