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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user