fix(secrets): UI reports a committed change as saved when its audit throws (scadaproj#34)
SecretEditor and ConfirmDeleteModal wrote the success audit inside the same try as the store write, so a throwing IAuditWriter after a committed add/rotate/delete showed an error, wrote a Failure audit and skipped the resolver-cache eviction - the old plaintext kept resolving for the TTL. The mutation try now covers only the cipher + store call; the success audit is best-effort afterwards and logs a warning (no value) on failure, so the change is reported saved, the callback fires and the cache is evicted. - tests: AuditFailureAfterCommitTests (add, rotate, delete) with a throwing success audit; all fail without the fix - code-reviews: Secrets-013 Resolved; README regenerated Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
@inject ISecretStore Store
|
||||
@inject IAuditWriter Audit
|
||||
@inject IServiceProvider Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@* Components/ConfirmDeleteModal.razor — an in-page Blazor overlay (NOT a JS confirm/alert dialog).
|
||||
Shown while Visible is true; Confirm tombstones + audits the delete, Cancel dismisses. *@
|
||||
|
||||
@@ -90,19 +91,7 @@
|
||||
{
|
||||
await Store.DeleteAsync(Name, actor, _cts.Token);
|
||||
|
||||
await Audit.WriteAsync(
|
||||
new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = actor,
|
||||
Action = "secret.delete",
|
||||
Outcome = AuditOutcome.Success,
|
||||
Category = "Secrets",
|
||||
Target = Name.Value,
|
||||
},
|
||||
_cts.Token);
|
||||
|
||||
// The tombstone is committed from here on.
|
||||
deleted = true;
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -112,6 +101,13 @@
|
||||
_error = "Unable to delete the secret.";
|
||||
await WriteFailureAuditAsync(actor);
|
||||
}
|
||||
|
||||
if (deleted)
|
||||
{
|
||||
// Outside the mutation try (scadaproj#34): a throwing audit sink must not turn a
|
||||
// committed delete into a reported failure, nor skip the cache eviction below.
|
||||
await WriteSuccessAuditAsync(actor);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -129,6 +125,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteSuccessAuditAsync(string actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Audit.WriteAsync(
|
||||
new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = actor,
|
||||
Action = "secret.delete",
|
||||
Outcome = AuditOutcome.Success,
|
||||
Category = "Secrets",
|
||||
Target = Name.Value,
|
||||
},
|
||||
_cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Best-effort: the delete itself succeeded. Logged so the audit gap is visible.
|
||||
Services.GetService<ILogger<ConfirmDeleteModal>>()?.LogWarning(
|
||||
ex,
|
||||
"Secret delete of {SecretName} by {Actor} succeeded, but its audit event could not be written.",
|
||||
Name.Value,
|
||||
actor);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteFailureAuditAsync(string actor)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@inject ISecretStore Store
|
||||
@inject IAuditWriter Audit
|
||||
@inject IServiceProvider Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@* Components/SecretEditor.razor — add (ExistingName == null) or rotate (ExistingName set) a secret.
|
||||
The value is entered through a masked input and never rendered back or placed in any audit field. *@
|
||||
|
||||
@@ -137,19 +138,7 @@
|
||||
};
|
||||
await Store.UpsertAsync(row, _cts.Token);
|
||||
|
||||
await Audit.WriteAsync(
|
||||
new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = actor,
|
||||
Action = IsRotate ? "secret.rotate" : "secret.add",
|
||||
Outcome = AuditOutcome.Success,
|
||||
Category = "Secrets",
|
||||
Target = name.Value,
|
||||
},
|
||||
_cts.Token);
|
||||
|
||||
// The write is committed (and, on a replicated host, already published) from here on.
|
||||
saved = true;
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -161,6 +150,16 @@
|
||||
await WriteFailureAuditAsync(
|
||||
actor, IsRotate ? "secret.rotate" : "secret.add", name.Value);
|
||||
}
|
||||
|
||||
if (saved)
|
||||
{
|
||||
// Outside the mutation try (scadaproj#34): an audit sink that throws AFTER the store
|
||||
// committed must not report the change as failed, audit it as a Failure, or skip the
|
||||
// cache eviction below — the operator would retry, or believe the leaked credential
|
||||
// was still live while the resolver kept serving the old plaintext.
|
||||
await WriteSuccessAuditAsync(
|
||||
actor, IsRotate ? "secret.rotate" : "secret.add", name.Value);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -178,6 +177,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteSuccessAuditAsync(string actor, string action, string target)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Audit.WriteAsync(
|
||||
new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = actor,
|
||||
Action = action,
|
||||
Outcome = AuditOutcome.Success,
|
||||
Category = "Secrets",
|
||||
Target = target,
|
||||
},
|
||||
_cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Best-effort: the change itself succeeded. Logged (name + action only, never the value)
|
||||
// so the gap in the audit trail is visible to whoever runs the host.
|
||||
Services.GetService<ILogger<SecretEditor>>()?.LogWarning(
|
||||
ex,
|
||||
"Secret {Action} of {SecretName} by {Actor} succeeded, but its audit event could not be written.",
|
||||
action,
|
||||
target,
|
||||
actor);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteFailureAuditAsync(string actor, string action, string target)
|
||||
{
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user