feat(python-client): operator credentials on acknowledge-alarm
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user