fix(management): CAS-guard secured-write reject (WHERE Status='Pending') so reject can never falsify an executed two-person write (arch-review 2026-08-19 P6-1 High)
Reject read the row as Pending, then landed a blind full-row UpdateAsync. A concurrent approve could win its own Pending->Approved CAS in that gap and enter the multi-second WriteTagAsync relay; the reject's update then stamped Status/ExecutedAtUtc/ExecutionError to Rejected mid-relay, so the two-person-control record said Rejected for a write that physically executed at the plant. Works cross-node (both centrals share one MS SQL). - ISecuredWriteRepository/SecuredWriteRepository: new TryMarkRejectedAsync, mirroring TryMarkApprovedAsync exactly (conditional UPDATE ... WHERE Status='Pending', writes only the columns the reject transition owns). No schema change, no migration, no rowversion column. - HandleRejectSecuredWrite: uses the CAS; on loss re-reads the row and returns the status that actually won (same UX shape as approve's lost-race error), and emits the SecuredWriteReject audit row ONLY on a won CAS. - Audited the other lifecycle writes: approve/reject/expiry (inline + the opportunistic list sweep) now ALL transition only out of Pending, so winning the approve CAS gives that leg exclusive ownership of the row and its post-relay Executed/Failed update stays safe as an unconditional write. Documented in place. - Tests: repository-level approve-vs-reject races in both orders (exactly one wins, loser false, row reflects the winner only) plus reject-vs-expiry; handler-level lost-race reject (conflict error, no write, no audit row) and approve on an already-rejected row (no CAS, no relay, no audit).
This commit is contained in:
@@ -168,7 +168,7 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B
|
||||
|
||||
- **SubmitSecuredWriteCommand** (`Operator` role): validates the target connection exists and uses the **MxGateway** protocol (rejected otherwise), then inserts a `Pending` row capturing site / connection / tag / typed value / operator + comment. Nothing is written yet.
|
||||
- **ApproveSecuredWriteCommand** (`Verifier` role): enforces **no self-approval** (`VerifierUser ≠ OperatorUser`, checked **before** any state change) and a **compare-and-swap** race guard (`TryMarkApprovedAsync` — exactly one concurrent approver flips `Pending → Approved`; the loser is rejected). On winning, it validates the value type, decodes the value (guarded — a bad value fails the row deterministically rather than leaving it stuck `Approved`), and relays a `WriteTagRequest` to the site MxGateway via the Communication Layer, recording the outcome as `Executed` or `Failed`.
|
||||
- **RejectSecuredWriteCommand** (`Verifier` role): marks a `Pending` row `Rejected` with the verifier's reason.
|
||||
- **RejectSecuredWriteCommand** (`Verifier` role): marks a `Pending` row `Rejected` with the verifier's reason. Like approve, the transition is **compare-and-swap guarded** (`TryMarkRejectedAsync` — a conditional `UPDATE … WHERE Status = 'Pending'`, so approve, reject and the TTL expiry can never both transition one row); a loser records **nothing** — no status write and no `SecuredWriteReject` audit row — and is answered with the status that actually won. The blind full-row update it replaced could land after a concurrent approve had won its own CAS and while that approve's multi-second `WriteTagRequest` relay was still in flight, stamping the row `Rejected` for a write that physically executed at the plant (arch-review 2026-08-19 P6-1). Winning the approve CAS therefore gives that leg exclusive ownership of the row, which is what makes its post-relay `Executed`/`Failed` update safe as an unconditional write.
|
||||
- **ListSecuredWritesCommand** (any of `Operator` / `Verifier` / `Administrator`): pending queue + terminal history, global or per-site, **offset-paged** via `Skip` / `Take` (arch-review P3; additive — the defaults `Skip=0` / `Take=200` preserve the historical unpaged wire behaviour). The handler clamps `Take` to `1..500` and `Skip` to `>= 0`, calls `QueryAsync(Status, SiteId, skip, take)` for the page and `CountAsync(Status, SiteId)` for the unpaged total, and returns a `SecuredWriteListResult { Items, TotalCount }` so a client can render a pager. Although read-only, the history table exposes process-sensitive tag values, so it is **not** open to any authenticated user — it is gated to the two Secured Write roles plus `Administrator` (any-of), aligning with the `RequireSecuredWriteAccess` Central UI policy (arch-review UA1).
|
||||
|
||||
**Server-side pending TTL (arch-review S2).** A `Pending` secured write is not valid indefinitely. Both `ApproveSecuredWriteCommand` and `RejectSecuredWriteCommand` call a shared `TryExpireIfStaleAsync` helper immediately after the `Pending`-status check: if the row's `SubmittedAtUtc` is older than the configured `SecuredWritePendingTtl` (`ManagementServiceOptions`, default **24 h**), the helper atomically transitions it `Pending → Expired` via `ISecuredWriteRepository.TryMarkExpiredAsync` (compare-and-swap — no verifier recorded, `VerifierUser` stays `NULL`), emits a best-effort `SecuredWriteExpire` audit row (system actor, `Discarded` status — the closest terminal `AuditStatus`), and the command fails with an "expired" error. A stale setpoint therefore can **never** be approved or executed days later, and no `WriteTagRequest` is relayed. A non-positive TTL disables expiry. `ListSecuredWritesCommand` additionally runs an **opportunistic sweep**: on every read it walks the returned page and calls the same helper for each `Pending` row, so overdue writes surface as `Expired` the next time anyone views the queue — no dedicated expiry-timer singleton is needed because the ManagementActor already runs on every central node and the CAS makes concurrent multi-node sweeps idempotent.
|
||||
|
||||
Reference in New Issue
Block a user