diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1514d80e47..5bd43b48d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -547,7 +547,7 @@ jobs: done echo "🔍 Connector-only PR — scoping tests to: $CONNECTOR_NAMES" cargo nextest run \ - --features grpc-server/connector-request-kafka \ + --features grpc-server/connector-request-kafka,grpc-server/connector-response-masking \ --config-file .nextest.toml \ --profile ci \ --no-tests=warn \ @@ -555,7 +555,7 @@ jobs: else echo "🔍 Running full test suite (event=$EVENT)" cargo nextest run \ - --features grpc-server/connector-request-kafka \ + --features grpc-server/connector-request-kafka,grpc-server/connector-response-masking \ --config-file .nextest.toml \ --profile ci \ -E "$SCHEMA_EXCLUDE" diff --git a/Cargo.lock b/Cargo.lock index 04db86a3a6..62def4833f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1797,6 +1797,7 @@ dependencies = [ "grpc-api-types", "http 0.2.12", "hyperswitch_masking", + "quick-xml 0.31.0", "rand 0.8.5", "regex", "router_derive", @@ -1806,6 +1807,7 @@ dependencies = [ "strum 0.26.3", "thiserror 1.0.69", "time 0.3.47", + "toml 0.8.23", "tonic 0.14.5", "tracing", "ucs_cards", diff --git a/Dockerfile b/Dockerfile index 9ea7e4a5e5..22ee6ab24b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,6 +55,8 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* # Build only the binary shipped by the runtime stage; skips test/SDK crates. +# `connector-response-masking` is deliberately absent: the code that turns a connector response +# into `masked_connector_response` is then not compiled in, so no config setting can enable it here. COPY . . RUN --mount=type=cache,target=/sccache \ cargo build --release --features kafka,connector-request-kafka,otel -p grpc-server diff --git a/config/development.toml b/config/development.toml index 64708417c3..e4f0e44ced 100644 --- a/config/development.toml +++ b/config/development.toml @@ -237,3 +237,37 @@ enqueue_timeout_ms = 5000 [unmasked_headers] keys = ["x-request-id","x-merchant-id","x-lineage-ids","x-reference-id","x-connector","x-tenant-id","x-shadow-mode","x-proxy-name"] + +# Selectively-masked view of the connector response, exposed as +# `masked_connector_response`. Every key is preserved; a value is shown only if +# that connector's list below names it. Gated separately from +# `common.return_raw_connector_data`, so this can stay on where raw capture is off. +# +# This whole section only takes effect in a build compiled with +# `--features connector-response-masking`. Without it the code is not in the binary and every +# setting below is ignored, `enabled` included. +[connector_response_masking] +enabled = true + +# Whether to ALSO write the masked view to our own logs (`response.masked_body`). +# `enabled` above already returns it to the caller; this is the extra copy we retain, +# so it stays off outside development. +log_to_span = true + +# Per-connector unmask lists, comma-separated and case-insensitive. +# A connector with no entry gets every value masked (keys still visible). +# Naming a key here reveals only that key's own value: an object below it is re-decided key by +# key, and an array below it stays masked, since its elements have no key you could name. +# A key whose name looks like a full PAN, CVV, expiry or credential stays masked even if listed. +# Truncated values (cardSummary, last4, cardBin) are not covered — name them if needed. +# A body that is not JSON, XML or form-encoded has no keys to gate on, so it is replaced +# wholesale by a stub carrying only its size. +# +# Names are validated against every connector enum — payment, surcharge, payout, FRM +# and authenticator — so interpayments, deutschebank and plaid are valid keys here. +# +# Only one entry is seeded, as a worked example for testing. Add a line per +# connector as you need its fields visible; an unknown connector name here will +# abort startup rather than be ignored. +[connector_response_masking.connector_keys] +adyen = "pspreference,resultcode,merchantreference,refusalreason,eventcode,success" diff --git a/config/production.toml b/config/production.toml index f5164420c0..30180e0d4c 100644 --- a/config/production.toml +++ b/config/production.toml @@ -178,3 +178,36 @@ keys = ["x-request-id","x-merchant-id","x-lineage-ids","x-reference-id","x-conne # Connectors that require an external API call for webhook source verification [webhook_source_verification_call] # comma-separated list of connector names (case-insensitive) connectors_with_webhook_source_verification_call = "paypal, truelayer" + +# Selectively-masked view of the connector response, exposed as +# `masked_connector_response`. Every key is preserved; a value is shown only if +# that connector's list below names it. Gated separately from +# `common.return_raw_connector_data`, so this can stay on where raw capture is off. +# +# This whole section only takes effect in a build compiled with +# `--features connector-response-masking`. The release image does not enable it, so the code is +# absent and every setting below is ignored, `enabled` included. +[connector_response_masking] +# Off by default: turning this on returns connector response bytes to the caller, so each +# deployment should opt in once it has chosen the key lists below. +enabled = false + +# Whether to ALSO write the masked view to our own logs (`response.masked_body`). +# `enabled` above already returns it to the caller; this is the extra copy we retain, +# so a mistaken allowlist entry stays contained to whoever configured it. +log_to_span = false + +# Per-connector unmask lists, comma-separated and case-insensitive. +# A connector with no entry gets every value masked (keys still visible). +# Naming a key here reveals only that key's own value: an object below it is re-decided key by +# key, and an array below it stays masked, since its elements have no key you could name. +# A key whose name looks like a full PAN, CVV, expiry or credential stays masked even if listed. +# Truncated values (cardSummary, last4, cardBin) are not covered — name them if needed. +# A body that is not JSON, XML or form-encoded has no keys to gate on, so it is replaced +# wholesale by a stub carrying only its size. +# +# Only one entry is seeded, as a worked example for testing. Add a line per +# connector as you need its fields visible; an unknown connector name here will +# abort startup rather than be ignored. +[connector_response_masking.connector_keys] +adyen = "pspreference,resultcode,merchantreference,refusalreason,eventcode,success" diff --git a/config/sandbox.toml b/config/sandbox.toml index 983d091978..1b887d67ff 100644 --- a/config/sandbox.toml +++ b/config/sandbox.toml @@ -179,3 +179,36 @@ keys = ["x-request-id","x-merchant-id","x-lineage-ids","x-reference-id","x-conne # Connectors that require an external API call for webhook source verification [webhook_source_verification_call] # comma-separated list of connector names (case-insensitive) connectors_with_webhook_source_verification_call = "paypal, truelayer" + +# Selectively-masked view of the connector response, exposed as +# `masked_connector_response`. Every key is preserved; a value is shown only if +# that connector's list below names it. Gated separately from +# `common.return_raw_connector_data`, so this can stay on where raw capture is off. +# +# This whole section only takes effect in a build compiled with +# `--features connector-response-masking`. The release image does not enable it, so the code is +# absent and every setting below is ignored, `enabled` included. +[connector_response_masking] +# Off by default: turning this on returns connector response bytes to the caller, so each +# deployment should opt in once it has chosen the key lists below. +enabled = false + +# Whether to ALSO write the masked view to our own logs (`response.masked_body`). +# `enabled` above already returns it to the caller; this is the extra copy we retain, +# so a mistaken allowlist entry stays contained to whoever configured it. +log_to_span = false + +# Per-connector unmask lists, comma-separated and case-insensitive. +# A connector with no entry gets every value masked (keys still visible). +# Naming a key here reveals only that key's own value: an object below it is re-decided key by +# key, and an array below it stays masked, since its elements have no key you could name. +# A key whose name looks like a full PAN, CVV, expiry or credential stays masked even if listed. +# Truncated values (cardSummary, last4, cardBin) are not covered — name them if needed. +# A body that is not JSON, XML or form-encoded has no keys to gate on, so it is replaced +# wholesale by a stub carrying only its size. +# +# Only one entry is seeded, as a worked example for testing. Add a line per +# connector as you need its fields visible; an unknown connector name here will +# abort startup rather than be ignored. +[connector_response_masking.connector_keys] +adyen = "pspreference,resultcode,merchantreference,refusalreason,eventcode,success" diff --git a/crates/common/common_utils/src/bytes_utils.rs b/crates/common/common_utils/src/bytes_utils.rs new file mode 100644 index 0000000000..b47db5e0fa --- /dev/null +++ b/crates/common/common_utils/src/bytes_utils.rs @@ -0,0 +1,16 @@ +//! Small helpers over raw byte slices. + +const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF]; + +/// Strip any leading UTF-8 BOMs from `bytes`. +/// +/// Several gateways (Authorize.Net among them) prefix responses with a BOM, which every body +/// parser rejects. Takes bytes rather than a decoded string so callers can strip before deciding +/// whether the body is even UTF-8. Repeated BOMs are all removed. +pub fn strip_utf8_bom(bytes: &[u8]) -> &[u8] { + let mut rest = bytes; + while let Some(stripped) = rest.strip_prefix(UTF8_BOM) { + rest = stripped; + } + rest +} diff --git a/crates/common/common_utils/src/events.rs b/crates/common/common_utils/src/events.rs index e9bc9c1a5f..4a2c802533 100644 --- a/crates/common/common_utils/src/events.rs +++ b/crates/common/common_utils/src/events.rs @@ -1,5 +1,6 @@ use hyperswitch_masking::ErasedMaskSerialize; use serde::{Deserialize, Serialize}; +use std::borrow::Cow; use std::collections::HashMap; use crate::errors::EventPublisherError; @@ -320,6 +321,24 @@ impl Event { }); } + /// Carry the connector's reply, every value masked except the ones that connector's allowlist + /// names, so a consumer can read it off the event stream. + /// + /// Recorded on the connector-call event because that is where it is produced, next to the + /// request and response it describes. Attached whenever it exists: delivery to consumers is + /// the point of the field and is deliberately independent of whether we log it ourselves — + /// see [`MASKED_CONNECTOR_RESPONSE_KEY`]. + pub fn add_masked_connector_response(&mut self, masked_response: &str) { + MaskedSerdeValue::from_masked_optional( + &masked_response.to_string(), + MASKED_CONNECTOR_RESPONSE_KEY, + ) + .map(|masked| { + self.additional_fields + .insert(MASKED_CONNECTOR_RESPONSE_KEY.to_string(), masked); + }); + } + pub fn set_grpc_error_response(&mut self, tonic_error: &tonic::Status) { self.status_code = Some(tonic_error.code().into()); let error_body = serde_json::json!({ @@ -515,8 +534,65 @@ impl Default for EventConfig { } } +/// Serde key of the connector's masked reply, wherever it appears in an event. +/// +/// Named once because two things must agree on it: [`Event::add_masked_connector_response`], which +/// writes it, and the callers that ask [`emit_event_with_config_redacting`] to keep it out of our +/// logs. +pub const MASKED_CONNECTOR_RESPONSE_KEY: &str = "masked_connector_response"; + +/// Drop `keys` from `value` wherever they occur, at any depth. +/// +/// Recursive rather than top-level: the same key sits at different depths depending on which event +/// carries it — top level on the connector event, under `response_data` on the gRPC event, and +/// nested again inside `event_content.content` for webhook responses. Borrows unless something +/// actually matches, so the usual empty-`keys` call costs nothing. +pub fn without_keys<'a>(value: &'a serde_json::Value, keys: &[&str]) -> Cow<'a, serde_json::Value> { + fn contains(value: &serde_json::Value, keys: &[&str]) -> bool { + match value { + serde_json::Value::Object(map) => map + .iter() + .any(|(key, nested)| keys.contains(&key.as_str()) || contains(nested, keys)), + serde_json::Value::Array(items) => items.iter().any(|item| contains(item, keys)), + _ => false, + } + } + + fn strip(value: &mut serde_json::Value, keys: &[&str]) { + match value { + serde_json::Value::Object(map) => { + map.retain(|key, _| !keys.contains(&key.as_str())); + map.values_mut().for_each(|nested| strip(nested, keys)); + } + serde_json::Value::Array(items) => items.iter_mut().for_each(|item| strip(item, keys)), + _ => {} + } + } + + if keys.is_empty() || !contains(value, keys) { + return Cow::Borrowed(value); + } + let mut owned = value.clone(); + strip(&mut owned, keys); + Cow::Owned(owned) +} + /// Emit an event: always processes and logs; publishes to Kafka only when kafka feature is enabled. pub fn emit_event_with_config(event: Event, config: &EventConfig) { + emit_event_with_config_redacting(event, config, &[]) +} + +/// As [`emit_event_with_config`], but omits `redacted_from_log` from the copy written to our own +/// logs. +/// +/// The published payload is deliberately untouched. A field can be required by a downstream +/// consumer and still be something we decline to retain ourselves, which is exactly the shape +/// `connector_response_masking.log_to_span` asks for. +pub fn emit_event_with_config_redacting( + event: Event, + config: &EventConfig, + redacted_from_log: &[&str], +) { let processed_event = match process_event_with_config(&event, config) { Ok(processed) => processed, Err(e) => { @@ -524,7 +600,7 @@ pub fn emit_event_with_config(event: Event, config: &EventConfig) { return; } }; - let event_json = serde_json::to_string(&processed_event) + let event_json = serde_json::to_string(&without_keys(&processed_event, redacted_from_log)) .unwrap_or_else(|e| format!("{{\"error\":\"Failed to serialize event: {}\"}}", e)); tracing::info!( events_enabled = config.enabled, @@ -779,3 +855,95 @@ mod runtime_metadata_tests { assert_eq!(value.get("version").and_then(|v| v.as_str()), Some("v")); } } + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod log_redaction_tests { + use super::{without_keys, MASKED_CONNECTOR_RESPONSE_KEY}; + use serde_json::json; + use std::borrow::Cow; + + const KEYS: &[&str] = &[MASKED_CONNECTOR_RESPONSE_KEY]; + + #[test] + fn removes_the_key_at_the_top_level() { + // Where it lands on the connector-call event: `additional_fields` is flattened. + let value = json!({"masked_connector_response": "{\"id\":\"1\"}", "connector": "adyen"}); + let out = without_keys(&value, KEYS); + assert!(out.get(MASKED_CONNECTOR_RESPONSE_KEY).is_none()); + assert_eq!(out.get("connector").and_then(|v| v.as_str()), Some("adyen")); + } + + #[test] + fn removes_the_key_when_nested() { + // Where it lands on the gRPC event (`response_data`) and, deeper, inside a webhook + // response (`event_content.content`). A top-level-only strip passes the first and + // silently leaks the second. + let value = json!({ + "response_data": { + "status": "CHARGED", + "masked_connector_response": "{\"pan\":\"***\"}", + "event_content": { + "content": {"masked_connector_response": "{\"deep\":\"***\"}"} + } + } + }); + let out = without_keys(&value, KEYS); + assert!(!out.to_string().contains(MASKED_CONNECTOR_RESPONSE_KEY)); + assert_eq!( + out.pointer("/response_data/status") + .and_then(|v| v.as_str()), + Some("CHARGED"), + "siblings must survive" + ); + } + + #[test] + fn removes_the_key_inside_arrays() { + let value = json!({"items": [{"masked_connector_response": "x", "keep": 1}]}); + let out = without_keys(&value, KEYS); + assert!(!out.to_string().contains(MASKED_CONNECTOR_RESPONSE_KEY)); + assert_eq!( + out.pointer("/items/0/keep").and_then(|v| v.as_u64()), + Some(1) + ); + } + + #[test] + fn borrows_when_nothing_matches() { + // The `log_to_span = true` and no-redaction paths must not pay for a deep clone. + let value = json!({"response_data": {"status": "CHARGED"}}); + assert!(matches!(without_keys(&value, KEYS), Cow::Borrowed(_))); + assert!(matches!(without_keys(&value, &[]), Cow::Borrowed(_))); + } + + #[test] + fn an_empty_key_list_is_a_no_op_even_when_the_key_is_present() { + // This is the `log_to_span = true` case: the value stays in the logged copy. + let value = json!({"masked_connector_response": "kept"}); + let out = without_keys(&value, &[]); + assert_eq!( + out.get(MASKED_CONNECTOR_RESPONSE_KEY) + .and_then(|v| v.as_str()), + Some("kept") + ); + } + + #[test] + fn the_published_value_is_never_mutated() { + // The property the whole change rests on: redaction produces a separate copy for the log, + // leaving the payload handed to Kafka intact. + let published = + json!({"masked_connector_response": "{\"id\":\"1\"}", "connector": "adyen"}); + let logged = without_keys(&published, KEYS).into_owned(); + + assert!(logged.get(MASKED_CONNECTOR_RESPONSE_KEY).is_none()); + assert_eq!( + published + .get(MASKED_CONNECTOR_RESPONSE_KEY) + .and_then(|v| v.as_str()), + Some("{\"id\":\"1\"}"), + "the published copy must still carry it" + ); + } +} diff --git a/crates/common/common_utils/src/lib.rs b/crates/common/common_utils/src/lib.rs index c9b842f21b..37fc860782 100644 --- a/crates/common/common_utils/src/lib.rs +++ b/crates/common/common_utils/src/lib.rs @@ -2,6 +2,7 @@ extern crate self as common_utils; +pub mod bytes_utils; pub mod config_patch; pub mod crypto; pub mod custom_serde; @@ -23,7 +24,10 @@ pub mod types; pub use errors::{CustomResult, EventPublisherError, ParsingError, ValidationError}; #[cfg(feature = "kafka")] pub use event_publisher::init_event_publisher; -pub use events::emit_event_with_config; +pub use events::{ + emit_event_with_config, emit_event_with_config_redacting, without_keys, + MASKED_CONNECTOR_RESPONSE_KEY, +}; #[cfg(not(feature = "kafka"))] pub fn init_event_publisher(_config: &events::EventConfig) {} diff --git a/crates/common/external-services/Cargo.toml b/crates/common/external-services/Cargo.toml index a0f20b626b..1527e31d40 100644 --- a/crates/common/external-services/Cargo.toml +++ b/crates/common/external-services/Cargo.toml @@ -76,6 +76,9 @@ injector-client = ["dep:injector"] connector-request-kafka = ["dep:connector_request_kafka"] # Push gRPC metrics over OTLP (in addition to the Prometheus /metrics endpoint). otel = ["dep:opentelemetry"] +# Populate `masked_connector_response` from the connector's reply. See the same feature on +# domain_types: off by default, so the code is absent from a build that does not ask for it. +connector-response-masking = ["domain_types/connector-response-masking"] [lints] workspace = true diff --git a/crates/common/external-services/src/service.rs b/crates/common/external-services/src/service.rs index 408925938e..ab626aff63 100644 --- a/crates/common/external-services/src/service.rs +++ b/crates/common/external-services/src/service.rs @@ -228,7 +228,7 @@ use common_utils::events::{Event, EventConfig, FlowName, RuntimeMetadata}; use common_utils::types::ExecutionMode; #[cfg(feature = "injector-client")] // TokenData is now imported from hyperswitch_injector -use common_utils::{consts, emit_event_with_config}; +use common_utils::{consts, emit_event_with_config_redacting}; use error_stack::{report, ResultExt}; use hyperswitch_masking::Maskable; #[cfg(feature = "injector-client")] @@ -327,6 +327,76 @@ fn flow_status_label(flow_status: &domain_types::router_data::FlowStatus) -> Str } } +/// Build the selectively-masked view of a connector response and stash it on the flow data. +/// +/// Reads the untouched response bytes, so this works whether or not `raw_connector_response` +/// is being captured — the safe view can be on in production with raw capture off. +/// +/// `connector_name` is a lookup key, not something to re-parse: it came from +/// `ConnectorVariant::get_connector_name()`, and ingress already validated it against whichever +/// connector enum matches the flow family. +#[cfg(feature = "connector-response-masking")] +fn record_masked_connector_response( + resource_common_data: &mut ResourceCommonData, + body: &Response, + connector_name: &str, + config: &domain_types::connector_response_masking::ConnectorResponseMaskingConfig, + event: Option<&mut Event>, +) where + ResourceCommonData: RawConnectorRequestResponse, +{ + // By name: this HeaderMap is reqwest 0.11 (http 0.2), not the http 1.x in scope. + let content_type = body + .headers + .as_ref() + .and_then(|headers| headers.get("content-type")) + .and_then(|value| value.to_str().ok()); + + let masked = domain_types::connector_response_masking::mask_connector_response( + &body.response, + content_type, + connector_name, + config, + ); + + // Gated separately from populating the field: the caller always gets the masked view back, + // but a copy only lands in our own logs where that is explicitly enabled. + if config.log_to_span { + if let Some(masked) = masked.as_deref() { + tracing::Span::current() + .record("response.masked_body", tracing::field::display(masked)); + } + } + + // Onto the connector-call event, so a consumer can read it off the event stream. Attached + // whether or not we log it: `log_to_span` governs our own logs, not what we hand downstream. + // The logged copy of this event drops it again at the emit site. + if let (Some(event), Some(masked)) = (event, masked.as_deref()) { + event.add_masked_connector_response(masked); + } + + resource_common_data.set_masked_connector_response(masked); +} + +/// Keys to drop from the logged copy of an event, leaving the published payload untouched. +/// +/// Only ever the masked connector response, and only while the operator has declined to log it. +/// Consumers still receive it either way — that separation is the whole point of `log_to_span`. +#[cfg(feature = "connector-response-masking")] +fn log_redacted_event_keys(event_params: &EventProcessingParams<'_>) -> &'static [&'static str] { + if event_params.connector_response_masking.log_to_span { + &[] + } else { + &[common_utils::MASKED_CONNECTOR_RESPONSE_KEY] + } +} + +#[cfg(not(feature = "connector-response-masking"))] +fn log_redacted_event_keys(_event_params: &EventProcessingParams<'_>) -> &'static [&'static str] { + // Nothing to redact: without the feature the field is never produced. + &[] +} + /// Handles the connector response, processing both successful and error responses #[allow(clippy::too_many_arguments)] pub fn handle_connector_response( @@ -367,6 +437,20 @@ where .set_connector_response_headers(body.headers.clone()); } + // Independent of `return_raw_connector_data`: this view is already sanitized. + #[cfg(feature = "connector-response-masking")] + if let Some(params) = + event_params.filter(|p| p.connector_response_masking.enabled) + { + record_masked_connector_response( + &mut updated_router_data.resource_common_data, + &body, + params.connector_name, + params.connector_response_masking, + event.as_deref_mut(), + ); + } + let handle_response_result = connector.handle_response_v2( &updated_router_data, event.as_deref_mut(), @@ -424,6 +508,20 @@ where .set_connector_response_headers(body.headers.clone()); } + // A 4xx/5xx body is exactly when the masked view is most useful. + #[cfg(feature = "connector-response-masking")] + if let Some(params) = + event_params.filter(|p| p.connector_response_masking.enabled) + { + record_masked_connector_response( + &mut updated_router_data.resource_common_data, + &body, + params.connector_name, + params.connector_response_masking, + event.as_deref_mut(), + ); + } + let error_response = match body.status_code { 500..=511 => connector.get_5xx_error_response( body.clone(), @@ -533,6 +631,12 @@ pub struct EventProcessingParams<'a> { pub tenant_id: &'a str, pub merchant_id: &'a str, pub return_raw_connector_data: bool, + /// Per-connector key lists driving `masked_connector_response`. Present only in a build with + /// the `connector-response-masking` feature, and gated at runtime by its own `enabled` flag — + /// deliberately independent of `return_raw_connector_data`. + #[cfg(feature = "connector-response-masking")] + pub connector_response_masking: + &'a domain_types::connector_response_masking::ConnectorResponseMaskingConfig, pub connector_latency: ConnectorLatencyTracker, } @@ -546,6 +650,7 @@ pub struct EventProcessingParams<'a> { request.url = Empty, request.method = Empty, response.body = Empty, + response.masked_body = Empty, response.headers = Empty, response.error_message = Empty, response.status_code = Empty, @@ -894,7 +999,11 @@ where Err(transport_err) => Err(transport_err), }; - emit_event_with_config(event, event_params.event_config); + emit_event_with_config_redacting( + event, + event_params.event_config, + log_redacted_event_keys(&event_params), + ); result } None => Ok(router_data), @@ -1012,7 +1121,11 @@ where Err(publish_err) => Err(publish_err), }; - emit_event_with_config(event, event_params.event_config); + emit_event_with_config_redacting( + event, + event_params.event_config, + log_redacted_event_keys(&event_params), + ); result } None => Ok(router_data), @@ -1615,14 +1728,8 @@ async fn handle_response( /// Helper function to remove BOM from response bytes and convert to string fn strip_bom_and_convert_to_string(response_bytes: &[u8]) -> Option { - String::from_utf8(response_bytes.to_vec()).ok().map(|s| { - // Remove BOM if present (UTF-8 BOM is 0xEF, 0xBB, 0xBF) - if s.starts_with('\u{FEFF}') { - s.trim_start_matches('\u{FEFF}').to_string() - } else { - s - } - }) + let stripped = common_utils::bytes_utils::strip_utf8_bom(response_bytes); + String::from_utf8(stripped.to_vec()).ok() } #[cfg(feature = "injector-client")] diff --git a/crates/common/ucs_env/Cargo.toml b/crates/common/ucs_env/Cargo.toml index 56202101e9..2ae79a7d33 100644 --- a/crates/common/ucs_env/Cargo.toml +++ b/crates/common/ucs_env/Cargo.toml @@ -68,6 +68,12 @@ build_info = { git = "https://github.com/juspay/framework-libs-rs", rev = "24356 default = [] kafka = ["tracing-kafka"] otel = ["dep:opentelemetry", "dep:opentelemetry-otlp", "dep:opentelemetry_sdk"] +# Carry the `[connector_response_masking]` config section. Without it the section is absent from +# `Config` and simply ignored wherever it appears in a config file. +connector-response-masking = [ + "domain_types/connector-response-masking", + "external-services/connector-response-masking", +] [lints] workspace = true \ No newline at end of file diff --git a/crates/common/ucs_env/src/configs.rs b/crates/common/ucs_env/src/configs.rs index 7d9afce9ab..6b27063686 100644 --- a/crates/common/ucs_env/src/configs.rs +++ b/crates/common/ucs_env/src/configs.rs @@ -14,6 +14,11 @@ use domain_types::{ connector_types::ConnectorEnum, types::{Connectors, ConnectorsPatch, ProxyConfig, ProxyConfigPatch}, }; +// Both names are needed in scope: `config_patch_derive::Patch` resolves the patch type by name. +#[cfg(feature = "connector-response-masking")] +use domain_types::connector_response_masking::{ + ConnectorResponseMaskingConfig, ConnectorResponseMaskingConfigPatch, +}; use crate::{ error::ConfigurationError, @@ -34,6 +39,12 @@ pub struct Config { pub lineage: LineageConfig, #[serde(default)] pub unmasked_headers: HeaderMaskingConfig, + /// Per-connector key lists controlling which response values stay visible in + /// `masked_connector_response`. Compiled in only with the `connector-response-masking` + /// feature; otherwise the section is ignored wherever a config file sets it. + #[cfg(feature = "connector-response-masking")] + #[serde(default)] + pub connector_response_masking: ConnectorResponseMaskingConfig, #[serde(default)] pub test: TestConfig, #[serde(default)] diff --git a/crates/grpc-server/grpc-server/Cargo.toml b/crates/grpc-server/grpc-server/Cargo.toml index 938418cb2d..e8ede0d2ad 100644 --- a/crates/grpc-server/grpc-server/Cargo.toml +++ b/crates/grpc-server/grpc-server/Cargo.toml @@ -101,6 +101,12 @@ connector-request-kafka = ["dep:connector_request_kafka", "external-services/con # Push gRPC metrics over OTLP to an OpenTelemetry Collector, in addition to the # Prometheus /metrics scrape endpoint. otel = ["ucs_env/otel", "external-services/otel"] +# Return `masked_connector_response` to the caller. Off by default, so a release image built +# without it cannot emit connector response content whatever the config file says. +connector-response-masking = [ + "ucs_env/connector-response-masking", + "external-services/connector-response-masking", +] [lints] workspace = true diff --git a/crates/grpc-server/grpc-server/src/server/disputes.rs b/crates/grpc-server/grpc-server/src/server/disputes.rs index 5c2eb10b94..d35cb3a165 100644 --- a/crates/grpc-server/grpc-server/src/server/disputes.rs +++ b/crates/grpc-server/grpc-server/src/server/disputes.rs @@ -184,6 +184,8 @@ impl DisputeService for Disputes { tenant_id: &tenant_id, merchant_id: merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency, }; @@ -424,6 +426,8 @@ impl DisputeService for Disputes { tenant_id: &tenant_id, merchant_id: merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency, }; diff --git a/crates/grpc-server/grpc-server/src/server/events.rs b/crates/grpc-server/grpc-server/src/server/events.rs index 8299b1aa34..4d983fb618 100644 --- a/crates/grpc-server/grpc-server/src/server/events.rs +++ b/crates/grpc-server/grpc-server/src/server/events.rs @@ -504,6 +504,8 @@ impl EventServiceImpl { tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -613,6 +615,8 @@ impl EventServiceImpl { tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -722,6 +726,8 @@ impl EventServiceImpl { tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -828,6 +834,8 @@ impl EventServiceImpl { tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -934,6 +942,8 @@ impl EventServiceImpl { tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -975,6 +985,7 @@ async fn verify_webhook_source_external( connectors: config.connectors.clone(), connector_request_reference_id: format!("webhook_verify_{}", metadata_payload.request_id), raw_connector_response: None, + masked_connector_response: None, raw_connector_request: None, connector_response_headers: None, }; @@ -1029,6 +1040,8 @@ async fn verify_webhook_source_external( tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; diff --git a/crates/grpc-server/grpc-server/src/server/payments.rs b/crates/grpc-server/grpc-server/src/server/payments.rs index 1ccdd45768..2aa5c7f689 100644 --- a/crates/grpc-server/grpc-server/src/server/payments.rs +++ b/crates/grpc-server/grpc-server/src/server/payments.rs @@ -577,6 +577,8 @@ impl Payments { tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -715,6 +717,8 @@ impl Payments { tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -1129,6 +1133,8 @@ impl PaymentService for Payments { tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -2607,6 +2613,8 @@ impl PaymentMethod { tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -2743,6 +2751,8 @@ impl PaymentMethod { tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), runtime_metadata: &config.runtime_metadata, }; @@ -2860,6 +2870,8 @@ impl MerchantAuthentication { tenant_id: event_params.tenant_id, merchant_id: event_params.merchant_id, return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: event_params.connector_latency.clone(), }; @@ -3001,6 +3013,8 @@ impl MerchantAuthentication { tenant_id: event_params.tenant_id, merchant_id: event_params.merchant_id, return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: event_params.connector_latency.clone(), }; @@ -3580,6 +3594,8 @@ impl RecurringPaymentService for RecurringPayments { tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -3852,6 +3868,9 @@ pub fn generate_mandate_revoke_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -3881,6 +3900,7 @@ pub fn generate_mandate_revoke_response( network_transaction_id: None, merchant_revoke_id: None, raw_connector_response, + masked_connector_response, raw_connector_request, }), Err(e) => Ok(RecurringPaymentServiceRevokeResponse { @@ -3901,6 +3921,7 @@ pub fn generate_mandate_revoke_response( network_transaction_id: None, merchant_revoke_id: e.connector_transaction_id, raw_connector_response, + masked_connector_response, raw_connector_request, }), } diff --git a/crates/grpc-server/grpc-server/src/utils.rs b/crates/grpc-server/grpc-server/src/utils.rs index 72b103f699..6a0b3a8577 100644 --- a/crates/grpc-server/grpc-server/src/utils.rs +++ b/crates/grpc-server/grpc-server/src/utils.rs @@ -289,15 +289,62 @@ where Ok(()) } -pub fn log_after_initialization(result: &Result, tonic::Status>) +/// Whether the operator asked for the masked connector response to reach our own logs. +/// +/// Without the `connector-response-masking` feature the field is never populated, so there is +/// nothing to strip and the answer does not matter. +#[cfg(feature = "connector-response-masking")] +fn should_log_masked(config: &configs::Config) -> bool { + config.connector_response_masking.log_to_span +} + +#[cfg(not(feature = "connector-response-masking"))] +fn should_log_masked(_config: &configs::Config) -> bool { + true +} + +/// Serialize a gRPC response for logging, dropping `masked_connector_response` unless the operator +/// opted into logging it. +/// +/// That field already has its own log channel — `response.masked_body`, gated by the same flag in +/// `external-services` — so while the flag is off it must not ride along inside a generic response +/// payload either. Masked the same way the request side is (see `log_before_initialization`), so +/// `Secret` fields are hidden by serde rather than relying on each field's `Debug`. +/// +/// Removal is recursive: on this struct the field is top-level, but `EventContent` nests whole +/// response messages under `event_content.content`, where a top-level-only strip would miss it. +fn response_for_logging(response: &R, log_masked: bool) -> Value where - T: serde::Serialize + std::fmt::Debug, + R: serde::Serialize, +{ + let value = match hyperswitch_masking::masked_serialize(response) { + Ok(value) => value, + Err(e) => { + tracing::error!("Masked serialization error: {:?}", e); + return Value::String("".to_string()); + } + }; + + if log_masked { + return value; + } + common_utils::without_keys(&value, &[common_utils::MASKED_CONNECTOR_RESPONSE_KEY]).into_owned() +} + +pub fn log_after_initialization( + result: &Result, tonic::Status>, + log_masked: bool, +) where + T: serde::Serialize, { let current_span = tracing::Span::current(); match &result { Ok(response) => { - current_span.record("response_body", tracing::field::debug(response.get_ref())); + current_span.record( + "response_body", + response_for_logging(response.get_ref(), log_masked).to_string(), + ); let res_ref = response.get_ref(); @@ -376,7 +423,7 @@ where .await; let grpc_response = handler_result.into_grpc_status(); - log_after_initialization(&grpc_response); + log_after_initialization(&grpc_response, should_log_masked(&config)); #[cfg(feature = "otel")] observe_internal_latency( @@ -440,7 +487,7 @@ where .await; let grpc_response = handler_result.into_grpc_status(); - log_after_initialization(&grpc_response); + log_after_initialization(&grpc_response, should_log_masked(&config)); #[cfg(feature = "otel")] observe_internal_latency( @@ -541,7 +588,12 @@ fn create_and_emit_grpc_event( ); match grpc_response { - Ok(response) => grpc_event.set_grpc_success_response(response.get_ref()), + // Same strip as the span field: the event payload is logged in full on every request + // (`emit_event_with_config`), so it is the second way the masked view would reach our logs. + Ok(response) => grpc_event.set_grpc_success_response(&response_for_logging( + response.get_ref(), + should_log_masked(config), + )), Err(error) => { grpc_event.set_grpc_error_response(error); grpc_event.set_error_response(&build_error_detail(error)); @@ -709,6 +761,8 @@ macro_rules! implement_connector_operation { tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -1070,6 +1124,8 @@ macro_rules! implement_connector_operation { tenant_id: &metadata_payload.tenant_id, merchant_id: metadata_payload.merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + #[cfg(feature = "connector-response-masking")] + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; let call_connector_action = connector_integration.get_call_connector_action(); diff --git a/crates/integrations/connector-integration/src/authenticator_connectors/plaid/test.rs b/crates/integrations/connector-integration/src/authenticator_connectors/plaid/test.rs index 28be8ce7fc..9094af37a8 100644 --- a/crates/integrations/connector-integration/src/authenticator_connectors/plaid/test.rs +++ b/crates/integrations/connector-integration/src/authenticator_connectors/plaid/test.rs @@ -41,6 +41,7 @@ mod tests { RouterDataV2 { flow: PhantomData, resource_common_data: MerchantAuthenticationFlowData { + masked_connector_response: None, merchant_id: common_utils::id_type::MerchantId::default(), connectors: Connectors::default(), connector_request_reference_id: "ref_test".to_owned(), @@ -201,6 +202,7 @@ mod tests { RouterDataV2 { flow: PhantomData, resource_common_data: PaymentFlowData { + masked_connector_response: None, merchant_id: common_utils::id_type::MerchantId::default(), customer_id: None, connector_customer: None, @@ -359,6 +361,7 @@ mod tests { RouterDataV2 { flow: PhantomData, resource_common_data: PaymentFlowData { + masked_connector_response: None, merchant_id: common_utils::id_type::MerchantId::default(), customer_id: None, connector_customer: None, diff --git a/crates/integrations/connector-integration/src/connectors/adyen/test.rs b/crates/integrations/connector-integration/src/connectors/adyen/test.rs index d58afbfc84..fe7281855d 100644 --- a/crates/integrations/connector-integration/src/connectors/adyen/test.rs +++ b/crates/integrations/connector-integration/src/connectors/adyen/test.rs @@ -38,6 +38,7 @@ mod tests { > = RouterDataV2 { flow: PhantomData::, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: common_utils::id_type::MerchantId::default(), customer_id: None, @@ -245,6 +246,7 @@ mod tests { > = RouterDataV2 { flow: PhantomData::, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: common_utils::id_type::MerchantId::default(), customer_id: None, diff --git a/crates/integrations/connector-integration/src/connectors/calida/test.rs b/crates/integrations/connector-integration/src/connectors/calida/test.rs index 4c4ed924c1..4ee4017a4d 100644 --- a/crates/integrations/connector-integration/src/connectors/calida/test.rs +++ b/crates/integrations/connector-integration/src/connectors/calida/test.rs @@ -43,6 +43,7 @@ mod tests { > = RouterDataV2 { flow: PhantomData::, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, vault_headers: None, merchant_id: common_utils::id_type::MerchantId::default(), @@ -230,6 +231,7 @@ mod tests { > = RouterDataV2 { flow: PhantomData::, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, vault_headers: None, merchant_id: common_utils::id_type::MerchantId::default(), diff --git a/crates/integrations/connector-integration/src/connectors/razorpay/test.rs b/crates/integrations/connector-integration/src/connectors/razorpay/test.rs index 25b6deb3bd..4f975cb090 100644 --- a/crates/integrations/connector-integration/src/connectors/razorpay/test.rs +++ b/crates/integrations/connector-integration/src/connectors/razorpay/test.rs @@ -59,6 +59,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -274,6 +275,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -427,6 +429,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -590,6 +593,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -963,6 +967,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1172,6 +1177,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1383,6 +1389,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: domain_types::connector_types::PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1519,6 +1526,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: domain_types::connector_types::PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1646,6 +1654,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1808,6 +1817,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1944,6 +1954,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -2069,6 +2080,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, diff --git a/crates/types-traits/domain_types/Cargo.toml b/crates/types-traits/domain_types/Cargo.toml index a3ef2625cd..c6568ca986 100644 --- a/crates/types-traits/domain_types/Cargo.toml +++ b/crates/types-traits/domain_types/Cargo.toml @@ -24,8 +24,11 @@ router_derive = { git = "https://github.com/juspay/hyperswitch", tag = "2026.06. thiserror = { workspace = true } strum = { version = "0.26", features = ["derive"] } serde = { workspace = true } -serde_json = { workspace = true } -serde_urlencoded = "0.7" +serde_json = { workspace = true, features = ["preserve_order"] } +# Only `connector_response_masking` uses these, so both hang off that feature: without it this +# crate does not depend on an XML or a form parser at all. +serde_urlencoded = { version = "0.7", optional = true } +quick-xml = { version = "0.31.0", features = ["serialize"], optional = true } error-stack = "0.4.1" base64 = "0.21" rand = "0.8.5" @@ -45,7 +48,13 @@ actix-web = { version = "4.11.0", optional = true } tracing = { workspace = true } tonic = { workspace = true } +[dev-dependencies] +# Already in the lockfile; used to prove the config section parses in its real format. +toml = "0.8" [features] default = ["actix-web"] actix-web = ["dep:actix-web"] +# Builds `masked_connector_response`. Off by default: it turns raw connector response bytes into a +# string the caller is handed back, so a build that does not opt in cannot produce one at all. +connector-response-masking = ["dep:quick-xml", "dep:serde_urlencoded"] diff --git a/crates/types-traits/domain_types/src/connector_response_masking.rs b/crates/types-traits/domain_types/src/connector_response_masking.rs new file mode 100644 index 0000000000..0318645083 --- /dev/null +++ b/crates/types-traits/domain_types/src/connector_response_masking.rs @@ -0,0 +1,1035 @@ +//! Builds `masked_connector_response`: the connector's reply with every key preserved and every +//! value masked unless that connector's configured list names it. Emitted in the same format the +//! gateway used — JSON, XML or form-encoded. + +use std::collections::{HashMap, HashSet}; + +use quick_xml::events::{BytesStart, BytesText, Event}; +use quick_xml::{Reader, Writer}; +use serde::ser::{SerializeMap, SerializeSeq}; +use serde::{Deserialize, Serialize, Serializer}; +use serde_json::Value; + +use common_utils::config_patch::Patch; + +use crate::connector_types::{ + AuthenticatorConnectorEnum, ConnectorEnum, FrmConnectorEnum, PayoutConnectorEnum, + SurchargeConnectorEnum, +}; + +/// Replacement written in place of a masked value. +pub const MASKED: &str = "***"; + +/// Per-connector configuration controlling which response keys keep their value. +/// +/// No global list: a field name safe on one gateway is not necessarily safe on another. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct ConnectorResponseMaskingConfig { + /// Whether to populate `masked_connector_response` at all. + pub enabled: bool, + + /// Whether the masked view may reach **our own logs**. Separate from [`Self::enabled`], which + /// decides whether it is produced and delivered at all. + /// + /// While this is off the value is stripped from every log sink — the `response.masked_body` + /// span field, the `response_body` field, and the logged copy of each event — because a plain + /// `String` has no type-level masking to fall back on, unlike its `Secret` sibling + /// `raw_connector_response`. + /// + /// It does **not** gate delivery. The caller still receives the field on the gRPC response, and + /// it is still published on the connector-call event for consumers to read off the event + /// stream. So this is containment within UCS, not end-to-end: the published record is a + /// retained copy we no longer control. + pub log_to_span: bool, + + /// Connector name -> comma-separated list of keys whose values stay visible. + /// + /// Comma-separated rather than a list because that is the only shape settable per connector + /// from the environment. Keyed by name rather than a typed enum — see [`is_known_connector`]. + #[serde(deserialize_with = "deserialize_connector_keys")] + pub connector_keys: HashMap, String>, +} + +/// Whether any connector enum recognises this snake_case name. +/// +/// Ingress resolves a connector per flow family — `x-connector` against [`ConnectorEnum`], +/// `x-payout-connector` against [`PayoutConnectorEnum`], and so on +/// (`ucs_interface_common::metadata::connector_variant_from_metadata`). No single enum spans all +/// five, so validating against one would reject real connectors: `interpayments`, `deutschebank` +/// and `plaid` have no [`ConnectorEnum`] counterpart. +fn is_known_connector(name: &str) -> bool { + use std::str::FromStr; + + ConnectorEnum::from_str(name).is_ok() + || SurchargeConnectorEnum::from_str(name).is_ok() + || PayoutConnectorEnum::from_str(name).is_ok() + || FrmConnectorEnum::from_str(name).is_ok() + || AuthenticatorConnectorEnum::from_str(name).is_ok() +} + +/// Validate map keys via `FromStr`, not the serde derive. +/// +/// `#[strum(serialize_all = "snake_case")]` governs `FromStr` *and* the derived `Display` — which +/// is why `get_connector_name()` yields `adyen` and a lowercase config key resolves at all. It does +/// not reach serde: the connector enums carry no `#[serde(rename_all)]`, so their serde derive +/// would demand `Adyen`, while both TOML keys and the config crate's env-var keys arrive +/// lowercased (`CS__…__CONNECTOR_KEYS__ADYEN` → `adyen`). Same route as +/// `WebhookSourceVerificationCall`. +fn deserialize_connector_keys<'de, D>( + deserializer: D, +) -> Result, String>, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + + HashMap::::deserialize(deserializer)? + .into_iter() + .map(|(name, keys)| { + let normalized = name.to_lowercase(); + if is_known_connector(&normalized) { + Ok((normalized.into_boxed_str(), keys)) + } else { + Err(D::Error::custom(format!("unknown connector `{name}`"))) + } + }) + .collect() +} + +impl ConnectorResponseMaskingConfig { + /// Keys whose values stay visible for `connector_name`. Built per request rather than cached, + /// so a runtime config patch can never leave a stale set behind. No entry yields an empty set, + /// as does an unrecognised name — masking every value is right either way. + pub fn keys_for(&self, connector_name: &str) -> HashSet> { + self.connector_keys + .get(connector_name) + .map(|keys| { + keys.split(',') + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(|key| key.to_lowercase().into_boxed_str()) + .collect() + }) + .unwrap_or_default() + } +} + +/// Partial override for [`ConnectorResponseMaskingConfig`]. +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct ConnectorResponseMaskingConfigPatch { + /// See [`ConnectorResponseMaskingConfig::enabled`]. + pub enabled: Option, + /// See [`ConnectorResponseMaskingConfig::log_to_span`]. + pub log_to_span: Option, + /// See [`ConnectorResponseMaskingConfig::connector_keys`]. + #[serde(default, deserialize_with = "deserialize_optional_connector_keys")] + pub connector_keys: Option, String>>, +} + +fn deserialize_optional_connector_keys<'de, D>( + deserializer: D, +) -> Result, String>>, D::Error> +where + D: serde::Deserializer<'de>, +{ + deserialize_connector_keys(deserializer).map(Some) +} + +impl Patch for ConnectorResponseMaskingConfig { + fn apply(&mut self, patch: ConnectorResponseMaskingConfigPatch) { + if let Some(enabled) = patch.enabled { + self.enabled = enabled; + } + if let Some(log_to_span) = patch.log_to_span { + self.log_to_span = log_to_span; + } + if let Some(connector_keys) = patch.connector_keys { + self.connector_keys = connector_keys; + } + // Nothing derived to rebuild — the next request reads the new lists directly. + } +} + +/// Never revealed regardless of config. Substring match after stripping non-alphanumerics, so +/// `card_number`, `cardNumber` and `ssl_card_number` all match `cardnumber`. +/// +/// Scope is full PAN, CVV, expiry and credentials — **not** every card-derived field. Truncated +/// values such as `cardSummary`, `last4` and `cardBin` are deliberately absent: they are not PAN, +/// they appear on receipts, and a connector that needs them for reconciliation can name them in +/// its own list. +const ALWAYS_MASKED_SUBSTRING: &[&str] = &[ + "cardnumber", + "cardnum", + "accountnumber", + "cvv", + "cvc", + "cvn", + "expmonth", + "expyear", + "expirydate", + "secret", + "token", + "password", + "signature", + "apikey", +]; + +/// Never revealed, matched **exactly**. `authorization` is here rather than above so it does not +/// also block `authorizationCode`, which operators legitimately reveal. +const ALWAYS_MASKED_EXACT: &[&str] = &["authorization"]; + +/// Normalise a key for comparison: lowercase, alphanumerics only. +fn normalize(key: &str) -> String { + key.chars() + .filter(char::is_ascii_alphanumeric) + .map(|character| character.to_ascii_lowercase()) + .collect() +} + +/// Whether a key is on either never-reveal list. +fn is_always_masked(key: &str) -> bool { + let normalized = normalize(key); + ALWAYS_MASKED_EXACT + .iter() + .any(|needle| normalized == *needle) + || ALWAYS_MASKED_SUBSTRING + .iter() + .any(|needle| normalized.contains(needle)) +} + +/// Whether the connector's configured list names this key. +fn in_allowlist(keys: &HashSet>, key: &str) -> bool { + keys.contains(key.to_ascii_lowercase().as_str()) + // XML names may be prefixed (`s:authCode`); accept the local name too. + || key + .rsplit_once(':') + .is_some_and(|(_, local)| keys.contains(local.to_ascii_lowercase().as_str())) +} + +/// Whether this key's scalar value keeps its value. +/// +/// Allowlist first: the denylist only ever overrides a key the allowlist would have revealed, so +/// the keys it rejects are masked either way. Checking membership first skips the substring scan +/// for the large majority of fields. +fn allowed(keys: &HashSet>, key: &str) -> bool { + in_allowlist(keys, key) && !is_always_masked(key) +} + +/// Namespace declarations are structural: masking them would break prefix resolution, and they +/// never carry secrets. +fn is_namespace_declaration(name: &str) -> bool { + name == "xmlns" || name.starts_with("xmlns:") +} + +/// Serializes a [`Value`], substituting `"***"` for scalars whose key is not allowed. +/// +/// `mask` is the decision for a value that has no key of its own — the body's root, and every +/// array element. It is only ever cleared by an object entry whose key the allowlist names, so +/// allowing a key reveals that key's own scalar and nothing else: objects beneath it re-decide +/// per key, arrays beneath it stay masked. +struct Masked<'a> { + value: &'a Value, + keys: &'a HashSet>, + mask: bool, +} + +impl Serialize for Masked<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self.value { + // Containers always recurse: allowing a key must never reveal a whole subtree. + Value::Object(map) => { + let mut state = serializer.serialize_map(Some(map.len()))?; + for (key, value) in map { + state.serialize_entry( + key, + &Self { + value, + keys: self.keys, + mask: !allowed(self.keys, key), + }, + )?; + } + state.end() + } + // Elements carry no key of their own, so an operator can never name one. Inheriting + // the parent's decision would let a single allowed key reveal every scalar beneath + // it, which is the invariant the object arm above upholds. + Value::Array(items) => { + let mut state = serializer.serialize_seq(Some(items.len()))?; + for value in items { + state.serialize_element(&Self { + value, + keys: self.keys, + mask: true, + })?; + } + state.end() + } + // An explicit null carries no secret and is worth seeing. + Value::Null => self.value.serialize(serializer), + _ if self.mask => serializer.serialize_str(MASKED), + other => other.serialize(serializer), + } + } +} + +fn mask_json(body: &[u8], keys: &HashSet>) -> Option { + let value: Value = serde_json::from_slice(body).ok()?; + serde_json::to_string(&Masked { + // The root has no key to gate on — a bare scalar or an array of scalars must not be + // revealed just because nothing named it. An object re-decides per key immediately. + value: &value, + keys, + mask: true, + }) + .ok() +} + +/// Rebuild a tag, masking attribute values whose name is not allowed. Values are unescaped first +/// because `push_attribute` re-escapes. +fn mask_attributes(tag: &BytesStart<'_>, keys: &HashSet>) -> BytesStart<'static> { + let name = String::from_utf8_lossy(tag.name().as_ref()).into_owned(); + let mut rebuilt = BytesStart::new(name); + for attribute in tag.attributes().flatten() { + let key = String::from_utf8_lossy(attribute.key.as_ref()).into_owned(); + if is_namespace_declaration(&key) || allowed(keys, &key) { + let value = attribute.unescape_value().unwrap_or_default(); + rebuilt.push_attribute((key.as_str(), value.as_ref())); + } else { + rebuilt.push_attribute((key.as_str(), MASKED)); + } + } + rebuilt +} + +fn mask_xml(body: &[u8], keys: &HashSet>) -> Option { + let text = std::str::from_utf8(body).ok()?; + let mut reader = Reader::from_str(text); + // Lenient: this is a diagnostic artefact, not a validator. + reader.check_end_names(false); + + let mut writer = Writer::new(Vec::new()); + // The element currently open; a Text/CData event belongs to it. + let mut current: Option = None; + + loop { + match reader.read_event().ok()? { + Event::Eof => break, + Event::Start(tag) => { + current = Some(String::from_utf8_lossy(tag.name().as_ref()).into_owned()); + writer + .write_event(Event::Start(mask_attributes(&tag, keys))) + .ok()?; + } + Event::Empty(tag) => { + writer + .write_event(Event::Empty(mask_attributes(&tag, keys))) + .ok()?; + } + Event::End(tag) => { + current = None; + writer.write_event(Event::End(tag)).ok()?; + } + Event::Text(text) => { + // Whitespace between elements is layout, not data — never mask it. + let keep = text.iter().all(u8::is_ascii_whitespace) + || current.as_deref().is_some_and(|name| allowed(keys, name)); + if keep { + writer.write_event(Event::Text(text)).ok()?; + } else { + writer + .write_event(Event::Text(BytesText::new(MASKED))) + .ok()?; + } + } + Event::CData(data) => { + if current.as_deref().is_some_and(|name| allowed(keys, name)) { + writer.write_event(Event::CData(data)).ok()?; + } else { + writer + .write_event(Event::Text(BytesText::new(MASKED))) + .ok()?; + } + } + // The XML declaration is structural — version and encoding, never data. + Event::Decl(declaration) => { + writer.write_event(Event::Decl(declaration)).ok()?; + } + // Comments are free text with no element name to gate on, so a gateway that echoes + // the request into one would leak it. Keep the fact that a comment was there; + // discard what it said. + Event::Comment(_) => { + writer + .write_event(Event::Comment(BytesText::new(MASKED))) + .ok()?; + } + // Processing instructions and DOCTYPE (whose internal subset can carry entity + // values) are dropped. Fail-closed: anything this match does not recognise — + // including a variant a future `quick-xml` adds — is dropped rather than forwarded. + _ => {} + } + } + + String::from_utf8(writer.into_inner()).ok() +} + +/// Whether `body` is genuinely a sequence of `key=value` pairs. +/// +/// `serde_urlencoded` never *fails* on arbitrary bytes: a segment with no `=` parses as +/// `(whole_segment, "")`. Keys are emitted verbatim by design, so without this check a body that +/// is not form-encoded at all comes back out as one giant unmasked key — a `text/plain` decline +/// message or a CSV row would be re-emitted in full. Requiring every segment to carry a non-empty +/// key sends those to the size-only stub instead. +/// +/// Deliberately all-or-nothing: a real form body with one valueless segment (`a=1&flag&b=2`) is +/// stubbed whole rather than partly emitted. No gateway is known to send that shape, and losing a +/// diagnostic is the cheaper failure here — relax it if one turns up. +fn is_pair_shaped(body: &[u8]) -> bool { + let mut saw_pair = false; + for segment in body.split(|byte| *byte == b'&') { + // A trailing or doubled separator is not a pair either way. + if segment.is_empty() { + continue; + } + match segment.iter().position(|byte| *byte == b'=') { + // No `=` at all, or nothing before it: no key to keep. + None | Some(0) => return false, + Some(_) => saw_pair = true, + } + } + saw_pair +} + +fn mask_form(body: &[u8], keys: &HashSet>) -> Option { + // Some connectors (Fiuu) separate pairs with newlines rather than `&`. Left as-is, urlencoded + // parsing folds the entire body into the first pair's value, so an allowlisted first key would + // reveal every later line. Only literal newline bytes are rewritten, so a percent-encoded + // `%0A` inside a genuine form value is untouched. + let normalised: Vec = body + .iter() + .map(|byte| match byte { + b'\n' | b'\r' => b'&', + other => *other, + }) + .collect(); + + // Checked after normalisation so a newline-separated body is judged on its real pairs, and + // before parsing because parsing is what silently accepts a non-form body. + if !is_pair_shaped(&normalised) { + return None; + } + + // A Vec rather than a map so repeated keys survive. + let pairs: Vec<(String, String)> = serde_urlencoded::from_bytes(&normalised).ok()?; + let masked = pairs + .into_iter() + .map(|(key, value)| { + if allowed(keys, &key) { + (key, value) + } else { + (key, MASKED.to_string()) + } + }) + .collect::>(); + serde_urlencoded::to_string(masked).ok() +} + +/// Wire format of a connector response body. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Format { + Json, + Xml, + Form, +} + +/// Prefer the declared `Content-Type`; fall back to sniffing the first meaningful byte. +fn detect(content_type: Option<&str>, body: &[u8]) -> Option { + if let Some(content_type) = content_type { + let lowered = content_type.to_ascii_lowercase(); + if lowered.contains("json") { + return Some(Format::Json); + } + if lowered.contains("xml") || lowered.contains("soap") { + return Some(Format::Xml); + } + if lowered.contains("x-www-form-urlencoded") { + return Some(Format::Form); + } + } + + match body.iter().find(|byte| !byte.is_ascii_whitespace()) { + Some(b'{' | b'[') => Some(Format::Json), + Some(b'<') => Some(Format::Xml), + Some(_) => Some(Format::Form), + None => None, + } +} + +/// Mask `body` for `connector_name` and re-emit it in the same format. +/// +/// Returns `None` when masking is disabled or the body is empty. A body that cannot be parsed +/// yields a labelled stub carrying only its size — never its content. +pub fn mask_connector_response( + body: &[u8], + content_type: Option<&str>, + connector_name: &str, + config: &ConnectorResponseMaskingConfig, +) -> Option { + if !config.enabled || body.is_empty() { + return None; + } + + // Some connectors (Authorize.Net) prefix responses with a UTF-8 BOM, which every parser below + // rejects. The connector strips it in `preprocess_response_bytes`, but that runs inside + // `handle_response_v2` — after this point. Must precede `detect`: a BOM makes the first + // meaningful byte `0xEF`, so sniffing would misroute before reaching the `{`. + let body = common_utils::bytes_utils::strip_utf8_bom(body); + + let keys = config.keys_for(connector_name); + + let masked = match detect(content_type, body) { + Some(Format::Json) => mask_json(body, &keys), + Some(Format::Xml) => mask_xml(body, &keys), + Some(Format::Form) => mask_form(body, &keys), + None => None, + }; + + // Emitted whole: the full body is the point, so there is no truncation. + Some(masked.unwrap_or_else(|| format!(r#"{{"_format":"unparsable","_bytes":{}}}"#, body.len()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PAYSAFE: &str = "paysafe"; + const ADYEN: &str = "adyen"; + const ELAVON: &str = "elavon"; + const PAYU: &str = "payu"; + const FIUU: &str = "fiuu"; + + /// A PAN that must never appear in any output below. Asserting on its absence is the + /// property under test; asserting on the exact shape only pins how it is spelt. + const PAN: &str = "4111111111111111"; + + fn config(pairs: &[(&str, &str)]) -> ConnectorResponseMaskingConfig { + ConnectorResponseMaskingConfig { + enabled: true, + log_to_span: false, + connector_keys: pairs + .iter() + .map(|(connector, keys)| ((*connector).into(), (*keys).to_string())) + .collect(), + } + } + + fn mask_json_body(body: &str, connector: &str, cfg: &ConnectorResponseMaskingConfig) -> String { + mask_connector_response(body.as_bytes(), Some("application/json"), connector, cfg) + .unwrap_or_default() + } + + fn mask_xml_body(body: &str, connector: &str, cfg: &ConnectorResponseMaskingConfig) -> String { + mask_connector_response(body.as_bytes(), Some("text/xml"), connector, cfg) + .unwrap_or_default() + } + + /// The size-only stub emitted when a body matches no structured format. + fn assert_is_stub(out: &str, bytes: usize) { + assert!( + out.contains(r#""_format":"unparsable""#), + "not a stub: {out}" + ); + assert!( + out.contains(&format!(r#""_bytes":{bytes}"#)), + "stub should carry the size: {out}" + ); + } + + // -- key set ----------------------------------------------------------- + + #[test] + fn keys_for_splits_trims_and_lowercases() { + let cfg = config(&[(PAYSAFE, " id , authCode ,, MerchantRefNum ")]); + let keys = cfg.keys_for(PAYSAFE); + assert_eq!(keys.len(), 3); + assert!(keys.contains("id")); + assert!(keys.contains("authcode")); + assert!(keys.contains("merchantrefnum")); + } + + #[test] + fn unknown_connector_yields_an_empty_set() { + let cfg = config(&[(PAYSAFE, "id")]); + assert!(cfg.keys_for(ADYEN).is_empty()); + } + + // -- JSON: keyed values ------------------------------------------------ + + #[test] + fn listed_keys_keep_their_value_others_are_masked() { + let cfg = config(&[(PAYSAFE, "id,status")]); + let out = mask_json_body( + r#"{"id":"1003044460","status":"COMPLETED","amount":1000}"#, + PAYSAFE, + &cfg, + ); + assert_eq!( + out, + r#"{"id":"1003044460","status":"COMPLETED","amount":"***"}"# + ); + } + + #[test] + fn nested_objects_keep_their_inner_key_names() { + let cfg = config(&[(PAYSAFE, "status")]); + let out = mask_json_body( + r#"{"status":"OK","card":{"holder":"A","last4":"1111"}}"#, + PAYSAFE, + &cfg, + ); + assert_eq!( + out, + r#"{"status":"OK","card":{"holder":"***","last4":"***"}}"# + ); + } + + #[test] + fn key_matching_ignores_case() { + let cfg = config(&[(PAYSAFE, "authcode")]); + assert_eq!( + mask_json_body(r#"{"authCode":"727050"}"#, PAYSAFE, &cfg), + r#"{"authCode":"727050"}"# + ); + } + + #[test] + fn explicit_nulls_are_preserved() { + let cfg = config(&[(PAYSAFE, "")]); + assert_eq!( + mask_json_body(r#"{"reason":null}"#, PAYSAFE, &cfg), + r#"{"reason":null}"# + ); + } + + #[test] + fn unconfigured_connector_masks_every_value_and_keeps_every_key() { + let cfg = config(&[]); + assert_eq!( + mask_json_body(r#"{"id":"1","status":"OK"}"#, ADYEN, &cfg), + r#"{"id":"***","status":"***"}"# + ); + } + + #[test] + fn denylist_overrides_the_configured_list() { + let cfg = config(&[(PAYSAFE, "cardnumber,card_number,cvv,status")]); + let out = mask_json_body( + &format!(r#"{{"status":"OK","card_number":"{PAN}","cvv":"123"}}"#), + PAYSAFE, + &cfg, + ); + assert_eq!(out, r#"{"status":"OK","card_number":"***","cvv":"***"}"#); + assert!(!out.contains(PAN)); + } + + #[test] + fn authorization_code_is_configurable_but_bare_authorization_is_not() { + // `authorization` is exact-match only: substring-matching it would permanently block + // `authorizationCode`, which connectors return and operators legitimately reveal. + let cfg = config(&[(PAYSAFE, "authorizationcode,authorization")]); + assert_eq!( + mask_json_body( + r#"{"authorizationCode":"A1B2C3","authorization":"Bearer xyz"}"#, + PAYSAFE, + &cfg, + ), + r#"{"authorizationCode":"A1B2C3","authorization":"***"}"# + ); + } + + #[test] + fn deeply_nested_input_does_not_blow_the_stack() { + let depth = 120; // serde_json refuses beyond 128 + let body = format!("{}{}{}", "{\"a\":".repeat(depth), "1", "}".repeat(depth)); + let out = mask_json_body(&body, PAYSAFE, &config(&[])); + assert!(out.ends_with(&"}".repeat(depth))); + } + + // -- JSON: the invariant that allowing a key never reveals a subtree ---- + + #[test] + fn a_listed_key_holding_an_object_still_recurses() { + let cfg = config(&[(PAYSAFE, "card,holder")]); + assert_eq!( + mask_json_body(r#"{"card":{"holder":"A","last4":"1111"}}"#, PAYSAFE, &cfg), + r#"{"card":{"holder":"A","last4":"***"}}"# + ); + } + + #[test] + fn objects_inside_arrays_are_decided_per_key() { + let cfg = config(&[(PAYSAFE, "code")]); + assert_eq!( + mask_json_body( + r#"{"errors":[{"code":"51","detail":"no funds"}]}"#, + PAYSAFE, + &cfg + ), + r#"{"errors":[{"code":"51","detail":"***"}]}"# + ); + } + + #[test] + fn scalars_in_an_array_under_a_denied_key_are_masked() { + let cfg = config(&[(PAYSAFE, "status")]); + let out = mask_json_body( + &format!(r#"{{"status":"OK","tags":["{PAN}","secret"]}}"#), + PAYSAFE, + &cfg, + ); + assert_eq!(out, r#"{"status":"OK","tags":["***","***"]}"#); + assert!(!out.contains(PAN)); + } + + #[test] + fn scalars_in_an_array_under_an_allowed_key_are_masked_too() { + // Elements carry no key of their own, so an operator can never name them. Allowing + // `success` reveals the scalar it names, not everything nested beneath it. + let cfg = config(&[(ADYEN, "success")]); + let out = mask_json_body(&format!(r#"{{"success":["{PAN}"]}}"#), ADYEN, &cfg); + assert_eq!(out, r#"{"success":["***"]}"#); + assert!(!out.contains(PAN)); + } + + #[test] + fn an_allowed_key_holding_a_scalar_is_unaffected_by_that_rule() { + let cfg = config(&[(ADYEN, "success")]); + assert_eq!( + mask_json_body(r#"{"success":true}"#, ADYEN, &cfg), + r#"{"success":true}"# + ); + } + + // -- JSON: bodies with no key to gate on ------------------------------- + + #[test] + fn a_top_level_array_of_scalars_is_masked() { + let cfg = config(&[(PAYSAFE, "status")]); + let out = mask_json_body(&format!(r#"["{PAN}","x"]"#), PAYSAFE, &cfg); + assert_eq!(out, r#"["***","***"]"#); + assert!(!out.contains(PAN)); + } + + #[test] + fn a_bare_top_level_scalar_is_masked() { + let cfg = config(&[(PAYSAFE, "status")]); + let out = mask_json_body(&format!(r#""{PAN}""#), PAYSAFE, &cfg); + assert_eq!(out, r#""***""#); + } + + #[test] + fn a_top_level_array_is_reached_by_sniffing_too() { + // `[` sniffs as JSON, so a missing Content-Type takes the same path. + let body = format!(r#"["{PAN}"]"#); + let out = mask_connector_response(body.as_bytes(), None, PAYSAFE, &config(&[])).unwrap(); + assert_eq!(out, r#"["***"]"#); + } + + // -- XML --------------------------------------------------------------- + + #[test] + fn xml_stays_xml_and_masks_element_text() { + let cfg = config(&[(ELAVON, "ssl_result")]); + let out = mask_xml_body( + &format!( + r#"0{PAN}"# + ), + ELAVON, + &cfg, + ); + + assert!(out.starts_with("0")); + assert!(out.contains("***")); + assert!(!out.contains(PAN)); + } + + #[test] + fn xml_masks_attribute_values_and_keeps_namespaces() { + let cfg = config(&[(ELAVON, "id")]); + let out = mask_xml_body( + &format!(r#""#), + ELAVON, + &cfg, + ); + + assert!(out.contains(r#"xmlns:s="urn:x""#), "namespace kept: {out}"); + assert!(out.contains(r#"id="42""#)); + assert!(out.contains(r#"pan="***""#)); + assert!(!out.contains(PAN)); + } + + #[test] + fn xml_whitespace_between_elements_is_not_masked() { + let cfg = config(&[(ELAVON, "")]); + assert_eq!( + mask_xml_body("\n 1\n", ELAVON, &cfg), + "\n ***\n" + ); + } + + #[test] + fn xml_cdata_is_masked() { + let cfg = config(&[(ELAVON, "")]); + let out = mask_xml_body( + &format!(""), + ELAVON, + &cfg, + ); + assert!(!out.contains(PAN), "{out}"); + } + + #[test] + fn xml_comments_do_not_carry_their_content_through() { + // A gateway that echoes the request inside a comment must not leak it. + let cfg = config(&[(ELAVON, "a")]); + let out = mask_xml_body(&format!("b"), ELAVON, &cfg); + assert!(!out.contains(PAN), "{out}"); + assert!( + out.contains("b"), + "allowed text still revealed: {out}" + ); + } + + #[test] + fn xml_doctype_does_not_carry_its_content_through() { + let cfg = config(&[(ELAVON, "a")]); + let out = mask_xml_body( + &format!(r#"]>b"#), + ELAVON, + &cfg, + ); + assert!(!out.contains(PAN), "{out}"); + } + + #[test] + fn xml_processing_instructions_do_not_carry_their_content_through() { + let cfg = config(&[(ELAVON, "a")]); + let out = mask_xml_body(&format!("b"), ELAVON, &cfg); + assert!(!out.contains(PAN), "{out}"); + } + + // -- form-urlencoded --------------------------------------------------- + + #[test] + fn form_stays_form_and_keeps_repeated_keys() { + let cfg = config(&[(PAYU, "status")]); + let out = mask_connector_response( + format!("status=success&tag=a&tag=b&pan={PAN}").as_bytes(), + Some("application/x-www-form-urlencoded"), + PAYU, + &cfg, + ) + .unwrap(); + + assert_eq!(out, "status=success&tag=***&tag=***&pan=***"); + } + + #[test] + fn a_newline_separated_form_body_is_masked_pair_wise() { + // Fiuu separates pairs with newlines; left as-is, urlencoded parsing folds the whole + // body into the first pair's value. + let cfg = config(&[(FIUU, "status")]); + let out = mask_connector_response( + format!("status=success\npan={PAN}\ntranID=123").as_bytes(), + Some("application/x-www-form-urlencoded"), + FIUU, + &cfg, + ) + .unwrap(); + + assert_eq!(out, "status=success&pan=***&tranID=***"); + assert!(!out.contains(PAN)); + } + + // -- bodies that match no structured format ---------------------------- + + #[test] + fn a_plain_text_body_is_not_re_emitted_as_a_form_key() { + // Keys are emitted verbatim by design, so a body that parses as one giant key would + // come back out in the clear. + let body = format!("Transaction declined for card {PAN}"); + let out = + mask_connector_response(body.as_bytes(), Some("text/plain"), PAYSAFE, &config(&[])) + .unwrap(); + + assert!(!out.contains(PAN), "{out}"); + assert_is_stub(&out, body.len()); + } + + #[test] + fn a_csv_shaped_body_is_not_re_emitted_as_form_keys() { + let body = format!("1,{PAN},SUPERSECRET"); + let out = mask_connector_response(body.as_bytes(), None, PAYSAFE, &config(&[])).unwrap(); + + assert!(!out.contains(PAN), "{out}"); + assert!(!out.contains("SUPERSECRET"), "{out}"); + assert_is_stub(&out, body.len()); + } + + #[test] + fn a_binary_body_is_not_re_emitted_as_a_form_key() { + let body: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x1A, 0x0A, 0xFF, 0xFE]; + let out = mask_connector_response(body, None, PAYSAFE, &config(&[])).unwrap(); + assert_is_stub(&out, body.len()); + } + + #[test] + fn a_declared_form_body_that_is_not_form_shaped_yields_the_stub() { + // The declared Content-Type is not enough: the body still has to be pair-shaped. + let out = mask_connector_response( + PAN.as_bytes(), + Some("application/x-www-form-urlencoded"), + PAYSAFE, + &config(&[]), + ) + .unwrap(); + + assert!(!out.contains(PAN), "{out}"); + assert_is_stub(&out, PAN.len()); + } + + #[test] + fn an_unparsable_xml_body_yields_a_stub_carrying_only_its_size() { + let body = format!("<<(toml) + .expect_err("an unknown connector name must not deserialize"); + assert!( + err.to_string().contains("paysafee"), + "error should name the bad key: {err}" + ); + } + + #[test] + fn defaults_are_off() { + let cfg = ConnectorResponseMaskingConfig::default(); + assert!(!cfg.enabled); + assert!(!cfg.log_to_span); + assert!(cfg.connector_keys.is_empty()); + } + + #[test] + fn a_patch_takes_effect_without_any_rebuild_step() { + let mut cfg = config(&[(PAYSAFE, "id")]); + assert!(cfg.keys_for(PAYSAFE).contains("id")); + + cfg.apply(ConnectorResponseMaskingConfigPatch { + enabled: None, + log_to_span: None, + connector_keys: Some( + [(PAYSAFE.into(), "status".to_string())] + .into_iter() + .collect(), + ), + }); + + let keys = cfg.keys_for(PAYSAFE); + assert!(keys.contains("status")); + assert!(!keys.contains("id"), "stale key survived the patch"); + } +} diff --git a/crates/types-traits/domain_types/src/connector_types.rs b/crates/types-traits/domain_types/src/connector_types.rs index 6728d71a6f..9fb861eb9c 100644 --- a/crates/types-traits/domain_types/src/connector_types.rs +++ b/crates/types-traits/domain_types/src/connector_types.rs @@ -590,6 +590,13 @@ pub trait RawConnectorRequestResponse { fn get_raw_connector_response(&self) -> Option>; fn set_raw_connector_request(&mut self, request: Option>); fn get_raw_connector_request(&self) -> Option>; + + /// The same response body with every key preserved and every value masked unless the + /// connector's configured list names it. `String`, not `Secret`: it is already + /// sanitized, and wrapping it would collapse it to a placeholder in logs — the very + /// problem this field exists to solve. + fn set_masked_connector_response(&mut self, response: Option); + fn get_masked_connector_response(&self) -> Option; } pub trait ConnectorResponseHeaders { @@ -789,6 +796,8 @@ pub struct PaymentFlowData { pub external_latency: Option, pub connectors: Connectors, pub raw_connector_response: Option>, + /// Same body, values masked per the connector's config. Already sanitized — not a `Secret`. + pub masked_connector_response: Option, pub raw_connector_request: Option>, pub vault_headers: Option>>, /// This field is used to store various data regarding the response from connector @@ -1479,6 +1488,14 @@ impl RawConnectorRequestResponse for PaymentFlowData { self.raw_connector_response.clone() } + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; + } + + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() + } + fn get_raw_connector_request(&self) -> Option> { self.raw_connector_request.clone() } @@ -2673,6 +2690,8 @@ pub struct RefundFlowData { pub connectors: Connectors, pub connector_request_reference_id: String, pub raw_connector_response: Option>, + /// Same body, values masked per the connector's config. Already sanitized — not a `Secret`. + pub masked_connector_response: Option, pub connector_response_headers: Option, pub raw_connector_request: Option>, pub access_token: Option, @@ -2696,6 +2715,14 @@ impl RawConnectorRequestResponse for RefundFlowData { self.raw_connector_response.clone() } + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; + } + + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() + } + fn get_raw_connector_request(&self) -> Option> { self.raw_connector_request.clone() } @@ -3708,6 +3735,8 @@ pub struct DisputeFlowData { pub defense_reason_code: Option, pub connector_request_reference_id: String, pub raw_connector_response: Option>, + /// Same body, values masked per the connector's config. Already sanitized — not a `Secret`. + pub masked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -3721,6 +3750,14 @@ impl RawConnectorRequestResponse for DisputeFlowData { self.raw_connector_response.clone() } + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; + } + + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() + } + fn set_raw_connector_request(&mut self, request: Option>) { self.raw_connector_request = request; } @@ -3745,6 +3782,8 @@ pub struct VerifyWebhookSourceFlowData { pub connectors: Connectors, pub connector_request_reference_id: String, pub raw_connector_response: Option>, + /// Same body, values masked per the connector's config. Already sanitized — not a `Secret`. + pub masked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -3758,6 +3797,14 @@ impl RawConnectorRequestResponse for VerifyWebhookSourceFlowData { self.raw_connector_response.clone() } + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; + } + + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() + } + fn get_raw_connector_request(&self) -> Option> { self.raw_connector_request.clone() } @@ -3783,6 +3830,8 @@ pub struct RefreshPaymentMethodFlowData { pub connector_request_reference_id: String, /// Provider's encrypted form only — never decrypted payment method data. pub raw_connector_response: Option>, + /// Same body, values masked per the connector's config. Already sanitized — not a `Secret`. + pub masked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -3796,6 +3845,14 @@ impl RawConnectorRequestResponse for RefreshPaymentMethodFlowData { self.raw_connector_response.clone() } + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; + } + + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() + } + fn get_raw_connector_request(&self) -> Option> { self.raw_connector_request.clone() } diff --git a/crates/types-traits/domain_types/src/frm/frm_types.rs b/crates/types-traits/domain_types/src/frm/frm_types.rs index 741060725e..a99168f13b 100644 --- a/crates/types-traits/domain_types/src/frm/frm_types.rs +++ b/crates/types-traits/domain_types/src/frm/frm_types.rs @@ -19,6 +19,8 @@ pub struct FrmFlowData { pub connectors: Connectors, pub access_token: Option, pub raw_connector_response: Option>, + /// Same body, values masked per the connector's config. Already sanitized — not a `Secret`. + pub masked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -32,6 +34,14 @@ impl RawConnectorRequestResponse for FrmFlowData { self.raw_connector_response.clone() } + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; + } + + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() + } + fn get_raw_connector_request(&self) -> Option> { self.raw_connector_request.clone() } diff --git a/crates/types-traits/domain_types/src/frm/types.rs b/crates/types-traits/domain_types/src/frm/types.rs index 7ae768806c..24d03346f2 100644 --- a/crates/types-traits/domain_types/src/frm/types.rs +++ b/crates/types-traits/domain_types/src/frm/types.rs @@ -87,6 +87,7 @@ impl .transpose()?; Ok(Self { + masked_connector_response: None, merchant_id, connectors, access_token, @@ -123,6 +124,7 @@ impl .transpose()?; Ok(Self { + masked_connector_response: None, merchant_id, connectors, access_token, @@ -159,6 +161,7 @@ impl .transpose()?; Ok(Self { + masked_connector_response: None, merchant_id, connectors, access_token, @@ -856,6 +859,9 @@ pub fn generate_pre_risk_check_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -884,6 +890,7 @@ pub fn generate_pre_risk_check_response( error: None, raw_connector_request, raw_connector_response, + masked_connector_response, response_headers, } } @@ -906,6 +913,7 @@ pub fn generate_pre_risk_check_response( }), raw_connector_request, raw_connector_response, + masked_connector_response, response_headers, }, }; @@ -926,6 +934,9 @@ pub fn generate_post_risk_check_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -954,6 +965,7 @@ pub fn generate_post_risk_check_response( error: None, raw_connector_request, raw_connector_response, + masked_connector_response, response_headers, } } @@ -976,6 +988,7 @@ pub fn generate_post_risk_check_response( }), raw_connector_request, raw_connector_response, + masked_connector_response, response_headers, }, }; diff --git a/crates/types-traits/domain_types/src/lib.rs b/crates/types-traits/domain_types/src/lib.rs index bfd3047f09..b358140320 100644 --- a/crates/types-traits/domain_types/src/lib.rs +++ b/crates/types-traits/domain_types/src/lib.rs @@ -2,6 +2,8 @@ pub mod api; pub mod connector_flow; +#[cfg(feature = "connector-response-masking")] +pub mod connector_response_masking; pub mod connector_types; pub mod errors; pub mod frm; diff --git a/crates/types-traits/domain_types/src/merchant_authentication_flow_data.rs b/crates/types-traits/domain_types/src/merchant_authentication_flow_data.rs index 4d9b91b8b3..163af78de6 100644 --- a/crates/types-traits/domain_types/src/merchant_authentication_flow_data.rs +++ b/crates/types-traits/domain_types/src/merchant_authentication_flow_data.rs @@ -49,6 +49,8 @@ pub struct MerchantAuthenticationFlowData { // ── Observability ────────────────────────────────────────────────────── pub raw_connector_response: Option>, + /// Same body, values masked per the connector's config. Already sanitized — not a `Secret`. + pub masked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -68,6 +70,14 @@ impl RawConnectorRequestResponse for MerchantAuthenticationFlowData { fn get_raw_connector_response(&self) -> Option> { self.raw_connector_response.clone() } + + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; + } + + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() + } fn set_raw_connector_request(&mut self, r: Option>) { self.raw_connector_request = r; } diff --git a/crates/types-traits/domain_types/src/payouts/payouts_types.rs b/crates/types-traits/domain_types/src/payouts/payouts_types.rs index d88aaf33c4..49c948d84d 100644 --- a/crates/types-traits/domain_types/src/payouts/payouts_types.rs +++ b/crates/types-traits/domain_types/src/payouts/payouts_types.rs @@ -19,6 +19,8 @@ pub struct PayoutFlowData { pub connectors: Connectors, pub connector_request_reference_id: String, pub raw_connector_response: Option>, + /// Same body, values masked per the connector's config. Already sanitized — not a `Secret`. + pub masked_connector_response: Option, pub connector_response_headers: Option, pub raw_connector_request: Option>, pub access_token: Option, @@ -35,6 +37,14 @@ impl RawConnectorRequestResponse for PayoutFlowData { self.raw_connector_response.clone() } + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; + } + + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() + } + fn get_raw_connector_request(&self) -> Option> { self.raw_connector_request.clone() } diff --git a/crates/types-traits/domain_types/src/payouts/types.rs b/crates/types-traits/domain_types/src/payouts/types.rs index 5a1b001036..24ca87039e 100644 --- a/crates/types-traits/domain_types/src/payouts/types.rs +++ b/crates/types-traits/domain_types/src/payouts/types.rs @@ -30,6 +30,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -1513,6 +1514,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -1554,6 +1556,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -1595,6 +1598,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -1636,6 +1640,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, merchant_id, payout_id: value.merchant_quote_id.clone().unwrap_or_default(), connectors, @@ -1677,6 +1682,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -1718,6 +1724,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -1759,6 +1766,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -2224,6 +2232,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, diff --git a/crates/types-traits/domain_types/src/surcharge/surcharge_types.rs b/crates/types-traits/domain_types/src/surcharge/surcharge_types.rs index 2a544a9de1..69ca3a966d 100644 --- a/crates/types-traits/domain_types/src/surcharge/surcharge_types.rs +++ b/crates/types-traits/domain_types/src/surcharge/surcharge_types.rs @@ -13,6 +13,8 @@ pub struct SurchargeFlowData { pub connector_request_reference_id: String, pub connectors: Connectors, pub raw_connector_response: Option>, + /// Same body, values masked per the connector's config. Already sanitized — not a `Secret`. + pub masked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -26,6 +28,14 @@ impl RawConnectorRequestResponse for SurchargeFlowData { self.raw_connector_response.clone() } + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; + } + + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() + } + fn get_raw_connector_request(&self) -> Option> { self.raw_connector_request.clone() } diff --git a/crates/types-traits/domain_types/src/surcharge/types.rs b/crates/types-traits/domain_types/src/surcharge/types.rs index a4af60e402..1e65f0e8bc 100644 --- a/crates/types-traits/domain_types/src/surcharge/types.rs +++ b/crates/types-traits/domain_types/src/surcharge/types.rs @@ -33,6 +33,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, merchant_id, connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_surcharge_id, @@ -204,6 +205,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, merchant_id, connector_request_reference_id: extract_connector_request_reference_id(&Some( value.event_id, diff --git a/crates/types-traits/domain_types/src/types.rs b/crates/types-traits/domain_types/src/types.rs index 5525bbdb68..0a1d484e1f 100644 --- a/crates/types-traits/domain_types/src/types.rs +++ b/crates/types-traits/domain_types/src/types.rs @@ -5018,6 +5018,7 @@ impl .transpose()?; Ok(Self { + masked_connector_response: None, merchant_id: merchant_id_from_header, // Prefer caller's per-request `merchant_request_id`; fall back to the historical `merchant_access_token_id`. connector_request_reference_id: extract_connector_request_reference_id( @@ -5106,6 +5107,7 @@ impl ForeignTryFrom<(PaymentServiceAuthorizeRequest, Connectors, &MaskedMetadata .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5220,6 +5222,7 @@ impl ForeignTryFrom<(AuthorizationRequest, Connectors, &MaskedMetadata)> for Pay .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5308,6 +5311,7 @@ impl ForeignTryFrom<(SetupRecurringRequest, Connectors, &MaskedMetadata)> for Pa .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5422,6 +5426,7 @@ impl })?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5505,6 +5510,7 @@ impl .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5594,6 +5600,7 @@ impl ForeignTryFrom<(PaymentServiceVoidRequest, Connectors, &MaskedMetadata)> fo .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5677,6 +5684,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -6050,6 +6058,9 @@ pub fn generate_create_order_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -6080,6 +6091,7 @@ pub fn generate_create_order_response( merchant_order_id: None, raw_connector_request, raw_connector_response, + masked_connector_response, raw_connector_status, session_data: grpc_session_data, } @@ -6108,6 +6120,7 @@ pub fn generate_create_order_response( merchant_order_id: None, raw_connector_request, raw_connector_response, + masked_connector_response, raw_connector_status, session_data: None, }, @@ -6141,6 +6154,9 @@ pub fn generate_payment_method_eligibility_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -6154,6 +6170,7 @@ pub fn generate_payment_method_eligibility_response( error_info: None, raw_connector_request, raw_connector_response, + masked_connector_response, response_headers, }), Err(err) => Ok(PaymentMethodServiceEligibilityResponse { @@ -6175,6 +6192,7 @@ pub fn generate_payment_method_eligibility_response( }), raw_connector_request, raw_connector_response, + masked_connector_response, response_headers, }), } @@ -6346,6 +6364,9 @@ pub fn generate_payment_authorize_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -6443,6 +6464,7 @@ pub fn generate_payment_authorize_response( status: grpc_status as i32, error: None, raw_connector_response, + masked_connector_response, raw_connector_request, status_code: status_code as u32, response_headers, @@ -6515,6 +6537,7 @@ pub fn generate_payment_authorize_response( status_code: err.status_code as u32, response_headers, raw_connector_response, + masked_connector_response, raw_connector_request, connector_feature_data: None, state, @@ -7453,6 +7476,9 @@ pub fn generate_payment_void_response( raw_connector_response: router_data_v2 .resource_common_data .get_raw_connector_response(), + masked_connector_response: router_data_v2 + .resource_common_data + .get_masked_connector_response(), state, mandate_reference: mandate_reference_grpc, mandate_reference_details, @@ -7501,6 +7527,9 @@ pub fn generate_payment_void_response( raw_connector_response: router_data_v2 .resource_common_data .get_raw_connector_response(), + masked_connector_response: router_data_v2 + .resource_common_data + .get_masked_connector_response(), error: Some(grpc_api_types::payments::ErrorInfo { unified_details: None, connector_details: Some(grpc_api_types::payments::ConnectorErrorDetails { @@ -7562,6 +7591,9 @@ pub fn generate_payment_void_post_capture_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_status = router_data_v2 .resource_common_data @@ -7603,6 +7635,7 @@ pub fn generate_payment_void_post_capture_response( .get_connector_response_headers_as_map(), raw_connector_request, raw_connector_response, + masked_connector_response, raw_connector_status, }) } @@ -7636,6 +7669,7 @@ pub fn generate_payment_void_post_capture_response( .get_connector_response_headers_as_map(), raw_connector_request, raw_connector_response, + masked_connector_response, raw_connector_status, }) } @@ -7682,6 +7716,7 @@ pub fn generate_payment_void_post_capture_response( .get_connector_response_headers_as_map(), raw_connector_request, raw_connector_response, + masked_connector_response, raw_connector_status, }) } @@ -7793,6 +7828,9 @@ pub fn generate_payment_sync_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); // Create state if either access token or connector customer is available let state = if router_data_v2.resource_common_data.access_token.is_some() @@ -7920,6 +7958,7 @@ pub fn generate_payment_sync_response( metadata: None, status_code: status_code as u32, raw_connector_response, + masked_connector_response, response_headers: router_data_v2 .resource_common_data .get_connector_response_headers_as_map(), @@ -8035,6 +8074,7 @@ pub fn generate_payment_sync_response( metadata: None, status_code: status_code as u32, raw_connector_response, + masked_connector_response, response_headers: router_data_v2 .resource_common_data .get_connector_response_headers_as_map(), @@ -8128,6 +8168,7 @@ pub fn generate_payment_sync_response( merchant_order_id: None, metadata: None, raw_connector_response, + masked_connector_response, status_code: e.status_code as u32, response_headers: router_data_v2 .resource_common_data @@ -8235,6 +8276,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, connector_request_reference_id: extract_connector_request_reference_id( @@ -8286,6 +8328,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, status: common_enums::RefundStatus::Success, @@ -8350,6 +8393,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; let refund_id = value.merchant_refund_id.clone(); Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, connector_request_reference_id: extract_connector_request_reference_id(&refund_id), @@ -8668,6 +8712,7 @@ impl ), ) -> Result> { Ok(Self { + masked_connector_response: None, dispute_id: None, connectors, connector_dispute_id: value.dispute_id, @@ -8699,6 +8744,7 @@ impl ), ) -> Result> { Ok(Self { + masked_connector_response: None, connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_dispute_id.clone(), ), @@ -8793,6 +8839,7 @@ impl ), ) -> Result> { Ok(Self { + masked_connector_response: None, dispute_id: None, connectors, connector_dispute_id: value.dispute_id, @@ -8822,6 +8869,7 @@ impl ), ) -> Result> { Ok(Self { + masked_connector_response: None, connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_dispute_id, ), @@ -8844,6 +8892,9 @@ pub fn generate_refund_sync_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data @@ -8891,6 +8942,7 @@ pub fn generate_refund_sync_response( metadata: None, refund_metadata: None, raw_connector_response, + masked_connector_response, status_code: response.status_code as u32, response_headers, state: None, @@ -8941,6 +8993,7 @@ pub fn generate_refund_sync_response( customer_name: None, email: None, raw_connector_response, + masked_connector_response, merchant_order_id: None, metadata: None, refund_metadata: None, @@ -9053,6 +9106,7 @@ impl ForeignTryFrom for PaymentServiceGetResponse { metadata: None, status_code: value.status_code as u32, raw_connector_response: None, + masked_connector_response: None, response_headers, state: None, raw_connector_request: None, @@ -9246,6 +9300,9 @@ pub fn generate_void_post_refund_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -9299,6 +9356,7 @@ pub fn generate_void_post_refund_response( metadata: None, refund_metadata: None, raw_connector_response, + masked_connector_response, status_code: response.status_code as u32, response_headers, state: Some(ConnectorState { @@ -9350,6 +9408,7 @@ pub fn generate_void_post_refund_response( customer_name: None, email: None, raw_connector_response, + masked_connector_response, merchant_order_id: None, metadata: None, refund_metadata: None, @@ -9435,6 +9494,7 @@ impl }); Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -9547,6 +9607,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -9636,6 +9697,7 @@ impl ForeignTryFrom for RefundResponse { issuer_details: None, }), raw_connector_response: None, + masked_connector_response: None, refund_amount: None, payment_amount: None, refund_reason: None, @@ -10294,6 +10356,9 @@ pub fn generate_refund_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); // RefundFlowData doesn't have access_token field, so no state to return let state = None; @@ -10340,6 +10405,7 @@ pub fn generate_refund_response( email: None, merchant_order_id: None, raw_connector_response, + masked_connector_response, metadata: None, refund_metadata: None, status_code: response.status_code as u32, @@ -10391,6 +10457,7 @@ pub fn generate_refund_response( customer_name: None, email: None, raw_connector_response, + masked_connector_response, merchant_order_id: None, metadata: None, refund_metadata: None, @@ -10612,6 +10679,7 @@ impl .map(|m| ForeignTryFrom::foreign_try_from((m, "feature data"))) .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "PAYMENT_ID".to_string(), @@ -10681,6 +10749,7 @@ impl }; Ok(Self { + masked_connector_response: None, merchant_id: merchant_id_from_header, connector_request_reference_id: value.merchant_client_session_id, connector_feature_data: value @@ -10749,6 +10818,9 @@ pub fn generate_payment_incremental_authorization_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); match router_data_v2.response { Ok(response) => match response { @@ -10771,6 +10843,7 @@ pub fn generate_payment_incremental_authorization_response( state, raw_connector_request, raw_connector_response, + masked_connector_response, }) } _ => Err(report!(ConnectorError::UnexpectedResponseError { @@ -10803,6 +10876,7 @@ pub fn generate_payment_incremental_authorization_response( state, raw_connector_request, raw_connector_response, + masked_connector_response, }), } } @@ -10851,6 +10925,9 @@ pub fn generate_payment_capture_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let connector_response = router_data_v2 .resource_common_data @@ -10920,6 +10997,7 @@ pub fn generate_payment_capture_response( state, raw_connector_request, raw_connector_response, + masked_connector_response, incremental_authorization_allowed, mandate_reference: mandate_reference_grpc, mandate_reference_details, @@ -10984,6 +11062,7 @@ pub fn generate_payment_capture_response( state, raw_connector_request, raw_connector_response, + masked_connector_response, incremental_authorization_allowed: None, mandate_reference: None, mandate_reference_details: None, @@ -11062,6 +11141,7 @@ impl .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -11163,6 +11243,7 @@ impl .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -11931,6 +12012,9 @@ pub fn generate_setup_mandate_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let connector_response = router_data_v2 .resource_common_data @@ -12062,6 +12146,7 @@ pub fn generate_setup_mandate_response( state, raw_connector_request, raw_connector_response, + masked_connector_response, connector_response, connector_feature_data: convert_connector_metadata_to_secret_string( connector_metadata, @@ -12129,6 +12214,7 @@ pub fn generate_setup_mandate_response( state, raw_connector_request, raw_connector_response, + masked_connector_response, connector_response, connector_feature_data: None, captured_amount: None, @@ -12146,6 +12232,7 @@ impl ForeignTryFrom<(DisputeServiceDefendRequest, Connectors)> for DisputeFlowDa (value, connectors): (DisputeServiceDefendRequest, Connectors), ) -> Result> { Ok(Self { + masked_connector_response: None, dispute_id: Some(value.dispute_id.clone()), connectors, connector_dispute_id: value.dispute_id, @@ -12169,6 +12256,7 @@ impl ForeignTryFrom<(DisputeServiceDefendRequest, Connectors, &MaskedMetadata)> (value, connectors, _metadata): (DisputeServiceDefendRequest, Connectors, &MaskedMetadata), ) -> Result> { Ok(Self { + masked_connector_response: None, connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_dispute_id, ), @@ -12390,6 +12478,7 @@ impl .and_then(|state| state.connector_customer_id.clone()); Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -12996,6 +13085,7 @@ impl .transpose()?; Ok(Self { + masked_connector_response: None, merchant_id: merchant_id_from_header, connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_server_session_id.clone(), @@ -13142,6 +13232,7 @@ impl .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13329,6 +13420,7 @@ impl .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13446,6 +13538,7 @@ impl .map(ServerAuthenticationTokenResponseData::foreign_try_from) .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13554,6 +13647,7 @@ impl .map(ServerAuthenticationTokenResponseData::foreign_try_from) .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13656,6 +13750,7 @@ impl ), ) -> Result> { Ok(Self { + masked_connector_response: None, connectors, connector_request_reference_id: String::new(), raw_connector_response: None, @@ -13679,6 +13774,7 @@ impl ForeignTryFrom error: None, response_headers: std::collections::HashMap::new(), raw_connector_response: None, + masked_connector_response: None, raw_connector_request: None, }) } @@ -13763,6 +13859,7 @@ impl }), response_headers: std::collections::HashMap::new(), raw_connector_response: None, + masked_connector_response: None, raw_connector_request: None, } } @@ -13848,6 +13945,7 @@ impl .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13935,6 +14033,7 @@ impl ) -> Result> { let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -14299,6 +14398,9 @@ pub fn generate_repeat_payment_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data @@ -14374,6 +14476,7 @@ pub fn generate_repeat_payment_response( mandate_reference_details, status_code: status_code as u32, raw_connector_response, + masked_connector_response, response_headers: router_data_v2 .resource_common_data .get_connector_response_headers_as_map(), @@ -14436,6 +14539,7 @@ pub fn generate_repeat_payment_response( connector_feature_data: None, mandate_reference_details: None, raw_connector_response: None, + masked_connector_response: None, status_code: err.status_code as u32, response_headers: router_data_v2 .resource_common_data @@ -14811,6 +14915,9 @@ pub fn generate_payment_sdk_session_token_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); match transaction_response { Ok(response) => match response { @@ -14895,6 +15002,7 @@ pub fn generate_payment_sdk_session_token_response( session_data: grpc_session_data, error: None, raw_connector_response, + masked_connector_response, status_code: status_code as u32, raw_connector_request, }, @@ -14924,6 +15032,7 @@ pub fn generate_payment_sdk_session_token_response( issuer_details: Some(grpc_payment_types::IssuerErrorDetails::from(&e)), }), raw_connector_response, + masked_connector_response, status_code: e.status_code as u32, raw_connector_request, }, @@ -15912,6 +16021,7 @@ impl .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -16014,6 +16124,7 @@ impl .transpose()?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -16124,6 +16235,7 @@ impl .map(|s| s.to_string()); Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -16209,6 +16321,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "MANDATE_REVOKE_ID".to_string(), @@ -16301,6 +16414,10 @@ impl ForeignTryFrom<(bool, RedirectDetailsResponse)> raw_connector_response: redirect_details_response .raw_connector_response .map(|response| response.into()), + // This response is assembled from a redirect verification rather than a connector + // HTTP call, so it never passes through `handle_connector_response` and there is no + // masked body to carry. + masked_connector_response: None, }) } } @@ -16322,6 +16439,9 @@ pub fn generate_payment_pre_authenticate_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let response_headers = router_data_v2 .resource_common_data .get_connector_response_headers_as_map(); @@ -16474,6 +16594,7 @@ pub fn generate_payment_pre_authenticate_response( status: grpc_status.into(), error: None, raw_connector_response, + masked_connector_response, status_code: status_code.into(), response_headers, network_transaction_id: None, @@ -16522,6 +16643,7 @@ pub fn generate_payment_pre_authenticate_response( status_code: err.status_code.into(), response_headers, raw_connector_response, + masked_connector_response, connector_feature_data: None, state: None, authentication_data: None, @@ -16548,6 +16670,9 @@ pub fn generate_payment_authenticate_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let response_headers = router_data_v2 .resource_common_data .get_connector_response_headers_as_map(); @@ -16679,6 +16804,7 @@ pub fn generate_payment_authenticate_response( status: grpc_status.into(), error: None, raw_connector_response, + masked_connector_response, raw_connector_status, status_code: status_code.into(), response_headers, @@ -16727,6 +16853,7 @@ pub fn generate_payment_authenticate_response( }), status_code: err.status_code.into(), raw_connector_response, + masked_connector_response, raw_connector_status, response_headers, connector_feature_data: None, @@ -16754,6 +16881,9 @@ pub fn generate_payment_post_authenticate_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let response_headers = router_data_v2 .resource_common_data .get_connector_response_headers_as_map(); @@ -16786,6 +16916,7 @@ pub fn generate_payment_post_authenticate_response( status: grpc_status.into(), error: None, raw_connector_response, + masked_connector_response, raw_connector_status, status_code: status_code.into(), response_headers, @@ -16835,6 +16966,7 @@ pub fn generate_payment_post_authenticate_response( status_code: err.status_code.into(), response_headers, raw_connector_response, + masked_connector_response, raw_connector_status, connector_feature_data: None, state: None, @@ -17283,6 +17415,9 @@ pub fn generate_mandate_revoke_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17312,6 +17447,7 @@ pub fn generate_mandate_revoke_response( network_transaction_id: None, merchant_revoke_id: None, raw_connector_response, + masked_connector_response, raw_connector_request, }), Err(e) => Ok(RecurringPaymentServiceRevokeResponse { @@ -17332,6 +17468,7 @@ pub fn generate_mandate_revoke_response( network_transaction_id: None, merchant_revoke_id: e.connector_transaction_id, raw_connector_response, + masked_connector_response, raw_connector_request, }), } @@ -17520,6 +17657,9 @@ pub fn generate_recharge_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17544,6 +17684,7 @@ pub fn generate_recharge_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, + masked_connector_response, raw_connector_request, }) } @@ -17573,6 +17714,7 @@ pub fn generate_recharge_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, + masked_connector_response, raw_connector_request, }) } @@ -17591,6 +17733,9 @@ pub fn generate_create_payment_method_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17612,6 +17757,7 @@ pub fn generate_create_payment_method_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, + masked_connector_response, raw_connector_request, }), Err(error) => Ok(PaymentMethodServiceCreateResponse { @@ -17636,6 +17782,7 @@ pub fn generate_create_payment_method_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, + masked_connector_response, raw_connector_request, }), } @@ -17659,6 +17806,9 @@ pub fn generate_refresh_payment_method_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17684,6 +17834,7 @@ pub fn generate_refresh_payment_method_response( })?; proto.response_headers = response_headers; proto.raw_connector_response = raw_connector_response; + proto.masked_connector_response = masked_connector_response; proto.raw_connector_request = raw_connector_request; Ok(proto) } @@ -17696,6 +17847,7 @@ pub fn generate_refresh_payment_method_response( )); proto.response_headers = response_headers; proto.raw_connector_response = raw_connector_response; + proto.masked_connector_response = masked_connector_response; proto.raw_connector_request = raw_connector_request; Ok(proto) } @@ -17714,6 +17866,9 @@ pub fn generate_get_payment_method_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let masked_connector_response = router_data_v2 + .resource_common_data + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17735,6 +17890,7 @@ pub fn generate_get_payment_method_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, + masked_connector_response, raw_connector_request, }), Err(error) => Ok(PaymentMethodServiceGetResponse { @@ -17759,6 +17915,7 @@ pub fn generate_get_payment_method_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, + masked_connector_response, raw_connector_request, }), } diff --git a/crates/types-traits/grpc-api-types/proto/frm.proto b/crates/types-traits/grpc-api-types/proto/frm.proto index f0a8e086c2..9cfd095464 100644 --- a/crates/types-traits/grpc-api-types/proto/frm.proto +++ b/crates/types-traits/grpc-api-types/proto/frm.proto @@ -91,6 +91,10 @@ message FrmServicePreRiskCheckResponse { // Raw response from the connector for debugging optional SecretString raw_connector_response = 8; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 10; // Response headers from the connector map response_headers = 9; @@ -160,6 +164,10 @@ message FrmServicePostRiskCheckResponse { // Raw response from the connector for debugging optional SecretString raw_connector_response = 8; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 10; // Response headers from the connector map response_headers = 9; diff --git a/crates/types-traits/grpc-api-types/proto/payment.proto b/crates/types-traits/grpc-api-types/proto/payment.proto index b1da4e4f3c..ee04015c39 100644 --- a/crates/types-traits/grpc-api-types/proto/payment.proto +++ b/crates/types-traits/grpc-api-types/proto/payment.proto @@ -2642,6 +2642,10 @@ message PaymentServiceAuthorizeResponse { // Raw Response/Request for debugging optional SecretString raw_connector_response = 11; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 25; optional SecretString raw_connector_request = 12; // Payment Details @@ -2780,6 +2784,10 @@ message PaymentServiceGetResponse { // Raw Response/Request for debugging optional SecretString raw_connector_response = 24; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 38; optional SecretString raw_connector_request = 25; // Redirection and Transaction Details @@ -2879,6 +2887,10 @@ message PaymentServiceVoidResponse { // Raw Response/Request for debugging optional SecretString raw_connector_response = 13; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 17; // Mandate reference details returned by the connector for future recurring payments. optional MandateReferenceDetails mandate_reference_details = 14; @@ -2931,6 +2943,10 @@ message PaymentServiceReverseResponse { // Raw Request and Response for debugging optional SecretString raw_connector_request = 7; optional SecretString raw_connector_response = 8; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 11; // The connector's reported status for this response. optional RawConnectorStatus raw_connector_status = 9; @@ -3052,6 +3068,10 @@ message MerchantAuthenticationServiceCreateClientAuthenticationTokenResponse { optional ErrorInfo error = 2; uint32 status_code = 3; optional SecretString raw_connector_response = 4; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 6; optional SecretString raw_connector_request = 5; } @@ -3142,6 +3162,10 @@ message PaymentServiceCaptureResponse { // Raw Response for debugging optional SecretString raw_connector_response = 16; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 19; // Another reference for the connector's transaction, returned in the connector's response. // Distinct from connector_transaction_id — e.g. Stripe charge ID (ch_xxx), order codes, invoice numbers. @@ -3196,6 +3220,10 @@ message PaymentServiceCreateOrderResponse { // Raw Request/Response for debugging optional SecretString raw_connector_request = 7; optional SecretString raw_connector_response = 8; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 11; // SDK Session Data optional ClientAuthenticationTokenData session_data = @@ -3300,6 +3328,10 @@ message RefundResponse { // Raw Response/Request for debugging optional SecretString raw_connector_response = 21; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 27; optional SecretString raw_connector_request = 22; // Connector/domain state metadata to persist across calls. @@ -3552,6 +3584,10 @@ message PaymentServiceSetupRecurringResponse { // Raw Response for debugging optional SecretString raw_connector_response = 18; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 20; } // Request message for repeat payment (MIT - Merchant Initiated Transaction) @@ -3674,6 +3710,10 @@ message RecurringPaymentServiceChargeResponse { // Raw Response/Request for debugging optional SecretString raw_connector_response = 11; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 21; optional SecretString raw_connector_request = 12; // Payment Details @@ -3728,6 +3768,10 @@ message RecurringPaymentServiceRevokeResponse { // Raw Response/Request for debugging optional SecretString raw_connector_response = 7; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 9; optional SecretString raw_connector_request = 8; } @@ -3800,6 +3844,10 @@ message PaymentMethodAuthenticationServicePreAuthenticateResponse { // Raw Response for debugging optional SecretString raw_connector_response = 11; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 13; // Authentication Results optional AuthenticationData authentication_data = 12; } @@ -3877,6 +3925,10 @@ message PaymentMethodAuthenticationServiceAuthenticateResponse { // Raw Response for debugging optional SecretString raw_connector_response = 12; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 14; // The connector's reported status (code/message/reason). optional RawConnectorStatus raw_connector_status = 13; @@ -3950,6 +4002,10 @@ message PaymentMethodAuthenticationServicePostAuthenticateResponse { // Raw Response for debugging optional SecretString raw_connector_response = 13; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 15; // The connector's reported status for this response. optional RawConnectorStatus raw_connector_status = 14; @@ -3990,6 +4046,10 @@ message PaymentServiceIncrementalAuthorizationResponse { // Raw Request and Response for debugging optional SecretString raw_connector_request = 7; optional SecretString raw_connector_response = 8; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 9; } // IDs extracted from a payment webhook. @@ -4148,6 +4208,10 @@ message PaymentServiceVerifyRedirectResponseResponse { // Raw Response optional SecretString raw_connector_response = 7; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 8; } // ============================================================================ @@ -4389,6 +4453,10 @@ message PaymentMethodServiceCreateResponse { // Raw Response/Request for debugging optional SecretString raw_connector_response = 9; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 11; optional SecretString raw_connector_request = 10; } @@ -4423,6 +4491,10 @@ message PaymentMethodServiceGetResponse { // Raw Response/Request for debugging optional SecretString raw_connector_response = 9; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 11; optional SecretString raw_connector_request = 10; } @@ -4439,6 +4511,10 @@ message PaymentMethodServiceRefreshResponse { // Raw Response/Request for debugging optional SecretString raw_connector_response = 5; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 7; optional SecretString raw_connector_request = 6; } @@ -4592,6 +4668,10 @@ message PaymentMethodServiceRechargeResponse { uint32 status_code = 8; // HTTP status code from the connector map response_headers = 9; // Response headers from the connector optional SecretString raw_connector_response = 10; // Raw response from the connector for debugging + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 12; optional SecretString raw_connector_request = 11; // Raw request sent to the connector for debugging } @@ -6139,6 +6219,10 @@ message PaymentMethodServiceEligibilityResponse { // Raw response received from the connector (for debugging). optional SecretString raw_connector_response = 5; + // Selectively-masked view of the connector response: every key preserved, + // values masked unless the connector's configured list names them. Already + // sanitized, hence `string` and not `SecretString`. + optional string masked_connector_response = 7; // Response headers from the connector. map response_headers = 6;