feat(clients): CLI-04 typed single-item command parity (+CLI-30 unregister) — 4/5 clients
Every parity-critical single-item MXAccess command now has a typed session helper instead of only a raw-Invoke escape hatch. Added per client: - Phase 1: AdviseSupervisory, WriteSecured, WriteSecured2, AuthenticateUser, ArchestrAUserToId - Phase 2: AddBufferedItem, SetBufferedUpdateInterval, Suspend, Activate - CLI-30: Unregister (Rust + .NET; Go/Python already had it) Each wraps the existing raw-command machinery (no new wire surface) and runs the same MXAccess-level reply validation (hresult < 0 + MxStatusProxy). MXAccess parity preserved: WriteSecured before AuthenticateUser+AdviseSupervisory surfaces the native failure unchanged (not pre-validated/reordered). Credentials (AuthenticateUser password, WriteSecured payloads) route through each client's secret-redaction seam and never reach logs/exceptions/ToString/Debug/Display; each suite asserts a distinctive credential is absent from surfaced errors. New CLI subcommands source credentials via flag/env, never echoed. - .NET: 21 helpers (validated + Raw), CLI subcommands, multi-secret CLI redactor. Build clean (0 warn), 102 passed. - Go: 9 helpers + *Raw variants, redactSecrets seam, promoted CLI advise-supervisory to typed. gofmt/vet/build/test clean. - Rust: 10 helpers incl. unregister; verified ensure_mxaccess_success runs on secured paths; error.rs credential scrub. fmt/check/test/clippy clean. - Python: 9 async helpers, redact_secret seam + _invoke_redacted, CLI commands. 145 passed. - Shared doc: ClientLibrariesDesign "Typed Command Parity" section. Java client typed parity is batched to windev (no local JRE); CLI-04 + CLI-30 stay open until it lands. Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
This commit is contained in:
@@ -4,7 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
|
||||
from .errors import ensure_mxaccess_success
|
||||
from .auth import redact_secret
|
||||
from .errors import MxGatewayError, ensure_mxaccess_success
|
||||
from .events import ReplayGap
|
||||
from .generated import mxaccess_gateway_pb2 as pb
|
||||
from .values import MxValueInput, to_mx_value
|
||||
@@ -569,6 +570,249 @@ class Session:
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def _invoke_redacted(
|
||||
self,
|
||||
command: pb.MxCommand,
|
||||
*,
|
||||
correlation_id: str,
|
||||
secrets: Sequence[str | None],
|
||||
) -> pb.MxCommandReply:
|
||||
"""Invoke a command whose request carries credential-sensitive data.
|
||||
|
||||
Runs the same gateway + MXAccess validation as :meth:`invoke`, but scrubs
|
||||
the supplied secret substrings from any surfaced error message before it
|
||||
propagates. MXAccess parity is preserved — the native failure is still
|
||||
raised as :class:`~zb_mom_ww_mxgateway.errors.MxAccessError`; only the
|
||||
credential text is removed from the message so it can never reach logs.
|
||||
"""
|
||||
|
||||
try:
|
||||
return await self.invoke(command, correlation_id=correlation_id)
|
||||
except MxGatewayError as error:
|
||||
_redact_error(error, secrets)
|
||||
raise
|
||||
|
||||
async def advise_supervisory(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handle: int,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> None:
|
||||
"""Invoke MXAccess `AdviseSupervisory` for an `ItemHandle`.
|
||||
|
||||
Supervisory advise is the prerequisite for user-attributed and secured
|
||||
writes: it must be established (typically after :meth:`authenticate_user`)
|
||||
before a ``user_id``-bearing :meth:`write` or a :meth:`write_secured`
|
||||
takes effect.
|
||||
"""
|
||||
await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
||||
advise_supervisory=pb.AdviseSupervisoryCommand(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def write_secured(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handle: int,
|
||||
value: MxValueInput,
|
||||
*,
|
||||
current_user_id: int = 0,
|
||||
verifier_user_id: int = 0,
|
||||
correlation_id: str = "",
|
||||
) -> None:
|
||||
"""Invoke MXAccess `WriteSecured` — a signed/verified write.
|
||||
|
||||
The written *value* is credential-sensitive and is scrubbed from any
|
||||
surfaced error message (never logged). MXAccess parity is the contract:
|
||||
``WriteSecured`` failing before a prior :meth:`authenticate_user` +
|
||||
:meth:`advise_supervisory`, or before a value-bearing body, is the native
|
||||
behaviour and is surfaced as-is — it is not "fixed".
|
||||
"""
|
||||
await self._invoke_redacted(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED,
|
||||
write_secured=pb.WriteSecuredCommand(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
current_user_id=current_user_id,
|
||||
verifier_user_id=verifier_user_id,
|
||||
value=to_mx_value(value),
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
secrets=_value_secrets(value),
|
||||
)
|
||||
|
||||
async def write_secured2(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handle: int,
|
||||
value: MxValueInput,
|
||||
timestamp_value: MxValueInput,
|
||||
*,
|
||||
current_user_id: int = 0,
|
||||
verifier_user_id: int = 0,
|
||||
correlation_id: str = "",
|
||||
) -> None:
|
||||
"""Invoke MXAccess `WriteSecured2` — a signed/verified, timestamped write.
|
||||
|
||||
Like :meth:`write_secured` but also stamps a client-supplied timestamp.
|
||||
The written *value* is credential-sensitive and is scrubbed from any
|
||||
surfaced error message. Native pre-condition failures are surfaced as-is.
|
||||
"""
|
||||
await self._invoke_redacted(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED2,
|
||||
write_secured2=pb.WriteSecured2Command(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
current_user_id=current_user_id,
|
||||
verifier_user_id=verifier_user_id,
|
||||
value=to_mx_value(value),
|
||||
timestamp_value=to_mx_value(timestamp_value),
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
secrets=_value_secrets(value),
|
||||
)
|
||||
|
||||
async def authenticate_user(
|
||||
self,
|
||||
server_handle: int,
|
||||
verify_user: str,
|
||||
verify_user_password: str,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> int:
|
||||
"""Invoke MXAccess `AuthenticateUser` and return the resolved Galaxy user id.
|
||||
|
||||
*verify_user_password* is a raw MXAccess credential: it is never logged
|
||||
and is scrubbed from any surfaced error message. A native authentication
|
||||
failure is surfaced as :class:`~zb_mom_ww_mxgateway.errors.MxAccessError`
|
||||
with the credential removed.
|
||||
"""
|
||||
reply = await self._invoke_redacted(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER,
|
||||
authenticate_user=pb.AuthenticateUserCommand(
|
||||
server_handle=server_handle,
|
||||
verify_user=verify_user,
|
||||
verify_user_password=verify_user_password,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
secrets=[verify_user_password],
|
||||
)
|
||||
return reply.authenticate_user.user_id
|
||||
|
||||
async def archestra_user_to_id(
|
||||
self,
|
||||
server_handle: int,
|
||||
user_id_guid: str,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> int:
|
||||
"""Invoke MXAccess `ArchestrAUserToId` and return the resolved user id."""
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID,
|
||||
archestra_user_to_id=pb.ArchestrAUserToIdCommand(
|
||||
server_handle=server_handle,
|
||||
user_id_guid=user_id_guid,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return reply.archestra_user_to_id.user_id
|
||||
|
||||
async def add_buffered_item(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_definition: str,
|
||||
item_context: str = "",
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> int:
|
||||
"""Invoke MXAccess `AddBufferedItem` and return the new `ItemHandle`."""
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_ADD_BUFFERED_ITEM,
|
||||
add_buffered_item=pb.AddBufferedItemCommand(
|
||||
server_handle=server_handle,
|
||||
item_definition=item_definition,
|
||||
item_context=item_context,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return reply.add_buffered_item.item_handle
|
||||
|
||||
async def set_buffered_update_interval(
|
||||
self,
|
||||
server_handle: int,
|
||||
update_interval_milliseconds: int,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> None:
|
||||
"""Invoke MXAccess `SetBufferedUpdateInterval` for the server handle."""
|
||||
await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL,
|
||||
set_buffered_update_interval=pb.SetBufferedUpdateIntervalCommand(
|
||||
server_handle=server_handle,
|
||||
update_interval_milliseconds=update_interval_milliseconds,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def suspend(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handle: int,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> pb.MxStatusProxy:
|
||||
"""Invoke MXAccess `Suspend` for an `ItemHandle` and return its status."""
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_SUSPEND,
|
||||
suspend=pb.SuspendCommand(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return reply.suspend.status
|
||||
|
||||
async def activate(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handle: int,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> pb.MxStatusProxy:
|
||||
"""Invoke MXAccess `Activate` for a suspended `ItemHandle` and return its status."""
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_ACTIVATE,
|
||||
activate=pb.ActivateCommand(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return reply.activate.status
|
||||
|
||||
def stream_events(
|
||||
self,
|
||||
*,
|
||||
@@ -631,4 +875,39 @@ def _ensure_bulk_size(name: str, count: int) -> None:
|
||||
raise ValueError(f"{name} bulk commands are limited to {MAX_BULK_ITEMS} item(s)")
|
||||
|
||||
|
||||
def _value_secrets(value: MxValueInput) -> list[str]:
|
||||
"""Return the redaction candidate strings for a credential-sensitive write value.
|
||||
|
||||
Secured-write payloads may carry a password or other secret. Only textual
|
||||
values can appear verbatim in a surfaced error, so a string value (or a
|
||||
UTF-8-decodable ``bytes`` value) is returned for scrubbing; other value
|
||||
kinds have no verbatim text form to leak.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return [value] if value else []
|
||||
if isinstance(value, bytes):
|
||||
try:
|
||||
decoded = value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return []
|
||||
return [decoded] if decoded else []
|
||||
return []
|
||||
|
||||
|
||||
def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None:
|
||||
"""Scrub secret substrings from a raised error's message in place.
|
||||
|
||||
Rewrites ``error.args[0]`` (the message returned by ``str(error)``) through
|
||||
the shared :func:`~zb_mom_ww_mxgateway.auth.redact_secret` seam so credential
|
||||
text can never reach logs or be re-raised to a caller. The
|
||||
``protocol_status`` / ``raw_reply`` context is left untouched — those hold the
|
||||
gateway's own fields, which never echo the client-supplied secret.
|
||||
"""
|
||||
scrubbed = [secret for secret in secrets if secret]
|
||||
if not scrubbed:
|
||||
return
|
||||
if error.args and isinstance(error.args[0], str):
|
||||
error.args = (redact_secret(error.args[0], scrubbed), *error.args[1:])
|
||||
|
||||
|
||||
from .client import GatewayClient # noqa: E402
|
||||
|
||||
Reference in New Issue
Block a user