feat(mgmt): secured-write approve relays to site MxGateway write with CAS race guard (T14b)
This commit is contained in:
@@ -378,10 +378,11 @@ public class ManagementActor : ReceiveActor
|
||||
DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd, user),
|
||||
DebugSnapshotCommand cmd => await HandleDebugSnapshot(sp, cmd, user),
|
||||
|
||||
// Secured writes (M7 / T14b). Approve (execute) is intentionally NOT
|
||||
// dispatched here — the approve->execute relay is Task C3; it would
|
||||
// 'NotSupported' at runtime until then.
|
||||
// Secured writes (M7 / T14b). Approve executes the write — once a
|
||||
// Verifier wins the compare-and-swap the value is relayed to the site
|
||||
// MxGateway connection (Task C3).
|
||||
SubmitSecuredWriteCommand cmd => await HandleSubmitSecuredWrite(sp, cmd, user),
|
||||
ApproveSecuredWriteCommand cmd => await HandleApproveSecuredWrite(sp, cmd, user),
|
||||
RejectSecuredWriteCommand cmd => await HandleRejectSecuredWrite(sp, cmd, user),
|
||||
ListSecuredWritesCommand cmd => await HandleListSecuredWrites(sp, cmd),
|
||||
|
||||
@@ -928,6 +929,87 @@ public class ManagementActor : ReceiveActor
|
||||
return ToSecuredWriteDto(entity);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleApproveSecuredWrite(
|
||||
IServiceProvider sp, ApproveSecuredWriteCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ISecuredWriteRepository>();
|
||||
var row = await repo.GetAsync(cmd.Id)
|
||||
?? throw new ManagementCommandException($"Secured write {cmd.Id} not found.");
|
||||
|
||||
if (!string.Equals(row.Status, "Pending", StringComparison.Ordinal))
|
||||
throw new ManagementCommandException(
|
||||
$"Secured write {cmd.Id} is '{row.Status}', not Pending; it cannot be approved.");
|
||||
|
||||
// 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.
|
||||
if (string.Equals(row.OperatorUser, user.Username, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ManagementCommandException(
|
||||
"A secured write cannot be verified by its submitter.");
|
||||
|
||||
// Compare-and-swap: guards the two-verifier race. Exactly one concurrent
|
||||
// approver flips Pending->Approved; the loser observes false here and must
|
||||
// not relay the write.
|
||||
var decidedAtUtc = DateTime.UtcNow;
|
||||
if (!await repo.TryMarkApprovedAsync(cmd.Id, user.Username, cmd.Comment, decidedAtUtc))
|
||||
throw new ManagementCommandException(
|
||||
$"Secured write {cmd.Id} is no longer pending — already decided.");
|
||||
|
||||
// We won the race. Stamp the verifier decision locally so the entity we
|
||||
// persist below carries the same values the CAS committed.
|
||||
row.Status = "Approved";
|
||||
row.VerifierUser = user.Username;
|
||||
row.VerifierComment = cmd.Comment;
|
||||
row.DecidedAtUtc = decidedAtUtc;
|
||||
|
||||
// Validate the value type BEFORE attempting the relay. An unknown type can
|
||||
// never be decoded/written, so fail the row deterministically rather than
|
||||
// leaving it stuck Approved. (Addresses the C2 reviewer's deferred
|
||||
// ValueType-validation note.)
|
||||
if (!Enum.TryParse<Commons.Types.Enums.DataType>(row.ValueType, ignoreCase: true, out var dataType))
|
||||
{
|
||||
row.Status = "Failed";
|
||||
row.ExecutedAtUtc = DateTime.UtcNow;
|
||||
row.ExecutionError = "unknown value type";
|
||||
await repo.UpdateAsync(row);
|
||||
return ToSecuredWriteDto(row);
|
||||
}
|
||||
|
||||
var value = Commons.Types.AttributeValueCodec.Decode(row.ValueJson, dataType, elementType: null);
|
||||
|
||||
// Relay the write to the site MxGateway connection. A transport exception is
|
||||
// contained so the row is never left stuck Approved.
|
||||
var commService = sp.GetRequiredService<CommunicationService>();
|
||||
bool success;
|
||||
string? error;
|
||||
try
|
||||
{
|
||||
var resp = await commService.WriteTagAsync(
|
||||
row.SiteId,
|
||||
new Commons.Messages.DataConnection.WriteTagRequest(
|
||||
CorrelationId: Guid.NewGuid().ToString("N"),
|
||||
ConnectionName: row.ConnectionName,
|
||||
TagPath: row.TagPath,
|
||||
Value: value,
|
||||
Timestamp: DateTimeOffset.UtcNow));
|
||||
success = resp.Success;
|
||||
error = resp.Success ? null : resp.ErrorMessage;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
success = false;
|
||||
error = ex.Message;
|
||||
}
|
||||
|
||||
row.Status = success ? "Executed" : "Failed";
|
||||
row.ExecutedAtUtc = DateTime.UtcNow;
|
||||
row.ExecutionError = error;
|
||||
|
||||
// UpdateAsync overwrites all columns -> pass the fully-populated entity.
|
||||
await repo.UpdateAsync(row);
|
||||
return ToSecuredWriteDto(row);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleRejectSecuredWrite(
|
||||
IServiceProvider sp, RejectSecuredWriteCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user