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:
Joseph Doherty
2026-11-09 14:56:14 -05:00
parent 6112a9f447
commit e1487fbf10
8 changed files with 215 additions and 35 deletions
@@ -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