feat(python-client): operator credentials on acknowledge-alarm

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
Joseph Doherty
2026-10-19 19:41:55 -04:00
parent 0143591858
commit 7b81cb7f72
6 changed files with 252 additions and 173 deletions
+10 -4
View File
@@ -111,9 +111,15 @@ MXAccess commands and preserve raw replies on typed command exceptions.
For alarms, the client exposes `GatewayClient.query_active_alarms` (one-shot
snapshot), `GatewayClient.stream_alarms` (async generator yielding alarm-feed
messages from the gateway's central monitor), and
`GatewayClient.acknowledge_alarm` (ack by alarm reference, optional comment
and ack target). Cancel the surrounding task or `aclose()` the iterator to
terminate the stream.
`GatewayClient.acknowledge_alarm` (ack by alarm reference with an optional
comment). The ack is made as the operator: set `operator_user` (required) and
`operator_password` (may be empty) and optionally `operator_domain` on
`AcknowledgeAlarmRequest`. The gateway authenticates the operator and writes the
comment to the alarm's `AckMsg`, so the alarm history records the operator. The
password is redacted from any raised error.
Cancel the surrounding task or `aclose()` an alarm iterator to terminate the
stream.
`ActiveAlarmSnapshot.from_truncated_snapshot` reports that the record came from
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
@@ -457,7 +463,7 @@ mxgw-py add-item --session-id <id> --server-handle 1 --item Object.Attribute --j
mxgw-py advise --session-id <id> --server-handle 1 --item-handle 2 --json
mxgw-py stream-events --session-id <id> --max-events 1 --json
mxgw-py stream-alarms --max-messages 4 --json
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --json
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json
mxgw-py write --session-id <id> --server-handle 1 --item-handle 2 --type int32 --value 123 --json
```
@@ -9,7 +9,7 @@ from typing import Any
import grpc
from .auth import merge_metadata
from .errors import ensure_protocol_success, map_rpc_error
from .errors import MxGatewayError, ensure_protocol_success, map_rpc_error
from .generated import mxaccess_gateway_pb2 as pb
from .generated import mxaccess_gateway_pb2_grpc as pb_grpc
from .options import ClientOptions, create_channel, resolve_channel_security
@@ -148,17 +148,23 @@ class GatewayClient:
self,
request: pb.AcknowledgeAlarmRequest,
) -> pb.AcknowledgeAlarmReply:
"""Acknowledge an active MXAccess alarm condition through the gateway.
"""Acknowledge an alarm as the operator.
The gateway authenticates the request against the API key's
``invoke:alarm-ack`` scope and forwards the acknowledge to the worker's
MXAccess session; the resulting native ``MxStatus`` is returned in the
reply. Acks are idempotent — re-acking an already-acked condition is a
no-op at the MxAccess layer.
The gateway authenticates ``operator_user`` with ``operator_password`` and
writes the comment to the alarm's ``AckMsg``, so the alarm history records
the operator. The password is redacted from any raised error.
"""
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
return reply
try:
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
return reply
except MxGatewayError as error:
if request.operator_password:
# Imported lazily: session.py imports this module at its tail.
from .session import _redact_error
_redact_error(error, [request.operator_password])
raise
def query_active_alarms(
self,
File diff suppressed because one or more lines are too long
@@ -36,6 +36,7 @@ MAX_AGGREGATE_EVENTS = 10_000
#: CLI (CLI-45) so one exported variable drives the same operator workflow in all
#: five languages.
DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD"
DEFAULT_OPERATOR_PASSWORD_ENV = "MXGATEWAY_OPERATOR_PASSWORD"
_BATCH_EOR = "__MXGW_BATCH_EOR__"
@@ -528,16 +529,30 @@ def stream_alarms(**kwargs: Any) -> None:
@gateway_options
@click.option("--reference", required=True, help="Alarm full reference to acknowledge.")
@click.option("--comment", default="", help="Acknowledgement comment.")
@click.option("--operator", default="", help="Operator user name.")
@click.option("--operator", required=True, help="Operator user name (recorded in the alarm history).")
@click.option("--operator-domain", default="", help="Operator Windows/Galaxy domain (optional).")
@click.option(
"--operator-password",
default=None,
help="Operator password. Prefer --operator-password-env so the secret is not visible on the command line.",
)
@click.option(
"--operator-password-env",
default=DEFAULT_OPERATOR_PASSWORD_ENV,
show_default=True,
help="Environment variable holding the operator password (an unset variable means an empty password).",
)
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def acknowledge_alarm(**kwargs: Any) -> None:
"""Acknowledge an active MXAccess alarm condition (session-less)."""
"""Acknowledge an active MXAccess alarm condition as the operator (session-less)."""
password = _resolve_operator_password(kwargs)
kwargs["operator_password"] = password
_run(
_acknowledge_alarm(**kwargs),
output_json=kwargs["output_json"],
secrets=_secrets(kwargs),
secrets=_secrets(kwargs) + [password],
)
@@ -836,6 +851,21 @@ async def _authenticate_user(**kwargs: Any) -> dict[str, Any]:
return {"userId": user_id}
def _resolve_operator_password(kwargs: dict[str, Any]) -> str:
"""Resolve the alarm-ack operator password from --operator-password or its env var.
Unlike :func:`_resolve_password`, an unset or empty value is allowed: Galaxy
accounts may have empty passwords. The value is never echoed; callers add it
to the ``secrets`` redaction list.
"""
env_name = kwargs.get("operator_password_env") or DEFAULT_OPERATOR_PASSWORD_ENV
password = kwargs.get("operator_password")
if password is None:
password = os.environ.get(env_name, "")
return password
def _resolve_password(kwargs: dict[str, Any]) -> str:
"""Resolve the authenticate-user password from --password or --password-env.
@@ -1161,6 +1191,8 @@ async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]:
alarm_full_reference=kwargs["reference"],
comment=kwargs["comment"],
operator_user=kwargs["operator"],
operator_domain=kwargs["operator_domain"],
operator_password=kwargs["operator_password"],
),
)
return _message_dict(reply)
+27
View File
@@ -34,6 +34,8 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
alarm_full_reference="Tank01.Level.HiHi",
comment="investigating",
operator_user="alice",
operator_password="pw-secret",
operator_domain="CORP",
),
)
@@ -44,9 +46,34 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
assert captured_request.comment == "investigating"
assert captured_request.operator_user == "alice"
assert captured_request.operator_password == "pw-secret"
assert captured_request.operator_domain == "CORP"
assert stub.acknowledge_alarm.metadata == (("authorization", "Bearer mxgw_test_secret"),)
@pytest.mark.asyncio
async def test_acknowledge_alarm_error_redacts_operator_password() -> None:
stub = FakeGatewayStub()
stub.acknowledge_alarm.exception = FakeRpcError(
grpc.StatusCode.UNAUTHENTICATED, "bad credentials pw-secret",
)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
with pytest.raises(MxGatewayAuthenticationError) as excinfo:
await client.acknowledge_alarm(
pb.AcknowledgeAlarmRequest(
alarm_full_reference="Tank01.Level.HiHi",
operator_user="alice",
operator_password="pw-secret",
),
)
assert "pw-secret" not in str(excinfo.value)
@pytest.mark.asyncio
async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None:
stub = FakeGatewayStub()
@@ -771,6 +771,7 @@ def test_cli_stream_alarms_positive_max_messages_remains_finite(monkeypatch) ->
def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
monkeypatch.setenv("MXGW_TEST_OPPW", "pw-secret")
stub = _BulkFakeStub()
stub.acknowledge_alarm = _BulkFakeUnary(
[
@@ -798,12 +799,19 @@ def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
"investigating",
"--operator",
"alice",
"--operator-domain",
"CORP",
"--operator-password-env",
"MXGW_TEST_OPPW",
"--json",
],
)
assert result.exit_code == 0, result.output
assert "pw-secret" not in result.output
captured_request = stub.acknowledge_alarm.requests[0]
assert captured_request.operator_domain == "CORP"
assert captured_request.operator_password == "pw-secret"
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
assert captured_request.comment == "investigating"
assert captured_request.operator_user == "alice"