From cfe9f817a36f496fe926aa79de48854c4861b35a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:42:12 -0400 Subject: [PATCH] feat(go-client): operator credentials on acknowledge-alarm Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- clients/go/README.md | 8 +- clients/go/cmd/mxgw-go/main.go | 24 ++++- .../internal/generated/mxaccess_gateway.pb.go | 93 ++++++++++++++----- clients/go/mxgateway/alarms.go | 9 +- clients/go/mxgateway/alarms_test.go | 29 ++++++ 5 files changed, 136 insertions(+), 27 deletions(-) diff --git a/clients/go/README.md b/clients/go/README.md index 577fd57..5e5fa0f 100644 --- a/clients/go/README.md +++ b/clients/go/README.md @@ -140,7 +140,11 @@ sentinel typed and observable; it never synthesizes or swallows it. For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot snapshots, `Client.StreamAlarms` for the server-streaming feed, and -`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming +`Client.AcknowledgeAlarm` to ack an alarm by full reference. Acks carry the +operator's Galaxy credentials (`OperatorUser` required, `OperatorPassword`, +optional `OperatorDomain`) so the gateway authenticates as the operator and the +Historian records that operator; an empty password is allowed, and the password +is scrubbed from any returned error. The streaming call returns a `StreamAlarmsClient`; cancel its context to terminate the stream. All three pass straight through to the gateway's central alarm monitor. @@ -424,7 +428,7 @@ Every subcommand wired into the CLI. All accept the common flags | `bench-read-bulk` | Throughput benchmark (`-duration-seconds`, `-warmup-seconds`, `-bulk-size`). | | `stream-events` | Stream item-value events for a session (`-session-id`, `-limit`). | | `stream-alarms` | Stream the alarm feed (`-filter-prefix`, `-limit`). | -| `acknowledge-alarm` | Acknowledge an alarm reference. | +| `acknowledge-alarm` | Acknowledge an alarm as an operator (`-operator`, `-operator-domain`, `-operator-password-env`, default `MXGATEWAY_OPERATOR_PASSWORD`). | | `smoke` | End-to-end smoke workflow against one item. | | `galaxy-test-connection` | Probe the Galaxy Repository RPC connection. | | `galaxy-last-deploy` | Print the most recent deploy event. | diff --git a/clients/go/cmd/mxgw-go/main.go b/clients/go/cmd/mxgw-go/main.go index 159d3a2..f973d99 100644 --- a/clients/go/cmd/mxgw-go/main.go +++ b/clients/go/cmd/mxgw-go/main.go @@ -1119,6 +1119,10 @@ func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string { } } +// defaultOperatorPasswordEnv is the CLI environment variable holding the +// operator password for acknowledge-alarm. +const defaultOperatorPasswordEnv = "MXGATEWAY_OPERATOR_PASSWORD" + func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.Writer) error { flags := flag.NewFlagSet("acknowledge-alarm", flag.ContinueOnError) flags.SetOutput(stderr) @@ -1126,7 +1130,12 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W jsonOutput := flags.Bool("json", false, "write JSON output") reference := flags.String("reference", "", "full alarm reference to acknowledge") comment := flags.String("comment", "", "operator acknowledge comment") - operator := flags.String("operator", "", "operator user performing the acknowledge") + operator := flags.String("operator", "", "operator user performing the acknowledge (required)") + operatorDomain := flags.String("operator-domain", "", "operator Galaxy domain (optional)") + // Prefer the environment variable so the credential stays out of shell + // history and the process table; an empty password is allowed. + operatorPassword := flags.String("operator-password", "", "operator password (prefer -operator-password-env)") + operatorPasswordEnv := flags.String("operator-password-env", defaultOperatorPasswordEnv, "environment variable containing the operator password") if err := flags.Parse(args); err != nil { return err @@ -1134,6 +1143,17 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W if *reference == "" { return errors.New("reference is required") } + if *operator == "" { + return errors.New("operator is required") + } + resolvedOperatorPassword := *operatorPassword + if resolvedOperatorPassword == "" { + envName := *operatorPasswordEnv + if envName == "" { + envName = defaultOperatorPasswordEnv + } + resolvedOperatorPassword = os.Getenv(envName) + } client, options, err := dialForCommand(ctx, common) if err != nil { @@ -1145,6 +1165,8 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W AlarmFullReference: *reference, Comment: *comment, OperatorUser: *operator, + OperatorDomain: *operatorDomain, + OperatorPassword: resolvedOperatorPassword, }) if err != nil { return err diff --git a/clients/go/internal/generated/mxaccess_gateway.pb.go b/clients/go/internal/generated/mxaccess_gateway.pb.go index 3b7aa9d..c66f9f9 100644 --- a/clients/go/internal/generated/mxaccess_gateway.pb.go +++ b/clients/go/internal/generated/mxaccess_gateway.pb.go @@ -3583,10 +3583,13 @@ func (x *AlarmFailoverConfig) GetFailbackStableProbes() int32 { return 0 } -// Acknowledge a single alarm by its GUID. Operator identity fields are -// recorded atomically with the ack transition in the alarm-history log. -// The reply's hresult / native_status surfaces AVEVA's -// AlarmAckByGUID return code. +// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the +// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it +// by writing `comment` to `.AckMsg` over MXAccess after +// AuthenticateUser as the operator, so the alarm history records the real +// operator. The reply's hresult / native_status is 0 when the write was +// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or +// the OnWriteComplete status detail. type AcknowledgeAlarmCommand struct { state protoimpl.MessageState `protogen:"open.v1"` // Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73"). @@ -3596,6 +3599,10 @@ type AcknowledgeAlarmCommand struct { OperatorNode string `protobuf:"bytes,4,opt,name=operator_node,json=operatorNode,proto3" json:"operator_node,omitempty"` OperatorDomain string `protobuf:"bytes,5,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"` OperatorFullName string `protobuf:"bytes,6,opt,name=operator_full_name,json=operatorFullName,proto3" json:"operator_full_name,omitempty"` + // Operator credential used for AuthenticateUser before the AckMsg write. + // Implementations must keep this field out of logs, metrics labels, command + // lines, and diagnostics. + OperatorPassword string `protobuf:"bytes,7,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3672,6 +3679,13 @@ func (x *AcknowledgeAlarmCommand) GetOperatorFullName() string { return "" } +func (x *AcknowledgeAlarmCommand) GetOperatorPassword() string { + if x != nil { + return x.OperatorPassword + } + return "" +} + // Snapshot the currently-active alarm set. Optional filter prefix scopes // the snapshot to alarms whose alarm_full_reference starts with the // supplied string (matches QueryActiveAlarmsRequest.alarm_filter_prefix). @@ -3721,9 +3735,7 @@ func (x *QueryActiveAlarmsCommand) GetAlarmFilterPrefix() string { // Acknowledge a single alarm by its (name, provider, group) tuple. Used // when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot -// be resolved to a GUID directly. The worker invokes -// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm -// history path as AlarmAckByGUID. +// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator. type AcknowledgeAlarmByNameCommand struct { state protoimpl.MessageState `protogen:"open.v1"` // Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself @@ -3739,6 +3751,10 @@ type AcknowledgeAlarmByNameCommand struct { OperatorNode string `protobuf:"bytes,6,opt,name=operator_node,json=operatorNode,proto3" json:"operator_node,omitempty"` OperatorDomain string `protobuf:"bytes,7,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"` OperatorFullName string `protobuf:"bytes,8,opt,name=operator_full_name,json=operatorFullName,proto3" json:"operator_full_name,omitempty"` + // Operator credential used for AuthenticateUser before the AckMsg write. + // Implementations must keep this field out of logs, metrics labels, command + // lines, and diagnostics. + OperatorPassword string `protobuf:"bytes,9,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3829,6 +3845,13 @@ func (x *AcknowledgeAlarmByNameCommand) GetOperatorFullName() string { return "" } +func (x *AcknowledgeAlarmByNameCommand) GetOperatorPassword() string { + if x != nil { + return x.OperatorPassword + } + return "" +} + type UnsubscribeBulkCommand struct { state protoimpl.MessageState `protogen:"open.v1"` ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"` @@ -6127,11 +6150,11 @@ func (x *DrainEventsReply) GetEvents() []*MxEvent { // Reply payload for AcknowledgeAlarmCommand AND // AcknowledgeAlarmByNameCommand — both ack command kinds reuse this // payload case (`MxCommandReply.acknowledge_alarm`); there is no -// dedicated by-name reply case. Surfaces AVEVA's native ack return -// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the -// by-name arm); 0 means success. The MxCommandReply's hresult field +// dedicated by-name reply case. Surfaces the ack outcome: 0 when the +// AckMsg write was applied or sent but unconfirmed; otherwise the +// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field // carries the same value and is preferred for protocol consumers — -// this payload exists so the gateway-side WorkerAlarmRpcDispatcher +// this payload exists so GatewayAlarmMonitor // can echo native_status into AcknowledgeAlarmReply.hresult without // unpacking the outer envelope. type AcknowledgeAlarmReplyPayload struct { @@ -7217,9 +7240,18 @@ type AcknowledgeAlarmRequest struct { Comment string `protobuf:"bytes,4,opt,name=comment,proto3" json:"comment,omitempty"` // Operator principal performing the acknowledgement. The lmxopcua side // resolves this from the OPC UA session prior to invoking the RPC. - OperatorUser string `protobuf:"bytes,5,opt,name=operator_user,json=operatorUser,proto3" json:"operator_user,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + OperatorUser string `protobuf:"bytes,5,opt,name=operator_user,json=operatorUser,proto3" json:"operator_user,omitempty"` + // Operator's Galaxy (or OS) password. The gateway authenticates as + // operator_user with it before writing the acknowledgement so the alarm + // history records the real operator. May be empty for accounts without a + // password. Credential-sensitive: implementations must keep this field out + // of logs, metrics labels, command lines, and diagnostics. + OperatorPassword string `protobuf:"bytes,6,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"` + // Optional domain for OS-authenticated galaxies. When set the gateway + // authenticates as "\". + OperatorDomain string `protobuf:"bytes,7,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AcknowledgeAlarmRequest) Reset() { @@ -7280,14 +7312,27 @@ func (x *AcknowledgeAlarmRequest) GetOperatorUser() string { return "" } +func (x *AcknowledgeAlarmRequest) GetOperatorPassword() string { + if x != nil { + return x.OperatorPassword + } + return "" +} + +func (x *AcknowledgeAlarmRequest) GetOperatorDomain() string { + if x != nil { + return x.OperatorDomain + } + return "" +} + type AcknowledgeAlarmReply struct { state protoimpl.MessageState `protogen:"open.v1"` CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` ProtocolStatus *ProtocolStatus `protobuf:"bytes,3,opt,name=protocol_status,json=protocolStatus,proto3" json:"protocol_status,omitempty"` // Native ack return code echoed from the worker. The worker carries the // ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status, - // = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's - // WorkerAlarmRpcDispatcher copies that value here. This is the authoritative + // 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative // ack-outcome field for the public RPC. Absent only when the worker reply // omitted the value entirely (a protocol violation). Hresult *int32 `protobuf:"varint,4,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"` @@ -8992,7 +9037,7 @@ const file_mxaccess_gateway_proto_rawDesc = "" + "\x13AlarmFailoverConfig\x12B\n" + "\x1dconsecutive_failure_threshold\x18\x01 \x01(\x05R\x1bconsecutiveFailureThreshold\x12E\n" + "\x1ffailback_probe_interval_seconds\x18\x02 \x01(\x05R\x1cfailbackProbeIntervalSeconds\x124\n" + - "\x16failback_stable_probes\x18\x03 \x01(\x05R\x14failbackStableProbes\"\xf3\x01\n" + + "\x16failback_stable_probes\x18\x03 \x01(\x05R\x14failbackStableProbes\"\xa0\x02\n" + "\x17AcknowledgeAlarmCommand\x12\x1d\n" + "\n" + "alarm_guid\x18\x01 \x01(\tR\talarmGuid\x12\x18\n" + @@ -9000,9 +9045,10 @@ const file_mxaccess_gateway_proto_rawDesc = "" + "\roperator_user\x18\x03 \x01(\tR\foperatorUser\x12#\n" + "\roperator_node\x18\x04 \x01(\tR\foperatorNode\x12'\n" + "\x0foperator_domain\x18\x05 \x01(\tR\x0eoperatorDomain\x12,\n" + - "\x12operator_full_name\x18\x06 \x01(\tR\x10operatorFullName\"J\n" + + "\x12operator_full_name\x18\x06 \x01(\tR\x10operatorFullName\x12+\n" + + "\x11operator_password\x18\a \x01(\tR\x10operatorPassword\"J\n" + "\x18QueryActiveAlarmsCommand\x12.\n" + - "\x13alarm_filter_prefix\x18\x01 \x01(\tR\x11alarmFilterPrefix\"\xbd\x02\n" + + "\x13alarm_filter_prefix\x18\x01 \x01(\tR\x11alarmFilterPrefix\"\xea\x02\n" + "\x1dAcknowledgeAlarmByNameCommand\x12\x1d\n" + "\n" + "alarm_name\x18\x01 \x01(\tR\talarmName\x12#\n" + @@ -9013,7 +9059,8 @@ const file_mxaccess_gateway_proto_rawDesc = "" + "\roperator_user\x18\x05 \x01(\tR\foperatorUser\x12#\n" + "\roperator_node\x18\x06 \x01(\tR\foperatorNode\x12'\n" + "\x0foperator_domain\x18\a \x01(\tR\x0eoperatorDomain\x12,\n" + - "\x12operator_full_name\x18\b \x01(\tR\x10operatorFullName\"`\n" + + "\x12operator_full_name\x18\b \x01(\tR\x10operatorFullName\x12+\n" + + "\x11operator_password\x18\t \x01(\tR\x10operatorPassword\"`\n" + "\x16UnsubscribeBulkCommand\x12#\n" + "\rserver_handle\x18\x01 \x01(\x05R\fserverHandle\x12!\n" + "\fitem_handles\x18\x02 \x03(\x05R\vitemHandles\"v\n" + @@ -9262,12 +9309,14 @@ const file_mxaccess_gateway_proto_rawDesc = "" + "limitValue\x12\x1a\n" + "\bdegraded\x18\x0e \x01(\bR\bdegraded\x12O\n" + "\x0fsource_provider\x18\x0f \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x0esourceProvider\x126\n" + - "\x17from_truncated_snapshot\x18\x10 \x01(\bR\x15fromTruncatedSnapshot\"\xd0\x01\n" + + "\x17from_truncated_snapshot\x18\x10 \x01(\bR\x15fromTruncatedSnapshot\"\xa6\x02\n" + "\x17AcknowledgeAlarmRequest\x122\n" + "\x15client_correlation_id\x18\x02 \x01(\tR\x13clientCorrelationId\x120\n" + "\x14alarm_full_reference\x18\x03 \x01(\tR\x12alarmFullReference\x12\x18\n" + "\acomment\x18\x04 \x01(\tR\acomment\x12#\n" + - "\roperator_user\x18\x05 \x01(\tR\foperatorUserJ\x04\b\x01\x10\x02R\n" + + "\roperator_user\x18\x05 \x01(\tR\foperatorUser\x12+\n" + + "\x11operator_password\x18\x06 \x01(\tR\x10operatorPassword\x12'\n" + + "\x0foperator_domain\x18\a \x01(\tR\x0eoperatorDomainJ\x04\b\x01\x10\x02R\n" + "session_id\"\xb4\x02\n" + "\x15AcknowledgeAlarmReply\x12%\n" + "\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\x12L\n" + diff --git a/clients/go/mxgateway/alarms.go b/clients/go/mxgateway/alarms.go index bc7993b..b0fe2ab 100644 --- a/clients/go/mxgateway/alarms.go +++ b/clients/go/mxgateway/alarms.go @@ -10,6 +10,11 @@ import ( // invoke:alarm-ack scope and forwards the acknowledge to the worker's MXAccess // session; the resulting native MxStatus is returned in the reply. // +// OperatorUser is required: the gateway authenticates the operator (with +// OperatorPassword and optional OperatorDomain) so the Historian records the +// real operator. An empty password is allowed. The password is scrubbed from +// any error this method returns. +// // Acks are idempotent — re-acking an already-acked condition is a no-op at // the MxAccess layer. func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) { @@ -22,10 +27,10 @@ func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequ reply, err := c.raw.AcknowledgeAlarm(callCtx, req) if err != nil { - return nil, &GatewayError{Op: "acknowledge alarm", Err: err} + return nil, redactSecrets(&GatewayError{Op: "acknowledge alarm", Err: err}, req.GetOperatorPassword()) } if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil { - return reply, err + return reply, redactSecrets(err, req.GetOperatorPassword()) } return reply, nil diff --git a/clients/go/mxgateway/alarms_test.go b/clients/go/mxgateway/alarms_test.go index 83005db..073adfb 100644 --- a/clients/go/mxgateway/alarms_test.go +++ b/clients/go/mxgateway/alarms_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net" + "strings" "testing" pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" @@ -38,6 +39,8 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) { AlarmFullReference: "Tank01.Level.HiHi", Comment: "investigating", OperatorUser: "alice", + OperatorPassword: "pw-secret", + OperatorDomain: "CORP", }) if err != nil { t.Fatalf("AcknowledgeAlarm() error = %v", err) @@ -51,11 +54,37 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) { if got := fake.acknowledgeRequest.GetComment(); got != "investigating" { t.Fatalf("captured comment = %q", got) } + if got := fake.acknowledgeRequest.GetOperatorPassword(); got != "pw-secret" { + t.Fatalf("captured operator password mismatch") + } + if got := fake.acknowledgeRequest.GetOperatorDomain(); got != "CORP" { + t.Fatalf("captured operator domain = %q", got) + } if got := fake.acknowledgeAuth; got != "Bearer test-api-key" { t.Fatalf("authorization metadata = %q", got) } } +func TestAcknowledgeAlarmRedactsPasswordFromError(t *testing.T) { + fake := &fakeGatewayWithAlarms{ + acknowledgeError: status.Error(codes.Internal, "boom pw-secret"), + } + client, cleanup := newBufconnClientWithAlarms(t, fake) + defer cleanup() + + _, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{ + AlarmFullReference: "Tank01.Level.HiHi", + OperatorUser: "alice", + OperatorPassword: "pw-secret", + }) + if err == nil { + t.Fatal("AcknowledgeAlarm() returned no error") + } + if strings.Contains(err.Error(), "pw-secret") { + t.Fatalf("error leaks operator password: %v", err) + } +} + func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) { fake := &fakeGatewayWithAlarms{} client, cleanup := newBufconnClientWithAlarms(t, fake)