feat(management): server-side secured-write TTL — stale pending writes expire, never execute (arch-review S2)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 05:38:19 -04:00
parent b3e2294e1d
commit 064a7d9415
7 changed files with 129 additions and 2 deletions
@@ -24,6 +24,14 @@ public enum AuditKind
SecuredWriteReject,
SecuredWriteExecute,
/// <summary>
/// A Pending secured write aged past its server-side TTL and was transitioned to
/// <c>Expired</c> without ever executing — the stale setpoint is never relayed to
/// the site. Emitted best-effort by the system (no verifier) when the approve/reject
/// path or the opportunistic list sweep observes an overdue Pending row. (arch-review S2)
/// </summary>
SecuredWriteExpire,
/// <summary>
/// A reconciliation pull row that failed to insert on every retry up to the
/// permanent-abandon threshold, so central advanced its cursor past it and
@@ -5,6 +5,7 @@ using System.Text.Json.Serialization;
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
@@ -1129,6 +1130,28 @@ public class ManagementActor : ReceiveActor
}
}
/// <summary>If the Pending row is older than the configured TTL, CAS it to Expired
/// (multi-node idempotent), audit best-effort, and return true. A stale setpoint
/// must never be approvable days later (arch-review S2).</summary>
private static async Task<bool> TryExpireIfStaleAsync(
IServiceProvider sp, ISecuredWriteRepository repo, PendingSecuredWrite row)
{
var ttl = sp.GetService<IOptions<ManagementServiceOptions>>()?.Value.SecuredWritePendingTtl
?? TimeSpan.FromHours(24);
if (ttl <= TimeSpan.Zero || DateTime.UtcNow - row.SubmittedAtUtc <= ttl) return false;
var expiredAt = DateTime.UtcNow;
if (await repo.TryMarkExpiredAsync(row.Id, expiredAt))
{
row.Status = "Expired";
row.DecidedAtUtc = expiredAt;
// No Discarded-equivalent below Expired in the AuditStatus enum; Discarded is
// the closest terminal status (the write was abandoned, never executed).
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteExpire, AuditStatus.Discarded, row, actor: "system");
}
return true;
}
private static async Task<object?> HandleSubmitSecuredWrite(
IServiceProvider sp, SubmitSecuredWriteCommand cmd, AuthenticatedUser user)
{
@@ -1182,6 +1205,14 @@ public class ManagementActor : ReceiveActor
throw new ManagementCommandException(
$"Secured write {cmd.Id} is '{row.Status}', not Pending; it cannot be approved.");
// Server-side TTL: a Pending write older than the configured age is transitioned
// to Expired and can never be approved — the stale setpoint is never relayed
// (arch-review S2). Checked BEFORE the self-approval/CAS so an overdue row never
// executes, regardless of who touches it.
if (await TryExpireIfStaleAsync(sp, repo, row))
throw new ManagementCommandException(
$"Secured write {cmd.Id} expired (pending longer than the configured TTL) and can no longer be approved or rejected.");
// Separation of duties: a write may not be verified by its submitter. Checked
// BEFORE the CAS so a self-approval never consumes the Pending->Approved
// transition.
@@ -1331,6 +1362,13 @@ public class ManagementActor : ReceiveActor
throw new ManagementCommandException(
$"Secured write {cmd.Id} is '{entity.Status}', not Pending; it cannot be rejected.");
// Server-side TTL: an overdue Pending write expires here too, so a reject on a
// stale row deterministically reports the expiry rather than recording a
// human decision (arch-review S2).
if (await TryExpireIfStaleAsync(sp, repo, entity))
throw new ManagementCommandException(
$"Secured write {cmd.Id} expired (pending longer than the configured TTL) and can no longer be approved or rejected.");
// Separation of duties: the verifier must differ from the submitter.
if (string.Equals(entity.OperatorUser, user.Username, StringComparison.OrdinalIgnoreCase))
throw new ManagementCommandException(
@@ -7,4 +7,7 @@ public class ManagementServiceOptions
/// <summary>Maximum time to wait for a long-running transport/deploy command (import/preview/export bundle, deploy artifacts/instance) before returning a timeout error; default 5 minutes.</summary>
public TimeSpan LongRunningCommandTimeout { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>Age after which a Pending secured write can no longer be approved; it is transitioned to Expired at the next decision/list touch. Default 24 hours. A non-positive value disables server-side expiry.</summary>
public TimeSpan SecuredWritePendingTtl { get; set; } = TimeSpan.FromHours(24);
}