feat(rust-client): operator credentials on acknowledge-alarm
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
@@ -78,7 +78,8 @@ cargo run -p mxgw-cli -- add-item --session-id <session-id> --server-handle 1 --
|
|||||||
cargo run -p mxgw-cli -- advise --session-id <session-id> --server-handle 1 --item-handle 1 --json
|
cargo run -p mxgw-cli -- advise --session-id <session-id> --server-handle 1 --item-handle 1 --json
|
||||||
cargo run -p mxgw-cli -- stream-events --session-id <session-id> --max-events 1 --json
|
cargo run -p mxgw-cli -- stream-events --session-id <session-id> --max-events 1 --json
|
||||||
cargo run -p mxgw-cli -- stream-alarms --session-id <session-id> --max-messages 4 --json
|
cargo run -p mxgw-cli -- stream-alarms --session-id <session-id> --max-messages 4 --json
|
||||||
cargo run -p mxgw-cli -- acknowledge-alarm --session-id <session-id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
cargo run -p mxgw-cli -- acknowledge-alarm --reference "Area001.Pump001.PumpFault" --operator <user> [--operator-domain <domain>] --json
|
||||||
|
# operator password: MXGATEWAY_OPERATOR_PASSWORD, or --operator-password-env / --operator-password
|
||||||
cargo run -p mxgw-cli -- write --session-id <session-id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --json
|
cargo run -p mxgw-cli -- write --session-id <session-id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -124,6 +125,9 @@ creates an authenticated `tonic` client and attaches `authorization: Bearer
|
|||||||
`stream_alarms`, `acknowledge_alarm`, and `raw_client`. `stream_alarms`
|
`stream_alarms`, `acknowledge_alarm`, and `raw_client`. `stream_alarms`
|
||||||
returns an `AlarmFeedStream` async stream of alarm-feed messages and
|
returns an `AlarmFeedStream` async stream of alarm-feed messages and
|
||||||
shares the gateway's central alarm monitor with every other client.
|
shares the gateway's central alarm monitor with every other client.
|
||||||
|
`acknowledge_alarm` requires `operator_user` and carries `operator_password` (may be
|
||||||
|
empty) and `operator_domain`; the gateway authenticates as that operator so the Historian
|
||||||
|
records them. The password is scrubbed from any returned error's `Display`/`Debug`.
|
||||||
|
|
||||||
The `from_truncated_snapshot` field on `ActiveAlarmSnapshot` reports that the record came from
|
The `from_truncated_snapshot` field on `ActiveAlarmSnapshot` reports that the record came from
|
||||||
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
|
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ alarm monitor — no worker session is opened — so any number of clients may
|
|||||||
attach. Dropping the stream cancels the gRPC call cooperatively.
|
attach. Dropping the stream cancels the gRPC call cooperatively.
|
||||||
`acknowledge_alarm` is idempotent at the MxAccess layer; the returned
|
`acknowledge_alarm` is idempotent at the MxAccess layer; the returned
|
||||||
`AcknowledgeAlarmReply` carries the native MxStatus from the worker.
|
`AcknowledgeAlarmReply` carries the native MxStatus from the worker.
|
||||||
|
`operator_user` is required; `operator_password` may be empty and is scrubbed from any
|
||||||
|
returned error via `Error::redact_secrets`.
|
||||||
|
|
||||||
## Galaxy Repository
|
## Galaxy Repository
|
||||||
|
|
||||||
@@ -346,7 +348,7 @@ mxgw write-secured-bulk ...
|
|||||||
mxgw write-secured2-bulk ...
|
mxgw write-secured2-bulk ...
|
||||||
mxgw stream-events --session-id <id> --json
|
mxgw stream-events --session-id <id> --json
|
||||||
mxgw stream-alarms [--filter-prefix <prefix>] [--max-events <n>]
|
mxgw stream-alarms [--filter-prefix <prefix>] [--max-events <n>]
|
||||||
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] [--operator <user>]
|
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] --operator <user> [--operator-domain <domain>] [--operator-password-env <VAR>=MXGATEWAY_OPERATOR_PASSWORD] [--operator-password <pw>]
|
||||||
mxgw bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>]
|
mxgw bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>]
|
||||||
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
|
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
|
||||||
mxgw batch
|
mxgw batch
|
||||||
|
|||||||
@@ -376,6 +376,15 @@ enum Command {
|
|||||||
comment: String,
|
comment: String,
|
||||||
#[arg(long, default_value = "")]
|
#[arg(long, default_value = "")]
|
||||||
operator: String,
|
operator: String,
|
||||||
|
/// Galaxy domain of the operator; empty authenticates the bare user name.
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
operator_domain: String,
|
||||||
|
/// Operator password (prefer `--operator-password-env`; empty is allowed).
|
||||||
|
#[arg(long)]
|
||||||
|
operator_password: Option<String>,
|
||||||
|
/// Environment variable holding the operator password.
|
||||||
|
#[arg(long, default_value = "MXGATEWAY_OPERATOR_PASSWORD")]
|
||||||
|
operator_password_env: String,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
@@ -1081,8 +1090,16 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
|||||||
reference,
|
reference,
|
||||||
comment,
|
comment,
|
||||||
operator,
|
operator,
|
||||||
|
operator_domain,
|
||||||
|
operator_password,
|
||||||
|
operator_password_env,
|
||||||
json,
|
json,
|
||||||
} => {
|
} => {
|
||||||
|
// An empty password is allowed (Galaxy accounts may have one), so
|
||||||
|
// an unset flag and env resolve to an empty string.
|
||||||
|
let operator_password = operator_password
|
||||||
|
.or_else(|| env::var(&operator_password_env).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
let client = connect(connection).await?;
|
let client = connect(connection).await?;
|
||||||
let reply = client
|
let reply = client
|
||||||
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
||||||
@@ -1090,6 +1107,8 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
|||||||
alarm_full_reference: reference,
|
alarm_full_reference: reference,
|
||||||
comment,
|
comment,
|
||||||
operator_user: operator,
|
operator_user: operator,
|
||||||
|
operator_password,
|
||||||
|
operator_domain,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
print_acknowledge_alarm_reply(&reply, json);
|
print_acknowledge_alarm_reply(&reply, json);
|
||||||
|
|||||||
@@ -327,6 +327,9 @@ impl GatewayClient {
|
|||||||
/// MXAccess session; the resulting native MxStatus is returned in the
|
/// MXAccess session; the resulting native MxStatus is returned in the
|
||||||
/// reply. Acks are idempotent at the MxAccess layer.
|
/// reply. Acks are idempotent at the MxAccess layer.
|
||||||
///
|
///
|
||||||
|
/// `operator_user` is required; `operator_password` may be empty. The
|
||||||
|
/// password is scrubbed from any returned error's `Display`/`Debug`.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`Error::ProtocolStatus`] when the gateway accepts the call but
|
/// Returns [`Error::ProtocolStatus`] when the gateway accepts the call but
|
||||||
@@ -336,13 +339,19 @@ impl GatewayClient {
|
|||||||
&self,
|
&self,
|
||||||
request: AcknowledgeAlarmRequest,
|
request: AcknowledgeAlarmRequest,
|
||||||
) -> Result<AcknowledgeAlarmReply, Error> {
|
) -> Result<AcknowledgeAlarmReply, Error> {
|
||||||
|
let secret = request.operator_password.clone();
|
||||||
let mut client = self.inner.clone();
|
let mut client = self.inner.clone();
|
||||||
let response = client
|
let result = async {
|
||||||
.acknowledge_alarm(self.unary_request(request))
|
let response = client
|
||||||
.await?;
|
.acknowledge_alarm(self.unary_request(request))
|
||||||
let reply = response.into_inner();
|
.await?;
|
||||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
|
let reply = response.into_inner();
|
||||||
Ok(reply)
|
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
|
||||||
|
Ok(reply)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
// The operator password must never surface in a returned error.
|
||||||
|
result.map_err(|error: Error| error.redact_secrets(&[secret.as_str()]))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the server-streaming `QueryActiveAlarms` RPC — the gateway's
|
/// Open the server-streaming `QueryActiveAlarms` RPC — the gateway's
|
||||||
|
|||||||
@@ -314,6 +314,68 @@ impl std::fmt::Display for MxAccessError {
|
|||||||
|
|
||||||
impl std::error::Error for MxAccessError {}
|
impl std::error::Error for MxAccessError {}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
/// Scrub exact caller-supplied secrets (for example an operator password)
|
||||||
|
/// from the error's rendered text and any wrapped `tonic::Status`, so that
|
||||||
|
/// neither `Display` nor `Debug` can reveal them. Empty secrets are ignored.
|
||||||
|
#[must_use]
|
||||||
|
pub fn redact_secrets(self, secrets: &[&str]) -> Self {
|
||||||
|
let scrub = |text: &str| {
|
||||||
|
let mut out = text.to_owned();
|
||||||
|
for secret in secrets.iter().filter(|secret| !secret.is_empty()) {
|
||||||
|
out = out.replace(*secret, "<redacted>");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
};
|
||||||
|
let scrub_status = |status: Box<tonic::Status>| {
|
||||||
|
Box::new(tonic::Status::new(status.code(), scrub(status.message())))
|
||||||
|
};
|
||||||
|
match self {
|
||||||
|
Self::Authentication { message, status } => Self::Authentication {
|
||||||
|
message: scrub(&message),
|
||||||
|
status: scrub_status(status),
|
||||||
|
},
|
||||||
|
Self::Authorization { message, status } => Self::Authorization {
|
||||||
|
message: scrub(&message),
|
||||||
|
status: scrub_status(status),
|
||||||
|
},
|
||||||
|
Self::Timeout { message, status } => Self::Timeout {
|
||||||
|
message: scrub(&message),
|
||||||
|
status: scrub_status(status),
|
||||||
|
},
|
||||||
|
Self::Cancelled { message, status } => Self::Cancelled {
|
||||||
|
message: scrub(&message),
|
||||||
|
status: scrub_status(status),
|
||||||
|
},
|
||||||
|
Self::Unavailable { message, status } => Self::Unavailable {
|
||||||
|
message: scrub(&message),
|
||||||
|
status: scrub_status(status),
|
||||||
|
},
|
||||||
|
Self::Status(status) => Self::Status(scrub_status(status)),
|
||||||
|
Self::ProtocolStatus {
|
||||||
|
operation,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
} => Self::ProtocolStatus {
|
||||||
|
operation,
|
||||||
|
code,
|
||||||
|
message: scrub(&message),
|
||||||
|
},
|
||||||
|
Self::MalformedReply { detail } => Self::MalformedReply {
|
||||||
|
detail: scrub(&detail),
|
||||||
|
},
|
||||||
|
Self::InvalidArgument { name, detail } => Self::InvalidArgument {
|
||||||
|
name,
|
||||||
|
detail: scrub(&detail),
|
||||||
|
},
|
||||||
|
Self::MxAccess(error) => Self::MxAccess(Box::new(
|
||||||
|
error.with_secrets(secrets.iter().map(|secret| (*secret).to_owned()).collect()),
|
||||||
|
)),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<tonic::Status> for Error {
|
impl From<tonic::Status> for Error {
|
||||||
fn from(status: tonic::Status) -> Self {
|
fn from(status: tonic::Status) -> Self {
|
||||||
let message = redact_credentials(status.message());
|
let message = redact_credentials(status.message());
|
||||||
@@ -461,6 +523,14 @@ fn redact_credentials(message: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn redact_secrets_scrubs_status_display_and_debug() {
|
||||||
|
let error: super::Error = tonic::Status::internal("boom pw-secret").into();
|
||||||
|
let error = error.redact_secrets(&["pw-secret", ""]);
|
||||||
|
assert!(!format!("{error:?}").contains("pw-secret"));
|
||||||
|
assert!(!error.to_string().contains("pw-secret"));
|
||||||
|
}
|
||||||
|
|
||||||
use tonic::{Code, Status};
|
use tonic::{Code, Status};
|
||||||
|
|
||||||
use super::{ensure_mxaccess_success, Error};
|
use super::{ensure_mxaccess_success, Error};
|
||||||
|
|||||||
@@ -229,6 +229,8 @@ async fn acknowledge_alarm_returns_reply_with_native_status() {
|
|||||||
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||||
comment: "investigating".to_owned(),
|
comment: "investigating".to_owned(),
|
||||||
operator_user: "alice".to_owned(),
|
operator_user: "alice".to_owned(),
|
||||||
|
operator_password: String::new(),
|
||||||
|
operator_domain: String::new(),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1036,6 +1038,30 @@ async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn acknowledge_alarm_error_does_not_leak_operator_password() {
|
||||||
|
let state = Arc::new(FakeState::default());
|
||||||
|
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||||
|
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = client
|
||||||
|
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
||||||
|
client_correlation_id: "corr-pw".to_owned(),
|
||||||
|
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||||
|
comment: String::new(),
|
||||||
|
operator_user: "alice".to_owned(),
|
||||||
|
operator_password: "pw-secret".to_owned(),
|
||||||
|
operator_domain: String::new(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(!format!("{err:?}").contains("pw-secret"));
|
||||||
|
assert!(!err.to_string().contains("pw-secret"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_id() {
|
async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_id() {
|
||||||
// The CLI's `stream-alarms` and `acknowledge-alarm` paths used to
|
// The CLI's `stream-alarms` and `acknowledge-alarm` paths used to
|
||||||
@@ -1073,6 +1099,8 @@ async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_
|
|||||||
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||||
comment: String::new(),
|
comment: String::new(),
|
||||||
operator_user: String::new(),
|
operator_user: String::new(),
|
||||||
|
operator_password: String::new(),
|
||||||
|
operator_domain: String::new(),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1424,8 +1452,11 @@ impl MxAccessGateway for FakeGateway {
|
|||||||
&self,
|
&self,
|
||||||
request: Request<AcknowledgeAlarmRequest>,
|
request: Request<AcknowledgeAlarmRequest>,
|
||||||
) -> Result<Response<AcknowledgeAlarmReply>, Status> {
|
) -> Result<Response<AcknowledgeAlarmReply>, Status> {
|
||||||
*self.state.last_correlation_id.lock().await =
|
let request = request.into_inner();
|
||||||
Some(request.into_inner().client_correlation_id);
|
if request.operator_password == "pw-secret" {
|
||||||
|
return Err(Status::internal("boom pw-secret"));
|
||||||
|
}
|
||||||
|
*self.state.last_correlation_id.lock().await = Some(request.client_correlation_id);
|
||||||
Ok(Response::new(AcknowledgeAlarmReply {
|
Ok(Response::new(AcknowledgeAlarmReply {
|
||||||
correlation_id: "corr-1".to_owned(),
|
correlation_id: "corr-1".to_owned(),
|
||||||
protocol_status: Some(ok_status("ack ok")),
|
protocol_status: Some(ok_status("ack ok")),
|
||||||
|
|||||||
Reference in New Issue
Block a user