feat(worker): route alarm acks through the operator-authenticated AckMsg writer
ci / nightly-windev (push) Has been skipped
ci / java (push) Failing after 2m5s
ci / windows-x86 (push) Successful in 2m10s
ci / portable (push) Failing after 2m17s

AlarmCommandHandler -> AlarmDispatcher -> IAlarmAckWriter: the dispatcher resolves the ack item through the consumer and the writer authenticates the operator and writes the comment. The executor builds AlarmAckOperator from operator_user/domain/password, maps AlarmAckOutcome to the reply (Applied/Unconfirmed Ok, AlarmNotFound/NotAckable InvalidRequest, AuthenticationFailed/WriteRejected MxaccessFailure) and reports escaping exceptions by type and HRESULT only, so operator_password cannot leak. Removes the inert managed/WNAL alarm-client ack path.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
Joseph Doherty
2026-10-19 19:35:55 -04:00
parent 0d4b2aea2d
commit d6db0ff40d
14 changed files with 695 additions and 1282 deletions
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Conversion;
using ZB.MOM.WW.MxGateway.Worker.Sta;
@@ -912,30 +913,25 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
$"AcknowledgeAlarm.alarm_guid is not a valid canonical GUID: '{payload.AlarmGuid}'.");
}
// operator_password is a raw credential: it goes into AlarmAckOperator for
// MXAccess AuthenticateUser and nowhere else. Never format the payload
// (its generated ToString renders every field) or the exception text.
try
{
int rc = alarmCommandHandler.Acknowledge(
AlarmAckResult result = alarmCommandHandler.Acknowledge(
alarmGuid,
payload.Comment,
payload.OperatorUser,
payload.OperatorNode,
payload.OperatorDomain,
payload.OperatorFullName);
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = rc;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
{
NativeStatus = rc,
};
if (rc != 0)
{
reply.DiagnosticMessage = $"AVEVA AlarmAckByGUID returned non-zero status {rc}.";
}
return reply;
new AlarmAckOperator
{
UserName = payload.OperatorUser,
Domain = payload.OperatorDomain,
Password = payload.OperatorPassword,
});
return CreateAcknowledgeReply(command, result);
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
return CreateAcknowledgeFailureReply(command, ex);
}
}
@@ -958,35 +954,80 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName.alarm_name is required.");
}
// See ExecuteAcknowledgeAlarm: operator_password never leaves AlarmAckOperator.
try
{
int rc = alarmCommandHandler.AcknowledgeByName(
AlarmAckResult result = alarmCommandHandler.AcknowledgeByName(
payload.AlarmName,
payload.ProviderName,
payload.GroupName,
payload.Comment,
payload.OperatorUser,
payload.OperatorNode,
payload.OperatorDomain,
payload.OperatorFullName);
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = rc;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
{
NativeStatus = rc,
};
if (rc != 0)
{
reply.DiagnosticMessage = $"AVEVA AlarmAckByName returned non-zero status {rc}.";
}
return reply;
new AlarmAckOperator
{
UserName = payload.OperatorUser,
Domain = payload.OperatorDomain,
Password = payload.OperatorPassword,
});
return CreateAcknowledgeReply(command, result);
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
return CreateAcknowledgeFailureReply(command, ex);
}
}
private static MxCommandReply CreateAcknowledgeReply(StaCommand command, AlarmAckResult result)
{
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = result.NativeStatus;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = result.NativeStatus };
reply.Statuses.Add(result.Statuses);
reply.DiagnosticMessage = result.Diagnostic;
switch (result.Outcome)
{
case AlarmAckOutcome.AlarmNotFound:
case AlarmAckOutcome.NotAckable:
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.InvalidRequest, Message = result.Diagnostic };
break;
case AlarmAckOutcome.AuthenticationFailed:
case AlarmAckOutcome.WriteRejected:
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure, Message = result.Diagnostic };
break;
}
return reply;
}
/// <summary>
/// Maps an exception escaping an acknowledgement (e.g. the ack writer's
/// MXAccess Register failing, or no active subscription) to an
/// <see cref="ProtocolStatusCode.MxaccessFailure"/> reply. Only the
/// exception type and HRESULT are reported: exception text on this path
/// is not under our control and must not be able to echo operator input.
/// </summary>
private static MxCommandReply CreateAcknowledgeFailureReply(StaCommand command, Exception exception)
{
string diagnostic = string.Format(
CultureInfo.InvariantCulture,
"Alarm acknowledgement failed: {0} (HRESULT 0x{1:X8}).",
exception.GetType().FullName,
exception.HResult);
return new MxCommandReply
{
SessionId = command.SessionId,
CorrelationId = command.CorrelationId,
Kind = command.Kind,
Hresult = exception.HResult,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.MxaccessFailure,
Message = diagnostic,
},
DiagnosticMessage = diagnostic,
AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = exception.HResult },
};
}
private MxCommandReply ExecuteQueryActiveAlarms(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand)