From 6e2166f328f030aa17dd2ed8e569bea357c24c29 Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Tue, 4 Aug 2026 13:49:41 +0530 Subject: [PATCH 01/14] feat(core): add unmasked_connector_response with per-connector key allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `raw_connector_response` is an `Option>`, so `masked_serialize` collapses the entire gateway reply into a single placeholder (`*** alloc::string::String ***`). That leaves only two options: expose the whole body, which carries PAN/CVV/tokens, or see nothing at all. Fields a transformer never modelled are invisible either way, since `response.body` only ever shows the typed struct. Add a sibling field carrying the same body with every key preserved and every value masked unless that connector's configured list names it. It is a plain `String`, not a `Secret`, because it is already sanitized — wrapping it would reproduce the bug being fixed. - masking happens during serialization rather than by mutating the parsed tree. A walk-and-overwrite pass cannot see scalars inside arrays (they arrive with no key in scope), so `{"tags":["4111111111111111"]}` would leak. Carrying the parent-key decision into elements makes that impossible by construction, and drops a pass plus one allocation per masked field. - output keeps the input format: JSON in/JSON out, XML in/XML out (copied event by event so namespaces, attributes and ordering survive), form in/form out. - config is per-connector only. A field name safe on one gateway is not necessarily safe on another, so there is deliberately no global list. A connector with no entry gets every value masked with every key still visible, which is both the safe default and how you discover names to configure. - keyed by `ConnectorEnum`, so an unknown connector name aborts startup naming the bad key instead of silently masking everything. Keys parse via `FromStr`, not the serde derive, because the config crate lowercases env-var keys. - gated by its own `enabled` flag, independent of `return_raw_connector_data`, so production can keep raw capture off while retaining the safe view. That is the main reason the feature exists. Verified against the Adyen sandbox: listed fields visible, all 51 `additionalData` keys present with masked values, no PAN or CVC anywhere in the logs, and the field still populated on the 4xx path. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 2 + config/development.toml | 19 + config/production.toml | 19 + config/sandbox.toml | 19 + .../common/external-services/src/service.rs | 72 ++ crates/common/ucs_env/src/configs.rs | 7 + .../grpc-server/src/server/disputes.rs | 2 + .../grpc-server/src/server/events.rs | 7 + .../grpc-server/src/server/payments.rs | 12 + crates/grpc-server/grpc-server/src/utils.rs | 2 + crates/types-traits/domain_types/Cargo.toml | 7 +- .../src/connector_response_masking.rs | 837 ++++++++++++++++++ .../domain_types/src/connector_types.rs | 57 ++ .../domain_types/src/frm/frm_types.rs | 10 + .../domain_types/src/frm/types.rs | 13 + crates/types-traits/domain_types/src/lib.rs | 1 + .../src/merchant_authentication_flow_data.rs | 10 + .../domain_types/src/payouts/payouts_types.rs | 10 + .../domain_types/src/payouts/types.rs | 9 + .../src/surcharge/surcharge_types.rs | 10 + .../domain_types/src/surcharge/types.rs | 2 + crates/types-traits/domain_types/src/types.rs | 155 ++++ .../grpc-api-types/proto/frm.proto | 8 + .../grpc-api-types/proto/payment.proto | 84 ++ 24 files changed, 1373 insertions(+), 1 deletion(-) create mode 100644 crates/types-traits/domain_types/src/connector_response_masking.rs 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/config/development.toml b/config/development.toml index 46441cc70c..47d06216c4 100644 --- a/config/development.toml +++ b/config/development.toml @@ -236,3 +236,22 @@ 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 +# `unmasked_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. +[connector_response_masking] +enabled = true +max_bytes = 8192 + +# Per-connector unmask lists, comma-separated and case-insensitive. +# A connector with no entry gets every value masked (keys still visible). +# Never list card/CVV/token-like keys: those stay masked regardless. +[connector_response_masking.connector_keys] +paysafe = "id,status,txntime,merchantrefnum,authcode,currencycode,amount,errorcode,message" +adyen = "pspreference,resultcode,merchantreference,refusalreason,eventcode,success" +cybersource = "id,status,submittimeutc,reconciliationid,clientreferenceinformation,code,message,reason" +stripe = "id,object,status,amount,currency,created,livemode,failure_code,failure_message" +razorpay = "id,entity,status,amount,currency,method,created_at,error_code,error_description" +checkout = "id,status,amount,currency,response_code,response_summary,processed_on,reference" diff --git a/config/production.toml b/config/production.toml index c170e7bb2b..36d8f64546 100644 --- a/config/production.toml +++ b/config/production.toml @@ -177,3 +177,22 @@ 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 +# `unmasked_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. +[connector_response_masking] +enabled = true +max_bytes = 8192 + +# Per-connector unmask lists, comma-separated and case-insensitive. +# A connector with no entry gets every value masked (keys still visible). +# Never list card/CVV/token-like keys: those stay masked regardless. +[connector_response_masking.connector_keys] +paysafe = "id,status,txntime,merchantrefnum,authcode,currencycode,amount,errorcode,message" +adyen = "pspreference,resultcode,merchantreference,refusalreason,eventcode,success" +cybersource = "id,status,submittimeutc,reconciliationid,clientreferenceinformation,code,message,reason" +stripe = "id,object,status,amount,currency,created,livemode,failure_code,failure_message" +razorpay = "id,entity,status,amount,currency,method,created_at,error_code,error_description" +checkout = "id,status,amount,currency,response_code,response_summary,processed_on,reference" diff --git a/config/sandbox.toml b/config/sandbox.toml index dd5912e0dc..e24183b48f 100644 --- a/config/sandbox.toml +++ b/config/sandbox.toml @@ -178,3 +178,22 @@ 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 +# `unmasked_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. +[connector_response_masking] +enabled = true +max_bytes = 8192 + +# Per-connector unmask lists, comma-separated and case-insensitive. +# A connector with no entry gets every value masked (keys still visible). +# Never list card/CVV/token-like keys: those stay masked regardless. +[connector_response_masking.connector_keys] +paysafe = "id,status,txntime,merchantrefnum,authcode,currencycode,amount,errorcode,message" +adyen = "pspreference,resultcode,merchantreference,refusalreason,eventcode,success" +cybersource = "id,status,submittimeutc,reconciliationid,clientreferenceinformation,code,message,reason" +stripe = "id,object,status,amount,currency,created,livemode,failure_code,failure_message" +razorpay = "id,entity,status,amount,currency,method,created_at,error_code,error_description" +checkout = "id,status,amount,currency,response_code,response_summary,processed_on,reference" diff --git a/crates/common/external-services/src/service.rs b/crates/common/external-services/src/service.rs index 408925938e..a1dae16d1f 100644 --- a/crates/common/external-services/src/service.rs +++ b/crates/common/external-services/src/service.rs @@ -327,6 +327,49 @@ 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. +fn record_unmasked_connector_response( + resource_common_data: &mut ResourceCommonData, + body: &Response, + connector_name: &str, + config: &domain_types::connector_response_masking::ConnectorResponseMaskingConfig, +) where + ResourceCommonData: RawConnectorRequestResponse, +{ + use std::str::FromStr; + + // `connector_name` came from `ConnectorEnum::get_connector_name()`, and the enum derives + // `EnumString` with snake_case, so this always round-trips. + let Ok(connector) = domain_types::connector_types::ConnectorEnum::from_str(connector_name) + else { + return; + }; + + // Looked up by name rather than via `http::header::CONTENT_TYPE`: this `HeaderMap` comes + // from reqwest 0.11 (http 0.2), which does not share constants with 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, + config, + ); + + if let Some(masked) = masked.as_deref() { + tracing::Span::current().record("response.unmasked_body", tracing::field::display(masked)); + } + + resource_common_data.set_unmasked_connector_response(masked); +} + /// Handles the connector response, processing both successful and error responses #[allow(clippy::too_many_arguments)] pub fn handle_connector_response( @@ -367,6 +410,18 @@ where .set_connector_response_headers(body.headers.clone()); } + // Independent of `return_raw_connector_data`: this view is already sanitized. + if let Some(params) = + event_params.filter(|p| p.connector_response_masking.enabled) + { + record_unmasked_connector_response( + &mut updated_router_data.resource_common_data, + &body, + params.connector_name, + params.connector_response_masking, + ); + } + let handle_response_result = connector.handle_response_v2( &updated_router_data, event.as_deref_mut(), @@ -424,6 +479,18 @@ where .set_connector_response_headers(body.headers.clone()); } + // A 4xx/5xx body is exactly when the masked view is most useful. + if let Some(params) = + event_params.filter(|p| p.connector_response_masking.enabled) + { + record_unmasked_connector_response( + &mut updated_router_data.resource_common_data, + &body, + params.connector_name, + params.connector_response_masking, + ); + } + let error_response = match body.status_code { 500..=511 => connector.get_5xx_error_response( body.clone(), @@ -533,6 +600,10 @@ 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 `unmasked_connector_response`. Gated by its own + /// `enabled` flag, deliberately independent of `return_raw_connector_data`. + pub connector_response_masking: + &'a domain_types::connector_response_masking::ConnectorResponseMaskingConfig, pub connector_latency: ConnectorLatencyTracker, } @@ -546,6 +617,7 @@ pub struct EventProcessingParams<'a> { request.url = Empty, request.method = Empty, response.body = Empty, + response.unmasked_body = Empty, response.headers = Empty, response.error_message = Empty, response.status_code = Empty, diff --git a/crates/common/ucs_env/src/configs.rs b/crates/common/ucs_env/src/configs.rs index 7d9afce9ab..d6ff5013c4 100644 --- a/crates/common/ucs_env/src/configs.rs +++ b/crates/common/ucs_env/src/configs.rs @@ -11,6 +11,9 @@ use common_utils::{ SuperpositionConfig, }; use domain_types::{ + connector_response_masking::{ + ConnectorResponseMaskingConfig, ConnectorResponseMaskingConfigPatch, + }, connector_types::ConnectorEnum, types::{Connectors, ConnectorsPatch, ProxyConfig, ProxyConfigPatch}, }; @@ -34,6 +37,10 @@ pub struct Config { pub lineage: LineageConfig, #[serde(default)] pub unmasked_headers: HeaderMaskingConfig, + /// Per-connector key lists controlling which response values stay visible in + /// `unmasked_connector_response`. + #[serde(default)] + pub connector_response_masking: ConnectorResponseMaskingConfig, #[serde(default)] pub test: TestConfig, #[serde(default)] diff --git a/crates/grpc-server/grpc-server/src/server/disputes.rs b/crates/grpc-server/grpc-server/src/server/disputes.rs index 5c2eb10b94..210789f18d 100644 --- a/crates/grpc-server/grpc-server/src/server/disputes.rs +++ b/crates/grpc-server/grpc-server/src/server/disputes.rs @@ -184,6 +184,7 @@ impl DisputeService for Disputes { tenant_id: &tenant_id, merchant_id: merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + connector_response_masking: &config.connector_response_masking, connector_latency, }; @@ -424,6 +425,7 @@ impl DisputeService for Disputes { tenant_id: &tenant_id, merchant_id: merchant_id.as_str(), return_raw_connector_data: config.common.return_raw_connector_data, + 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..40ffa6b1c5 100644 --- a/crates/grpc-server/grpc-server/src/server/events.rs +++ b/crates/grpc-server/grpc-server/src/server/events.rs @@ -504,6 +504,7 @@ 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, + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -613,6 +614,7 @@ 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, + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -722,6 +724,7 @@ 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, + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -828,6 +831,7 @@ 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, + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -934,6 +938,7 @@ 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, + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -975,6 +980,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, + unmasked_connector_response: None, raw_connector_request: None, connector_response_headers: None, }; @@ -1029,6 +1035,7 @@ 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, + 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 a199f6d235..7c87de37ff 100644 --- a/crates/grpc-server/grpc-server/src/server/payments.rs +++ b/crates/grpc-server/grpc-server/src/server/payments.rs @@ -576,6 +576,7 @@ 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, + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -714,6 +715,7 @@ 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, + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -1128,6 +1130,7 @@ 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, + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -2540,6 +2543,7 @@ 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, + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -2661,6 +2665,7 @@ impl MerchantAuthentication { tenant_id: event_params.tenant_id, merchant_id: event_params.merchant_id, return_raw_connector_data: config.common.return_raw_connector_data, + connector_response_masking: &config.connector_response_masking, connector_latency: event_params.connector_latency.clone(), }; @@ -2802,6 +2807,7 @@ impl MerchantAuthentication { tenant_id: event_params.tenant_id, merchant_id: event_params.merchant_id, return_raw_connector_data: config.common.return_raw_connector_data, + connector_response_masking: &config.connector_response_masking, connector_latency: event_params.connector_latency.clone(), }; @@ -3332,6 +3338,7 @@ 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, + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -3604,6 +3611,9 @@ pub fn generate_mandate_revoke_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -3633,6 +3643,7 @@ pub fn generate_mandate_revoke_response( network_transaction_id: None, merchant_revoke_id: None, raw_connector_response, + unmasked_connector_response, raw_connector_request, }), Err(e) => Ok(RecurringPaymentServiceRevokeResponse { @@ -3653,6 +3664,7 @@ pub fn generate_mandate_revoke_response( network_transaction_id: None, merchant_revoke_id: e.connector_transaction_id, raw_connector_response, + unmasked_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 27821963d0..80b2c92de7 100644 --- a/crates/grpc-server/grpc-server/src/utils.rs +++ b/crates/grpc-server/grpc-server/src/utils.rs @@ -706,6 +706,7 @@ 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, + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), }; @@ -1067,6 +1068,7 @@ 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, + 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/types-traits/domain_types/Cargo.toml b/crates/types-traits/domain_types/Cargo.toml index a3ef2625cd..fc57fcc019 100644 --- a/crates/types-traits/domain_types/Cargo.toml +++ b/crates/types-traits/domain_types/Cargo.toml @@ -24,8 +24,9 @@ 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_json = { workspace = true, features = ["preserve_order"] } serde_urlencoded = "0.7" +quick-xml = { version = "0.31.0", features = ["serialize"] } error-stack = "0.4.1" base64 = "0.21" rand = "0.8.5" @@ -46,6 +47,10 @@ 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"] 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..06bab90619 --- /dev/null +++ b/crates/types-traits/domain_types/src/connector_response_masking.rs @@ -0,0 +1,837 @@ +//! Selective, per-connector masking of the raw connector response. +//! +//! `raw_connector_response` is a `Secret`, so any logger collapses the whole body into a +//! single placeholder. This module produces the sibling `unmasked_connector_response`: the same +//! body with **every key preserved** and **every value masked** unless that connector's configured +//! list names it. +//! +//! The output is emitted in the **same format** the gateway used — JSON in, JSON out; XML in, XML +//! out; form-encoded in, form-encoded out. The only thing the three paths share is the +//! per-connector key set. + +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::ConnectorEnum; + +/// Replacement written in place of a masked value. +pub const MASKED: &str = "***"; + +/// Default cap on the emitted string, in bytes. +const DEFAULT_MAX_BYTES: usize = 8192; + +/// Marker appended when the emitted string is capped. +const TRUNCATION_MARKER: &str = "…[truncated]"; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/// Per-connector configuration controlling which response keys keep their value. +/// +/// There is deliberately no global key list: a field name that is safe on one gateway is not +/// necessarily safe on another. A connector with no entry gets every value masked, with every key +/// still visible. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct ConnectorResponseMaskingConfig { + /// Whether to populate `unmasked_connector_response` at all. + pub enabled: bool, + + /// Cap on the emitted string in bytes. `0` means no cap. + pub max_bytes: usize, + + /// Connector -> comma-separated list of keys whose values stay visible. + /// + /// Keyed by [`ConnectorEnum`] so an unknown name in TOML or env aborts startup naming the bad + /// key, rather than silently masking everything. + /// + /// The *value* stays a comma-separated string because it is the only shape that can be set per + /// connector from the environment: env vars are always text, and the config crate only splits + /// a key it has been told about by literal path — which cannot be done for an open-ended set + /// of connectors. This is plain deserialized TOML, not a cache; nothing is derived ahead of + /// time. + #[serde( + deserialize_with = "deserialize_connector_keys", + serialize_with = "serialize_connector_keys" + )] + pub connector_keys: HashMap, +} + +/// Parse map keys through `ConnectorEnum`'s `FromStr` rather than its serde derive. +/// +/// `#[strum(serialize_all = "snake_case")]` governs `FromStr`/`Display`; serde would instead expect +/// the PascalCase variant names. Two reasons that matters: config files spell connectors in +/// lowercase, and the config crate lowercases environment-variable keys, so +/// `CS__…__CONNECTOR_KEYS__ADYEN` arrives as `adyen` and could never match `Adyen`. +/// +/// This is the same route `WebhookSourceVerificationCall` takes (`deserialize_hashset`). +fn deserialize_connector_keys<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + use std::str::FromStr; + + HashMap::::deserialize(deserializer)? + .into_iter() + .map(|(name, keys)| { + ConnectorEnum::from_str(&name.to_lowercase()) + .map(|connector| (connector, keys)) + .map_err(|_| D::Error::custom(format!("unknown connector `{name}`"))) + }) + .collect() +} + +/// Mirror of [`deserialize_connector_keys`] — emit the snake_case name, not the variant name. +fn serialize_connector_keys( + connector_keys: &HashMap, + serializer: S, +) -> Result +where + S: Serializer, +{ + connector_keys + .iter() + .map(|(connector, keys)| (connector.to_string(), keys)) + .collect::>() + .serialize(serializer) +} + +impl Default for ConnectorResponseMaskingConfig { + fn default() -> Self { + Self { + enabled: false, + max_bytes: DEFAULT_MAX_BYTES, + connector_keys: HashMap::new(), + } + } +} + +impl ConnectorResponseMaskingConfig { + /// Build the set of keys whose values stay visible for `connector`. + /// + /// Called once per request, for the single connector in play — a split plus a handful of small + /// allocations, against a gateway call measured in hundreds of milliseconds. Building on demand + /// rather than caching means a runtime config patch can never leave a stale set behind. + /// + /// A connector with no entry yields an empty set: every value masked, every key still visible. + pub fn keys_for(&self, connector: &ConnectorEnum) -> HashSet> { + self.connector_keys + .get(connector) + .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::max_bytes`]. + pub max_bytes: Option, + /// See [`ConnectorResponseMaskingConfig::connector_keys`]. + #[serde(default, deserialize_with = "deserialize_optional_connector_keys")] + pub connector_keys: Option>, +} + +fn deserialize_optional_connector_keys<'de, D>( + deserializer: D, +) -> Result>, 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(max_bytes) = patch.max_bytes { + self.max_bytes = max_bytes; + } + 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. + } +} + +// --------------------------------------------------------------------------- +// Key policy +// --------------------------------------------------------------------------- + +/// Keys never revealed whatever the configuration says, matched as a **substring** after stripping +/// non-alphanumerics — so `card_number`, `cardNumber`, `card-number` and `ssl_card_number` all +/// match `cardnumber`. Every entry here must be safe to match mid-word. +const ALWAYS_MASKED_SUBSTRING: &[&str] = &[ + "cardnumber", + "cardnum", + "accountnumber", + "cvv", + "cvc", + "cvn", + "expmonth", + "expyear", + "expirydate", + "secret", + "token", + "password", + "signature", + "apikey", +]; + +/// Keys never revealed, matched **exactly**. +/// +/// `authorization` is here rather than above because substring-matching it would also block +/// `authorizationCode` — a routine, non-sensitive field that connectors return and operators will +/// legitimately want visible. Blocking it would be unfixable from config, and would present as the +/// same "I configured it but it is still `***`" confusion this feature exists to remove. +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 this key's scalar value keeps its value. +fn allowed(keys: &HashSet>, key: &str) -> bool { + if is_always_masked(key) { + return false; + } + if keys.contains(key.to_ascii_lowercase().as_str()) { + return true; + } + // XML names may be prefixed (`s:authCode`); configuring the local name is what a + // reader expects, so accept that too. + key.rsplit_once(':') + .is_some_and(|(_, local)| keys.contains(local.to_ascii_lowercase().as_str())) +} + +/// 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:") +} + +// --------------------------------------------------------------------------- +// JSON — mask while serializing, never mutate the tree +// --------------------------------------------------------------------------- + +/// Serializes a [`Value`], substituting `"***"` for scalars whose key is not allowed. +/// +/// `mask` carries the decision made about the *parent key*, which is what lets array elements +/// inherit it — masking a tree in place would leave array scalars untouched, because they reach +/// the walker with no key in scope. +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() + } + 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: self.mask, + })?; + } + 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 { + value: &value, + keys, + mask: false, + }) + .ok() +} + +// --------------------------------------------------------------------------- +// XML — copy the event stream, rewrite only values +// --------------------------------------------------------------------------- + +/// Rebuild a start/empty tag with the same name, masking attribute values whose name is not +/// allowed. Values are unescaped before being pushed back, 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, or + // pretty-printed XML turns into a wall of markers. + 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()?; + } + } + // Declaration, comments, processing instructions, doctype: structural, copied as-is. + other => { + writer.write_event(other).ok()?; + } + } + } + + String::from_utf8(writer.into_inner()).ok() +} + +// --------------------------------------------------------------------------- +// Form-urlencoded +// --------------------------------------------------------------------------- + +fn mask_form(body: &[u8], keys: &HashSet>) -> Option { + // A Vec rather than a map so repeated keys survive. + let pairs: Vec<(String, String)> = serde_urlencoded::from_bytes(body).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() +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/// 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, + } +} + +/// Cap the emitted string without splitting a UTF-8 character. +fn cap(mut output: String, max_bytes: usize) -> String { + if max_bytes == 0 || output.len() <= max_bytes { + return output; + } + // Reserve room for the marker so the result honours the cap rather than overshooting it. + let mut boundary = max_bytes.saturating_sub(TRUNCATION_MARKER.len()); + while boundary > 0 && !output.is_char_boundary(boundary) { + boundary -= 1; + } + output.truncate(boundary); + output.push_str(TRUNCATION_MARKER); + output +} + +/// Mask `body` for `connector` 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: &ConnectorEnum, + config: &ConnectorResponseMaskingConfig, +) -> Option { + if !config.enabled || body.is_empty() { + return None; + } + + // Built here, for this connector only. Empty if it has no configured list, which still shows + // every key with every value masked. + let keys = config.keys_for(connector); + + 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, + }; + + Some(cap( + masked.unwrap_or_else(|| format!(r#"{{"_format":"unparseable","_bytes":{}}}"#, body.len())), + config.max_bytes, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PAYSAFE: ConnectorEnum = ConnectorEnum::Paysafe; + const ADYEN: ConnectorEnum = ConnectorEnum::Adyen; + const ELAVON: ConnectorEnum = ConnectorEnum::Elavon; + + fn config(pairs: &[(ConnectorEnum, &str)]) -> ConnectorResponseMaskingConfig { + ConnectorResponseMaskingConfig { + enabled: true, + max_bytes: DEFAULT_MAX_BYTES, + connector_keys: pairs + .iter() + .map(|(connector, keys)| (*connector, (*keys).to_string())) + .collect(), + } + } + + fn mask_json_body( + body: &str, + connector: &ConnectorEnum, + cfg: &ConnectorResponseMaskingConfig, + ) -> String { + mask_connector_response(body.as_bytes(), Some("application/json"), connector, cfg) + .unwrap_or_default() + } + + // -- 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 keys_for_is_case_insensitive_on_the_connector_name() { + let cfg = config(&[(PAYSAFE, "id")]); + assert!(cfg.keys_for(&PAYSAFE).contains("id")); + assert!(cfg.keys_for(&PAYSAFE).contains("id")); + } + + #[test] + fn unknown_connector_yields_an_empty_set() { + let cfg = config(&[(PAYSAFE, "id")]); + assert!(cfg.keys_for(&ADYEN).is_empty()); + } + + // -- JSON -------------------------------------------------------------- + + #[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 scalars_inside_arrays_are_masked() { + // Regression: an in-place tree walk leaves these untouched, because array + // elements reach the walker with no key in scope. + let cfg = config(&[(PAYSAFE, "status")]); + let out = mask_json_body( + r#"{"status":"OK","tags":["4111111111111111","secret"]}"#, + &PAYSAFE, + &cfg, + ); + assert_eq!(out, r#"{"status":"OK","tags":["***","***"]}"#); + assert!(!out.contains("4111")); + } + + #[test] + fn a_listed_key_holding_a_container_still_recurses() { + // Allowing `card` must not reveal the whole subtree. + let cfg = config(&[(PAYSAFE, "card,holder")]); + let out = mask_json_body(r#"{"card":{"holder":"A","last4":"1111"}}"#, &PAYSAFE, &cfg); + assert_eq!(out, r#"{"card":{"holder":"A","last4":"***"}}"#); + } + + #[test] + fn objects_inside_arrays_are_decided_per_key() { + let cfg = config(&[(PAYSAFE, "code")]); + let out = mask_json_body( + r#"{"errors":[{"code":"51","detail":"no funds"}]}"#, + &PAYSAFE, + &cfg, + ); + assert_eq!(out, r#"{"errors":[{"code":"51","detail":"***"}]}"#); + } + + #[test] + fn key_matching_ignores_case() { + let cfg = config(&[(PAYSAFE, "authcode")]); + let out = mask_json_body(r#"{"authCode":"727050"}"#, &PAYSAFE, &cfg); + assert_eq!(out, r#"{"authCode":"727050"}"#); + } + + #[test] + fn explicit_nulls_are_preserved() { + let cfg = config(&[(PAYSAFE, "")]); + let out = mask_json_body(r#"{"reason":null}"#, &PAYSAFE, &cfg); + assert_eq!(out, r#"{"reason":null}"#); + } + + #[test] + fn unconfigured_connector_masks_every_value_and_keeps_every_key() { + let cfg = config(&[]); + let out = mask_json_body(r#"{"id":"1","status":"OK"}"#, &ADYEN, &cfg); + assert_eq!(out, r#"{"id":"***","status":"***"}"#); + } + + #[test] + fn denylist_overrides_the_configured_list() { + let cfg = config(&[(PAYSAFE, "cardnumber,card_number,cvv,status")]); + let out = mask_json_body( + r#"{"status":"OK","card_number":"4111111111111111","cvv":"123"}"#, + &PAYSAFE, + &cfg, + ); + assert_eq!(out, r#"{"status":"OK","card_number":"***","cvv":"***"}"#); + } + + #[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 cfg = config(&[]); + let out = mask_json_body(&body, &PAYSAFE, &cfg); + assert!(out.ends_with(&"}".repeat(depth))); + } + + // -- XML --------------------------------------------------------------- + + #[test] + fn xml_stays_xml_and_masks_element_text() { + let cfg = config(&[(ELAVON, "ssl_result")]); + let body = r#"04111111111111111"#; + let out = + mask_connector_response(body.as_bytes(), Some("text/xml"), &ELAVON, &cfg).unwrap(); + + assert!(out.starts_with("0")); + assert!(out.contains("***")); + assert!(!out.contains("4111")); + } + + #[test] + fn xml_masks_attribute_values_and_keeps_namespaces() { + let cfg = config(&[(ELAVON, "id")]); + let body = r#""#; + let out = + mask_connector_response(body.as_bytes(), Some("text/xml"), &ELAVON, &cfg).unwrap(); + + 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("4111")); + } + + #[test] + fn xml_whitespace_between_elements_is_not_masked() { + let cfg = config(&[(ELAVON, "")]); + let body = "\n 1\n"; + let out = + mask_connector_response(body.as_bytes(), Some("text/xml"), &ELAVON, &cfg).unwrap(); + assert_eq!(out, "\n ***\n"); + } + + #[test] + fn xml_cdata_is_masked() { + let cfg = config(&[(ELAVON, "")]); + let body = ""; + let out = + mask_connector_response(body.as_bytes(), Some("text/xml"), &ELAVON, &cfg).unwrap(); + assert!(!out.contains("4111"), "{out}"); + } + + // -- form-urlencoded --------------------------------------------------- + + #[test] + fn form_stays_form_and_keeps_repeated_keys() { + let cfg = config(&[(ConnectorEnum::Payu, "status")]); + let body = "status=success&tag=a&tag=b&pan=4111111111111111"; + let out = mask_connector_response( + body.as_bytes(), + Some("application/x-www-form-urlencoded"), + &ConnectorEnum::Payu, + &cfg, + ) + .unwrap(); + + assert_eq!(out, "status=success&tag=***&tag=***&pan=***"); + } + + // -- detection, fallbacks, limits -------------------------------------- + + #[test] + fn format_is_sniffed_when_content_type_is_absent() { + let cfg = config(&[(PAYSAFE, "id")]); + let out = mask_connector_response(br#"{"id":"1","x":"y"}"#, None, &PAYSAFE, &cfg).unwrap(); + assert_eq!(out, r#"{"id":"1","x":"***"}"#); + } + + #[test] + fn unparseable_body_yields_a_stub_carrying_only_its_size() { + let cfg = config(&[]); + let body = b"<<(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_with_a_cap() { + let cfg = ConnectorResponseMaskingConfig::default(); + assert!(!cfg.enabled); + assert_eq!(cfg.max_bytes, DEFAULT_MAX_BYTES); + 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, + max_bytes: None, + connector_keys: Some([(PAYSAFE, "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"); + } + + #[test] + fn disabled_or_empty_yields_nothing() { + let mut cfg = config(&[(PAYSAFE, "id")]); + cfg.enabled = false; + assert!(mask_connector_response(br#"{"id":"1"}"#, None, &PAYSAFE, &cfg).is_none()); + + cfg.enabled = true; + assert!(mask_connector_response(b"", None, &PAYSAFE, &cfg).is_none()); + } +} diff --git a/crates/types-traits/domain_types/src/connector_types.rs b/crates/types-traits/domain_types/src/connector_types.rs index 70e110a743..bb580dfec5 100644 --- a/crates/types-traits/domain_types/src/connector_types.rs +++ b/crates/types-traits/domain_types/src/connector_types.rs @@ -540,6 +540,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_unmasked_connector_response(&mut self, response: Option); + fn get_unmasked_connector_response(&self) -> Option; } pub trait ConnectorResponseHeaders { @@ -739,6 +746,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 unmasked_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 @@ -1425,6 +1434,14 @@ impl RawConnectorRequestResponse for PaymentFlowData { self.raw_connector_response.clone() } + fn set_unmasked_connector_response(&mut self, response: Option) { + self.unmasked_connector_response = response; + } + + fn get_unmasked_connector_response(&self) -> Option { + self.unmasked_connector_response.clone() + } + fn get_raw_connector_request(&self) -> Option> { self.raw_connector_request.clone() } @@ -2612,6 +2629,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 unmasked_connector_response: Option, pub connector_response_headers: Option, pub raw_connector_request: Option>, pub access_token: Option, @@ -2635,6 +2654,14 @@ impl RawConnectorRequestResponse for RefundFlowData { self.raw_connector_response.clone() } + fn set_unmasked_connector_response(&mut self, response: Option) { + self.unmasked_connector_response = response; + } + + fn get_unmasked_connector_response(&self) -> Option { + self.unmasked_connector_response.clone() + } + fn get_raw_connector_request(&self) -> Option> { self.raw_connector_request.clone() } @@ -3647,6 +3674,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 unmasked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -3660,6 +3689,14 @@ impl RawConnectorRequestResponse for DisputeFlowData { self.raw_connector_response.clone() } + fn set_unmasked_connector_response(&mut self, response: Option) { + self.unmasked_connector_response = response; + } + + fn get_unmasked_connector_response(&self) -> Option { + self.unmasked_connector_response.clone() + } + fn set_raw_connector_request(&mut self, request: Option>) { self.raw_connector_request = request; } @@ -3684,6 +3721,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 unmasked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -3697,6 +3736,14 @@ impl RawConnectorRequestResponse for VerifyWebhookSourceFlowData { self.raw_connector_response.clone() } + fn set_unmasked_connector_response(&mut self, response: Option) { + self.unmasked_connector_response = response; + } + + fn get_unmasked_connector_response(&self) -> Option { + self.unmasked_connector_response.clone() + } + fn get_raw_connector_request(&self) -> Option> { self.raw_connector_request.clone() } @@ -3722,6 +3769,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 unmasked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -3735,6 +3784,14 @@ impl RawConnectorRequestResponse for RefreshPaymentMethodFlowData { self.raw_connector_response.clone() } + fn set_unmasked_connector_response(&mut self, response: Option) { + self.unmasked_connector_response = response; + } + + fn get_unmasked_connector_response(&self) -> Option { + self.unmasked_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..c2041e46ea 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 unmasked_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_unmasked_connector_response(&mut self, response: Option) { + self.unmasked_connector_response = response; + } + + fn get_unmasked_connector_response(&self) -> Option { + self.unmasked_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..5cc9ce1abb 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 { + unmasked_connector_response: None, merchant_id, connectors, access_token, @@ -123,6 +124,7 @@ impl .transpose()?; Ok(Self { + unmasked_connector_response: None, merchant_id, connectors, access_token, @@ -159,6 +161,7 @@ impl .transpose()?; Ok(Self { + unmasked_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 unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_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, + unmasked_connector_response, response_headers, } } @@ -906,6 +913,7 @@ pub fn generate_pre_risk_check_response( }), raw_connector_request, raw_connector_response, + unmasked_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 unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_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, + unmasked_connector_response, response_headers, } } @@ -976,6 +988,7 @@ pub fn generate_post_risk_check_response( }), raw_connector_request, raw_connector_response, + unmasked_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..8fce9a0b27 100644 --- a/crates/types-traits/domain_types/src/lib.rs +++ b/crates/types-traits/domain_types/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod connector_flow; +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..94b5496168 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 unmasked_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_unmasked_connector_response(&mut self, response: Option) { + self.unmasked_connector_response = response; + } + + fn get_unmasked_connector_response(&self) -> Option { + self.unmasked_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..d634c196fd 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 unmasked_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_unmasked_connector_response(&mut self, response: Option) { + self.unmasked_connector_response = response; + } + + fn get_unmasked_connector_response(&self) -> Option { + self.unmasked_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..e8d84323ae 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 { + unmasked_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 { + unmasked_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 { + unmasked_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 { + unmasked_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 { + unmasked_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 { + unmasked_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 { + unmasked_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 { + unmasked_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 { + unmasked_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..9950ba3e5c 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 unmasked_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_unmasked_connector_response(&mut self, response: Option) { + self.unmasked_connector_response = response; + } + + fn get_unmasked_connector_response(&self) -> Option { + self.unmasked_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..57c998ea8c 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 { + unmasked_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 { + unmasked_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 18ad91030f..f0f8c181a3 100644 --- a/crates/types-traits/domain_types/src/types.rs +++ b/crates/types-traits/domain_types/src/types.rs @@ -4854,6 +4854,7 @@ impl .transpose()?; Ok(Self { + unmasked_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( @@ -4942,6 +4943,7 @@ impl ForeignTryFrom<(PaymentServiceAuthorizeRequest, Connectors, &MaskedMetadata .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5056,6 +5058,7 @@ impl ForeignTryFrom<(AuthorizationRequest, Connectors, &MaskedMetadata)> for Pay .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5144,6 +5147,7 @@ impl ForeignTryFrom<(SetupRecurringRequest, Connectors, &MaskedMetadata)> for Pa .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5258,6 +5262,7 @@ impl })?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5341,6 +5346,7 @@ impl .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5430,6 +5436,7 @@ impl ForeignTryFrom<(PaymentServiceVoidRequest, Connectors, &MaskedMetadata)> fo .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5513,6 +5520,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5886,6 +5894,9 @@ pub fn generate_create_order_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -5916,6 +5927,7 @@ pub fn generate_create_order_response( merchant_order_id: None, raw_connector_request, raw_connector_response, + unmasked_connector_response, raw_connector_status, session_data: grpc_session_data, } @@ -5944,6 +5956,7 @@ pub fn generate_create_order_response( merchant_order_id: None, raw_connector_request, raw_connector_response, + unmasked_connector_response, raw_connector_status, session_data: None, }, @@ -5977,6 +5990,9 @@ pub fn generate_payment_method_eligibility_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -5990,6 +6006,7 @@ pub fn generate_payment_method_eligibility_response( error_info: None, raw_connector_request, raw_connector_response, + unmasked_connector_response, response_headers, }), Err(err) => Ok(PaymentMethodServiceEligibilityResponse { @@ -6011,6 +6028,7 @@ pub fn generate_payment_method_eligibility_response( }), raw_connector_request, raw_connector_response, + unmasked_connector_response, response_headers, }), } @@ -6182,6 +6200,9 @@ pub fn generate_payment_authorize_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -6279,6 +6300,7 @@ pub fn generate_payment_authorize_response( status: grpc_status as i32, error: None, raw_connector_response, + unmasked_connector_response, raw_connector_request, status_code: status_code as u32, response_headers, @@ -6351,6 +6373,7 @@ pub fn generate_payment_authorize_response( status_code: err.status_code as u32, response_headers, raw_connector_response, + unmasked_connector_response, raw_connector_request, connector_feature_data: None, state, @@ -7289,6 +7312,9 @@ pub fn generate_payment_void_response( raw_connector_response: router_data_v2 .resource_common_data .get_raw_connector_response(), + unmasked_connector_response: router_data_v2 + .resource_common_data + .get_unmasked_connector_response(), state, mandate_reference: mandate_reference_grpc, mandate_reference_details, @@ -7337,6 +7363,9 @@ pub fn generate_payment_void_response( raw_connector_response: router_data_v2 .resource_common_data .get_raw_connector_response(), + unmasked_connector_response: router_data_v2 + .resource_common_data + .get_unmasked_connector_response(), error: Some(grpc_api_types::payments::ErrorInfo { unified_details: None, connector_details: Some(grpc_api_types::payments::ConnectorErrorDetails { @@ -7398,6 +7427,9 @@ pub fn generate_payment_void_post_capture_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let raw_connector_status = router_data_v2 .resource_common_data @@ -7439,6 +7471,7 @@ pub fn generate_payment_void_post_capture_response( .get_connector_response_headers_as_map(), raw_connector_request, raw_connector_response, + unmasked_connector_response, raw_connector_status, }) } @@ -7472,6 +7505,7 @@ pub fn generate_payment_void_post_capture_response( .get_connector_response_headers_as_map(), raw_connector_request, raw_connector_response, + unmasked_connector_response, raw_connector_status, }) } @@ -7518,6 +7552,7 @@ pub fn generate_payment_void_post_capture_response( .get_connector_response_headers_as_map(), raw_connector_request, raw_connector_response, + unmasked_connector_response, raw_connector_status, }) } @@ -7629,6 +7664,9 @@ pub fn generate_payment_sync_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_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() @@ -7756,6 +7794,7 @@ pub fn generate_payment_sync_response( metadata: None, status_code: status_code as u32, raw_connector_response, + unmasked_connector_response, response_headers: router_data_v2 .resource_common_data .get_connector_response_headers_as_map(), @@ -7871,6 +7910,7 @@ pub fn generate_payment_sync_response( metadata: None, status_code: status_code as u32, raw_connector_response, + unmasked_connector_response, response_headers: router_data_v2 .resource_common_data .get_connector_response_headers_as_map(), @@ -7964,6 +8004,7 @@ pub fn generate_payment_sync_response( merchant_order_id: None, metadata: None, raw_connector_response, + unmasked_connector_response, status_code: e.status_code as u32, response_headers: router_data_v2 .resource_common_data @@ -8071,6 +8112,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, connector_request_reference_id: extract_connector_request_reference_id( @@ -8122,6 +8164,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, status: common_enums::RefundStatus::Success, @@ -8186,6 +8229,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; let refund_id = value.merchant_refund_id.clone(); Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, connector_request_reference_id: extract_connector_request_reference_id(&refund_id), @@ -8504,6 +8548,7 @@ impl ), ) -> Result> { Ok(Self { + unmasked_connector_response: None, dispute_id: None, connectors, connector_dispute_id: value.dispute_id, @@ -8535,6 +8580,7 @@ impl ), ) -> Result> { Ok(Self { + unmasked_connector_response: None, connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_dispute_id.clone(), ), @@ -8629,6 +8675,7 @@ impl ), ) -> Result> { Ok(Self { + unmasked_connector_response: None, dispute_id: None, connectors, connector_dispute_id: value.dispute_id, @@ -8658,6 +8705,7 @@ impl ), ) -> Result> { Ok(Self { + unmasked_connector_response: None, connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_dispute_id, ), @@ -8680,6 +8728,9 @@ pub fn generate_refund_sync_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data @@ -8727,6 +8778,7 @@ pub fn generate_refund_sync_response( metadata: None, refund_metadata: None, raw_connector_response, + unmasked_connector_response, status_code: response.status_code as u32, response_headers, state: None, @@ -8777,6 +8829,7 @@ pub fn generate_refund_sync_response( customer_name: None, email: None, raw_connector_response, + unmasked_connector_response, merchant_order_id: None, metadata: None, refund_metadata: None, @@ -8889,6 +8942,7 @@ impl ForeignTryFrom for PaymentServiceGetResponse { metadata: None, status_code: value.status_code as u32, raw_connector_response: None, + unmasked_connector_response: None, response_headers, state: None, raw_connector_request: None, @@ -9082,6 +9136,9 @@ pub fn generate_void_post_refund_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -9135,6 +9192,7 @@ pub fn generate_void_post_refund_response( metadata: None, refund_metadata: None, raw_connector_response, + unmasked_connector_response, status_code: response.status_code as u32, response_headers, state: Some(ConnectorState { @@ -9186,6 +9244,7 @@ pub fn generate_void_post_refund_response( customer_name: None, email: None, raw_connector_response, + unmasked_connector_response, merchant_order_id: None, metadata: None, refund_metadata: None, @@ -9271,6 +9330,7 @@ impl }); Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -9383,6 +9443,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -9472,6 +9533,7 @@ impl ForeignTryFrom for RefundResponse { issuer_details: None, }), raw_connector_response: None, + unmasked_connector_response: None, refund_amount: None, payment_amount: None, refund_reason: None, @@ -10130,6 +10192,9 @@ pub fn generate_refund_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); // RefundFlowData doesn't have access_token field, so no state to return let state = None; @@ -10176,6 +10241,7 @@ pub fn generate_refund_response( email: None, merchant_order_id: None, raw_connector_response, + unmasked_connector_response, metadata: None, refund_metadata: None, status_code: response.status_code as u32, @@ -10227,6 +10293,7 @@ pub fn generate_refund_response( customer_name: None, email: None, raw_connector_response, + unmasked_connector_response, merchant_order_id: None, metadata: None, refund_metadata: None, @@ -10443,6 +10510,7 @@ impl .map(|m| ForeignTryFrom::foreign_try_from((m, "feature data"))) .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "PAYMENT_ID".to_string(), @@ -10511,6 +10579,7 @@ impl }; Ok(Self { + unmasked_connector_response: None, merchant_id: merchant_id_from_header, connector_request_reference_id: value.merchant_client_session_id, connector_feature_data: value @@ -10579,6 +10648,9 @@ pub fn generate_payment_incremental_authorization_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); match router_data_v2.response { Ok(response) => match response { @@ -10601,6 +10673,7 @@ pub fn generate_payment_incremental_authorization_response( state, raw_connector_request, raw_connector_response, + unmasked_connector_response, }) } _ => Err(report!(ConnectorError::UnexpectedResponseError { @@ -10633,6 +10706,7 @@ pub fn generate_payment_incremental_authorization_response( state, raw_connector_request, raw_connector_response, + unmasked_connector_response, }), } } @@ -10681,6 +10755,9 @@ pub fn generate_payment_capture_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let connector_response = router_data_v2 .resource_common_data @@ -10750,6 +10827,7 @@ pub fn generate_payment_capture_response( state, raw_connector_request, raw_connector_response, + unmasked_connector_response, incremental_authorization_allowed, mandate_reference: mandate_reference_grpc, mandate_reference_details, @@ -10814,6 +10892,7 @@ pub fn generate_payment_capture_response( state, raw_connector_request, raw_connector_response, + unmasked_connector_response, incremental_authorization_allowed: None, mandate_reference: None, mandate_reference_details: None, @@ -10892,6 +10971,7 @@ impl .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -10993,6 +11073,7 @@ impl .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -11761,6 +11842,9 @@ pub fn generate_setup_mandate_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let connector_response = router_data_v2 .resource_common_data @@ -11892,6 +11976,7 @@ pub fn generate_setup_mandate_response( state, raw_connector_request, raw_connector_response, + unmasked_connector_response, connector_response, connector_feature_data: convert_connector_metadata_to_secret_string( connector_metadata, @@ -11959,6 +12044,7 @@ pub fn generate_setup_mandate_response( state, raw_connector_request, raw_connector_response, + unmasked_connector_response, connector_response, connector_feature_data: None, captured_amount: None, @@ -11976,6 +12062,7 @@ impl ForeignTryFrom<(DisputeServiceDefendRequest, Connectors)> for DisputeFlowDa (value, connectors): (DisputeServiceDefendRequest, Connectors), ) -> Result> { Ok(Self { + unmasked_connector_response: None, dispute_id: Some(value.dispute_id.clone()), connectors, connector_dispute_id: value.dispute_id, @@ -11999,6 +12086,7 @@ impl ForeignTryFrom<(DisputeServiceDefendRequest, Connectors, &MaskedMetadata)> (value, connectors, _metadata): (DisputeServiceDefendRequest, Connectors, &MaskedMetadata), ) -> Result> { Ok(Self { + unmasked_connector_response: None, connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_dispute_id, ), @@ -12220,6 +12308,7 @@ impl .and_then(|state| state.connector_customer_id.clone()); Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -12825,6 +12914,7 @@ impl .transpose()?; Ok(Self { + unmasked_connector_response: None, merchant_id: merchant_id_from_header, connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_server_session_id.clone(), @@ -12970,6 +13060,7 @@ impl .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13156,6 +13247,7 @@ impl .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13273,6 +13365,7 @@ impl .map(ServerAuthenticationTokenResponseData::foreign_try_from) .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13380,6 +13473,7 @@ impl .map(ServerAuthenticationTokenResponseData::foreign_try_from) .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13482,6 +13576,7 @@ impl ), ) -> Result> { Ok(Self { + unmasked_connector_response: None, connectors, connector_request_reference_id: String::new(), raw_connector_response: None, @@ -13505,6 +13600,7 @@ impl ForeignTryFrom error: None, response_headers: std::collections::HashMap::new(), raw_connector_response: None, + unmasked_connector_response: None, raw_connector_request: None, }) } @@ -13589,6 +13685,7 @@ impl }), response_headers: std::collections::HashMap::new(), raw_connector_response: None, + unmasked_connector_response: None, raw_connector_request: None, } } @@ -13674,6 +13771,7 @@ impl .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13761,6 +13859,7 @@ impl ) -> Result> { let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -14125,6 +14224,9 @@ pub fn generate_repeat_payment_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data @@ -14200,6 +14302,7 @@ pub fn generate_repeat_payment_response( mandate_reference_details, status_code: status_code as u32, raw_connector_response, + unmasked_connector_response, response_headers: router_data_v2 .resource_common_data .get_connector_response_headers_as_map(), @@ -14262,6 +14365,7 @@ pub fn generate_repeat_payment_response( connector_feature_data: None, mandate_reference_details: None, raw_connector_response: None, + unmasked_connector_response: None, status_code: err.status_code as u32, response_headers: router_data_v2 .resource_common_data @@ -14637,6 +14741,9 @@ pub fn generate_payment_sdk_session_token_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); match transaction_response { Ok(response) => match response { @@ -14704,6 +14811,7 @@ pub fn generate_payment_sdk_session_token_response( session_data: grpc_session_data, error: None, raw_connector_response, + unmasked_connector_response, status_code: status_code as u32, raw_connector_request, }, @@ -14733,6 +14841,7 @@ pub fn generate_payment_sdk_session_token_response( issuer_details: Some(grpc_payment_types::IssuerErrorDetails::from(&e)), }), raw_connector_response, + unmasked_connector_response, status_code: e.status_code as u32, raw_connector_request, }, @@ -15704,6 +15813,7 @@ impl .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -15806,6 +15916,7 @@ impl .transpose()?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -15916,6 +16027,7 @@ impl .map(|s| s.to_string()); Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -16001,6 +16113,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "MANDATE_REVOKE_ID".to_string(), @@ -16093,6 +16206,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. + unmasked_connector_response: None, }) } } @@ -16114,6 +16231,9 @@ pub fn generate_payment_pre_authenticate_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let response_headers = router_data_v2 .resource_common_data .get_connector_response_headers_as_map(); @@ -16266,6 +16386,7 @@ pub fn generate_payment_pre_authenticate_response( status: grpc_status.into(), error: None, raw_connector_response, + unmasked_connector_response, status_code: status_code.into(), response_headers, network_transaction_id: None, @@ -16314,6 +16435,7 @@ pub fn generate_payment_pre_authenticate_response( status_code: err.status_code.into(), response_headers, raw_connector_response, + unmasked_connector_response, connector_feature_data: None, state: None, authentication_data: None, @@ -16340,6 +16462,9 @@ pub fn generate_payment_authenticate_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let response_headers = router_data_v2 .resource_common_data .get_connector_response_headers_as_map(); @@ -16471,6 +16596,7 @@ pub fn generate_payment_authenticate_response( status: grpc_status.into(), error: None, raw_connector_response, + unmasked_connector_response, raw_connector_status, status_code: status_code.into(), response_headers, @@ -16519,6 +16645,7 @@ pub fn generate_payment_authenticate_response( }), status_code: err.status_code.into(), raw_connector_response, + unmasked_connector_response, raw_connector_status, response_headers, connector_feature_data: None, @@ -16546,6 +16673,9 @@ pub fn generate_payment_post_authenticate_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let response_headers = router_data_v2 .resource_common_data .get_connector_response_headers_as_map(); @@ -16578,6 +16708,7 @@ pub fn generate_payment_post_authenticate_response( status: grpc_status.into(), error: None, raw_connector_response, + unmasked_connector_response, raw_connector_status, status_code: status_code.into(), response_headers, @@ -16627,6 +16758,7 @@ pub fn generate_payment_post_authenticate_response( status_code: err.status_code.into(), response_headers, raw_connector_response, + unmasked_connector_response, raw_connector_status, connector_feature_data: None, state: None, @@ -17073,6 +17205,9 @@ pub fn generate_mandate_revoke_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17102,6 +17237,7 @@ pub fn generate_mandate_revoke_response( network_transaction_id: None, merchant_revoke_id: None, raw_connector_response, + unmasked_connector_response, raw_connector_request, }), Err(e) => Ok(RecurringPaymentServiceRevokeResponse { @@ -17122,6 +17258,7 @@ pub fn generate_mandate_revoke_response( network_transaction_id: None, merchant_revoke_id: e.connector_transaction_id, raw_connector_response, + unmasked_connector_response, raw_connector_request, }), } @@ -17221,6 +17358,9 @@ pub fn generate_recharge_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17245,6 +17385,7 @@ pub fn generate_recharge_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, + unmasked_connector_response, raw_connector_request, }) } @@ -17274,6 +17415,7 @@ pub fn generate_recharge_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, + unmasked_connector_response, raw_connector_request, }) } @@ -17292,6 +17434,9 @@ pub fn generate_create_payment_method_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17313,6 +17458,7 @@ pub fn generate_create_payment_method_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, + unmasked_connector_response, raw_connector_request, }), Err(error) => Ok(PaymentMethodServiceCreateResponse { @@ -17337,6 +17483,7 @@ pub fn generate_create_payment_method_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, + unmasked_connector_response, raw_connector_request, }), } @@ -17360,6 +17507,9 @@ pub fn generate_refresh_payment_method_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17415,6 +17565,9 @@ pub fn generate_get_payment_method_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); + let unmasked_connector_response = router_data_v2 + .resource_common_data + .get_unmasked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17436,6 +17589,7 @@ pub fn generate_get_payment_method_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, + unmasked_connector_response, raw_connector_request, }), Err(error) => Ok(PaymentMethodServiceGetResponse { @@ -17460,6 +17614,7 @@ pub fn generate_get_payment_method_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, + unmasked_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..98ea76d344 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 unmasked_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 unmasked_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 a831d17c9a..c81a7ba198 100644 --- a/crates/types-traits/grpc-api-types/proto/payment.proto +++ b/crates/types-traits/grpc-api-types/proto/payment.proto @@ -2567,6 +2567,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 unmasked_connector_response = 25; optional SecretString raw_connector_request = 12; // Payment Details @@ -2704,6 +2708,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 unmasked_connector_response = 38; optional SecretString raw_connector_request = 25; // Redirection and Transaction Details @@ -2803,6 +2811,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 unmasked_connector_response = 17; // Mandate reference details returned by the connector for future recurring payments. optional MandateReferenceDetails mandate_reference_details = 14; @@ -2855,6 +2867,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 unmasked_connector_response = 11; // The connector's reported status for this response. optional RawConnectorStatus raw_connector_status = 9; @@ -2976,6 +2992,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 unmasked_connector_response = 6; optional SecretString raw_connector_request = 5; } @@ -3066,6 +3086,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 unmasked_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. @@ -3120,6 +3144,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 unmasked_connector_response = 11; // SDK Session Data optional ClientAuthenticationTokenData session_data = @@ -3224,6 +3252,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 unmasked_connector_response = 27; optional SecretString raw_connector_request = 22; // Connector/domain state metadata to persist across calls. @@ -3476,6 +3508,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 unmasked_connector_response = 20; } // Request message for repeat payment (MIT - Merchant Initiated Transaction) @@ -3598,6 +3634,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 unmasked_connector_response = 21; optional SecretString raw_connector_request = 12; // Payment Details @@ -3652,6 +3692,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 unmasked_connector_response = 9; optional SecretString raw_connector_request = 8; } @@ -3724,6 +3768,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 unmasked_connector_response = 13; // Authentication Results optional AuthenticationData authentication_data = 12; } @@ -3801,6 +3849,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 unmasked_connector_response = 14; // The connector's reported status (code/message/reason). optional RawConnectorStatus raw_connector_status = 13; @@ -3874,6 +3926,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 unmasked_connector_response = 15; // The connector's reported status for this response. optional RawConnectorStatus raw_connector_status = 14; @@ -3914,6 +3970,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 unmasked_connector_response = 9; } // IDs extracted from a payment webhook. @@ -4072,6 +4132,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 unmasked_connector_response = 8; } // ============================================================================ @@ -4313,6 +4377,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 unmasked_connector_response = 11; optional SecretString raw_connector_request = 10; } @@ -4347,6 +4415,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 unmasked_connector_response = 11; optional SecretString raw_connector_request = 10; } @@ -4363,6 +4435,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 unmasked_connector_response = 7; optional SecretString raw_connector_request = 6; } @@ -4516,6 +4592,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 unmasked_connector_response = 12; optional SecretString raw_connector_request = 11; // Raw request sent to the connector for debugging } @@ -6051,6 +6131,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 unmasked_connector_response = 7; // Response headers from the connector. map response_headers = 6; From b4431bc53e75a3496e1fd1f7975ff6f8f2fc6e67 Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Tue, 4 Aug 2026 13:53:47 +0530 Subject: [PATCH 02/14] test(core): remove unmasked_connector_response unit tests Drops the test module and the `toml` dev-dependency it required. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 - crates/types-traits/domain_types/Cargo.toml | 4 - .../src/connector_response_masking.rs | 342 ------------------ 3 files changed, 347 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 62def4833f..9aa45f70b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1807,7 +1807,6 @@ 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/crates/types-traits/domain_types/Cargo.toml b/crates/types-traits/domain_types/Cargo.toml index fc57fcc019..9d1b223d2d 100644 --- a/crates/types-traits/domain_types/Cargo.toml +++ b/crates/types-traits/domain_types/Cargo.toml @@ -47,10 +47,6 @@ 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"] diff --git a/crates/types-traits/domain_types/src/connector_response_masking.rs b/crates/types-traits/domain_types/src/connector_response_masking.rs index 06bab90619..9797120bb9 100644 --- a/crates/types-traits/domain_types/src/connector_response_masking.rs +++ b/crates/types-traits/domain_types/src/connector_response_masking.rs @@ -493,345 +493,3 @@ pub fn mask_connector_response( config.max_bytes, )) } - -#[cfg(test)] -mod tests { - use super::*; - - const PAYSAFE: ConnectorEnum = ConnectorEnum::Paysafe; - const ADYEN: ConnectorEnum = ConnectorEnum::Adyen; - const ELAVON: ConnectorEnum = ConnectorEnum::Elavon; - - fn config(pairs: &[(ConnectorEnum, &str)]) -> ConnectorResponseMaskingConfig { - ConnectorResponseMaskingConfig { - enabled: true, - max_bytes: DEFAULT_MAX_BYTES, - connector_keys: pairs - .iter() - .map(|(connector, keys)| (*connector, (*keys).to_string())) - .collect(), - } - } - - fn mask_json_body( - body: &str, - connector: &ConnectorEnum, - cfg: &ConnectorResponseMaskingConfig, - ) -> String { - mask_connector_response(body.as_bytes(), Some("application/json"), connector, cfg) - .unwrap_or_default() - } - - // -- 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 keys_for_is_case_insensitive_on_the_connector_name() { - let cfg = config(&[(PAYSAFE, "id")]); - assert!(cfg.keys_for(&PAYSAFE).contains("id")); - assert!(cfg.keys_for(&PAYSAFE).contains("id")); - } - - #[test] - fn unknown_connector_yields_an_empty_set() { - let cfg = config(&[(PAYSAFE, "id")]); - assert!(cfg.keys_for(&ADYEN).is_empty()); - } - - // -- JSON -------------------------------------------------------------- - - #[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 scalars_inside_arrays_are_masked() { - // Regression: an in-place tree walk leaves these untouched, because array - // elements reach the walker with no key in scope. - let cfg = config(&[(PAYSAFE, "status")]); - let out = mask_json_body( - r#"{"status":"OK","tags":["4111111111111111","secret"]}"#, - &PAYSAFE, - &cfg, - ); - assert_eq!(out, r#"{"status":"OK","tags":["***","***"]}"#); - assert!(!out.contains("4111")); - } - - #[test] - fn a_listed_key_holding_a_container_still_recurses() { - // Allowing `card` must not reveal the whole subtree. - let cfg = config(&[(PAYSAFE, "card,holder")]); - let out = mask_json_body(r#"{"card":{"holder":"A","last4":"1111"}}"#, &PAYSAFE, &cfg); - assert_eq!(out, r#"{"card":{"holder":"A","last4":"***"}}"#); - } - - #[test] - fn objects_inside_arrays_are_decided_per_key() { - let cfg = config(&[(PAYSAFE, "code")]); - let out = mask_json_body( - r#"{"errors":[{"code":"51","detail":"no funds"}]}"#, - &PAYSAFE, - &cfg, - ); - assert_eq!(out, r#"{"errors":[{"code":"51","detail":"***"}]}"#); - } - - #[test] - fn key_matching_ignores_case() { - let cfg = config(&[(PAYSAFE, "authcode")]); - let out = mask_json_body(r#"{"authCode":"727050"}"#, &PAYSAFE, &cfg); - assert_eq!(out, r#"{"authCode":"727050"}"#); - } - - #[test] - fn explicit_nulls_are_preserved() { - let cfg = config(&[(PAYSAFE, "")]); - let out = mask_json_body(r#"{"reason":null}"#, &PAYSAFE, &cfg); - assert_eq!(out, r#"{"reason":null}"#); - } - - #[test] - fn unconfigured_connector_masks_every_value_and_keeps_every_key() { - let cfg = config(&[]); - let out = mask_json_body(r#"{"id":"1","status":"OK"}"#, &ADYEN, &cfg); - assert_eq!(out, r#"{"id":"***","status":"***"}"#); - } - - #[test] - fn denylist_overrides_the_configured_list() { - let cfg = config(&[(PAYSAFE, "cardnumber,card_number,cvv,status")]); - let out = mask_json_body( - r#"{"status":"OK","card_number":"4111111111111111","cvv":"123"}"#, - &PAYSAFE, - &cfg, - ); - assert_eq!(out, r#"{"status":"OK","card_number":"***","cvv":"***"}"#); - } - - #[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 cfg = config(&[]); - let out = mask_json_body(&body, &PAYSAFE, &cfg); - assert!(out.ends_with(&"}".repeat(depth))); - } - - // -- XML --------------------------------------------------------------- - - #[test] - fn xml_stays_xml_and_masks_element_text() { - let cfg = config(&[(ELAVON, "ssl_result")]); - let body = r#"04111111111111111"#; - let out = - mask_connector_response(body.as_bytes(), Some("text/xml"), &ELAVON, &cfg).unwrap(); - - assert!(out.starts_with("0")); - assert!(out.contains("***")); - assert!(!out.contains("4111")); - } - - #[test] - fn xml_masks_attribute_values_and_keeps_namespaces() { - let cfg = config(&[(ELAVON, "id")]); - let body = r#""#; - let out = - mask_connector_response(body.as_bytes(), Some("text/xml"), &ELAVON, &cfg).unwrap(); - - 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("4111")); - } - - #[test] - fn xml_whitespace_between_elements_is_not_masked() { - let cfg = config(&[(ELAVON, "")]); - let body = "\n 1\n"; - let out = - mask_connector_response(body.as_bytes(), Some("text/xml"), &ELAVON, &cfg).unwrap(); - assert_eq!(out, "\n ***\n"); - } - - #[test] - fn xml_cdata_is_masked() { - let cfg = config(&[(ELAVON, "")]); - let body = ""; - let out = - mask_connector_response(body.as_bytes(), Some("text/xml"), &ELAVON, &cfg).unwrap(); - assert!(!out.contains("4111"), "{out}"); - } - - // -- form-urlencoded --------------------------------------------------- - - #[test] - fn form_stays_form_and_keeps_repeated_keys() { - let cfg = config(&[(ConnectorEnum::Payu, "status")]); - let body = "status=success&tag=a&tag=b&pan=4111111111111111"; - let out = mask_connector_response( - body.as_bytes(), - Some("application/x-www-form-urlencoded"), - &ConnectorEnum::Payu, - &cfg, - ) - .unwrap(); - - assert_eq!(out, "status=success&tag=***&tag=***&pan=***"); - } - - // -- detection, fallbacks, limits -------------------------------------- - - #[test] - fn format_is_sniffed_when_content_type_is_absent() { - let cfg = config(&[(PAYSAFE, "id")]); - let out = mask_connector_response(br#"{"id":"1","x":"y"}"#, None, &PAYSAFE, &cfg).unwrap(); - assert_eq!(out, r#"{"id":"1","x":"***"}"#); - } - - #[test] - fn unparseable_body_yields_a_stub_carrying_only_its_size() { - let cfg = config(&[]); - let body = b"<<(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_with_a_cap() { - let cfg = ConnectorResponseMaskingConfig::default(); - assert!(!cfg.enabled); - assert_eq!(cfg.max_bytes, DEFAULT_MAX_BYTES); - 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, - max_bytes: None, - connector_keys: Some([(PAYSAFE, "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"); - } - - #[test] - fn disabled_or_empty_yields_nothing() { - let mut cfg = config(&[(PAYSAFE, "id")]); - cfg.enabled = false; - assert!(mask_connector_response(br#"{"id":"1"}"#, None, &PAYSAFE, &cfg).is_none()); - - cfg.enabled = true; - assert!(mask_connector_response(b"", None, &PAYSAFE, &cfg).is_none()); - } -} From cb64e5c51a6ec8a3f6c4bea2650ccf81221b0b8c Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Tue, 4 Aug 2026 14:08:16 +0530 Subject: [PATCH 03/14] refactor(core): drop the response size cap and seed a single connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emitted body is now returned whole. `max_bytes` carried the cap value in two places — the TOML files and a `DEFAULT_MAX_BYTES` const — which could silently disagree, and the requirement is the full response regardless. Removing truncation leaves nothing to configure, so the field, the const pair and `cap()` all go rather than being reworked into a config-only value. Also fixes a gap the compiler could not catch: `generate_refresh_payment_method_response` assigns proto fields rather than building a struct literal, so the missing `unmasked_connector_response` never raised E0063. `PaymentMethodServiceRefreshResponse` was silently never populated on either the success or error path. Seed only `adyen` in the sample config. The other five lists were written from knowledge of each gateway's response shape and never verified against a live call; shipping guesses as config invites people to trust them. Removed connectors fall into the "no entry" case — every value masked, every key still visible. Verified against the Adyen sandbox: 1474 bytes returned whole with no truncation marker, listed fields visible, 51 additionalData keys present with masked values. Co-Authored-By: Claude Opus 5 (1M context) --- config/development.toml | 10 +++--- config/production.toml | 10 +++--- config/sandbox.toml | 10 +++--- .../src/connector_response_masking.rs | 36 ++----------------- crates/types-traits/domain_types/src/types.rs | 2 ++ 5 files changed, 17 insertions(+), 51 deletions(-) diff --git a/config/development.toml b/config/development.toml index 47d06216c4..916b5515f9 100644 --- a/config/development.toml +++ b/config/development.toml @@ -243,15 +243,13 @@ keys = ["x-request-id","x-merchant-id","x-lineage-ids","x-reference-id","x-conne # `common.return_raw_connector_data`, so this can stay on where raw capture is off. [connector_response_masking] enabled = true -max_bytes = 8192 # Per-connector unmask lists, comma-separated and case-insensitive. # A connector with no entry gets every value masked (keys still visible). # Never list card/CVV/token-like keys: those stay masked regardless. +# +# 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] -paysafe = "id,status,txntime,merchantrefnum,authcode,currencycode,amount,errorcode,message" adyen = "pspreference,resultcode,merchantreference,refusalreason,eventcode,success" -cybersource = "id,status,submittimeutc,reconciliationid,clientreferenceinformation,code,message,reason" -stripe = "id,object,status,amount,currency,created,livemode,failure_code,failure_message" -razorpay = "id,entity,status,amount,currency,method,created_at,error_code,error_description" -checkout = "id,status,amount,currency,response_code,response_summary,processed_on,reference" diff --git a/config/production.toml b/config/production.toml index 36d8f64546..ab3bfd25fa 100644 --- a/config/production.toml +++ b/config/production.toml @@ -184,15 +184,13 @@ connectors_with_webhook_source_verification_call = "paypal, truelayer" # `common.return_raw_connector_data`, so this can stay on where raw capture is off. [connector_response_masking] enabled = true -max_bytes = 8192 # Per-connector unmask lists, comma-separated and case-insensitive. # A connector with no entry gets every value masked (keys still visible). # Never list card/CVV/token-like keys: those stay masked regardless. +# +# 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] -paysafe = "id,status,txntime,merchantrefnum,authcode,currencycode,amount,errorcode,message" adyen = "pspreference,resultcode,merchantreference,refusalreason,eventcode,success" -cybersource = "id,status,submittimeutc,reconciliationid,clientreferenceinformation,code,message,reason" -stripe = "id,object,status,amount,currency,created,livemode,failure_code,failure_message" -razorpay = "id,entity,status,amount,currency,method,created_at,error_code,error_description" -checkout = "id,status,amount,currency,response_code,response_summary,processed_on,reference" diff --git a/config/sandbox.toml b/config/sandbox.toml index e24183b48f..08bab1e48f 100644 --- a/config/sandbox.toml +++ b/config/sandbox.toml @@ -185,15 +185,13 @@ connectors_with_webhook_source_verification_call = "paypal, truelayer" # `common.return_raw_connector_data`, so this can stay on where raw capture is off. [connector_response_masking] enabled = true -max_bytes = 8192 # Per-connector unmask lists, comma-separated and case-insensitive. # A connector with no entry gets every value masked (keys still visible). # Never list card/CVV/token-like keys: those stay masked regardless. +# +# 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] -paysafe = "id,status,txntime,merchantrefnum,authcode,currencycode,amount,errorcode,message" adyen = "pspreference,resultcode,merchantreference,refusalreason,eventcode,success" -cybersource = "id,status,submittimeutc,reconciliationid,clientreferenceinformation,code,message,reason" -stripe = "id,object,status,amount,currency,created,livemode,failure_code,failure_message" -razorpay = "id,entity,status,amount,currency,method,created_at,error_code,error_description" -checkout = "id,status,amount,currency,response_code,response_summary,processed_on,reference" diff --git a/crates/types-traits/domain_types/src/connector_response_masking.rs b/crates/types-traits/domain_types/src/connector_response_masking.rs index 9797120bb9..403aa4d5b2 100644 --- a/crates/types-traits/domain_types/src/connector_response_masking.rs +++ b/crates/types-traits/domain_types/src/connector_response_masking.rs @@ -24,12 +24,6 @@ use crate::connector_types::ConnectorEnum; /// Replacement written in place of a masked value. pub const MASKED: &str = "***"; -/// Default cap on the emitted string, in bytes. -const DEFAULT_MAX_BYTES: usize = 8192; - -/// Marker appended when the emitted string is capped. -const TRUNCATION_MARKER: &str = "…[truncated]"; - // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- @@ -45,9 +39,6 @@ pub struct ConnectorResponseMaskingConfig { /// Whether to populate `unmasked_connector_response` at all. pub enabled: bool, - /// Cap on the emitted string in bytes. `0` means no cap. - pub max_bytes: usize, - /// Connector -> comma-separated list of keys whose values stay visible. /// /// Keyed by [`ConnectorEnum`] so an unknown name in TOML or env aborts startup naming the bad @@ -111,7 +102,6 @@ impl Default for ConnectorResponseMaskingConfig { fn default() -> Self { Self { enabled: false, - max_bytes: DEFAULT_MAX_BYTES, connector_keys: HashMap::new(), } } @@ -145,8 +135,6 @@ impl ConnectorResponseMaskingConfig { pub struct ConnectorResponseMaskingConfigPatch { /// See [`ConnectorResponseMaskingConfig::enabled`]. pub enabled: Option, - /// See [`ConnectorResponseMaskingConfig::max_bytes`]. - pub max_bytes: Option, /// See [`ConnectorResponseMaskingConfig::connector_keys`]. #[serde(default, deserialize_with = "deserialize_optional_connector_keys")] pub connector_keys: Option>, @@ -166,9 +154,6 @@ impl Patch for ConnectorResponseMaskingConf if let Some(enabled) = patch.enabled { self.enabled = enabled; } - if let Some(max_bytes) = patch.max_bytes { - self.max_bytes = max_bytes; - } if let Some(connector_keys) = patch.connector_keys { self.connector_keys = connector_keys; } @@ -448,21 +433,6 @@ fn detect(content_type: Option<&str>, body: &[u8]) -> Option { } } -/// Cap the emitted string without splitting a UTF-8 character. -fn cap(mut output: String, max_bytes: usize) -> String { - if max_bytes == 0 || output.len() <= max_bytes { - return output; - } - // Reserve room for the marker so the result honours the cap rather than overshooting it. - let mut boundary = max_bytes.saturating_sub(TRUNCATION_MARKER.len()); - while boundary > 0 && !output.is_char_boundary(boundary) { - boundary -= 1; - } - output.truncate(boundary); - output.push_str(TRUNCATION_MARKER); - output -} - /// Mask `body` for `connector` 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 @@ -488,8 +458,8 @@ pub fn mask_connector_response( None => None, }; - Some(cap( + // Emitted whole: the full body is the point, so there is no truncation. + Some( masked.unwrap_or_else(|| format!(r#"{{"_format":"unparseable","_bytes":{}}}"#, body.len())), - config.max_bytes, - )) + ) } diff --git a/crates/types-traits/domain_types/src/types.rs b/crates/types-traits/domain_types/src/types.rs index f0f8c181a3..7a2a1b7365 100644 --- a/crates/types-traits/domain_types/src/types.rs +++ b/crates/types-traits/domain_types/src/types.rs @@ -17535,6 +17535,7 @@ pub fn generate_refresh_payment_method_response( })?; proto.response_headers = response_headers; proto.raw_connector_response = raw_connector_response; + proto.unmasked_connector_response = unmasked_connector_response; proto.raw_connector_request = raw_connector_request; Ok(proto) } @@ -17547,6 +17548,7 @@ pub fn generate_refresh_payment_method_response( )); proto.response_headers = response_headers; proto.raw_connector_response = raw_connector_response; + proto.unmasked_connector_response = unmasked_connector_response; proto.raw_connector_request = raw_connector_request; Ok(proto) } From d00b9209e8ac4d72a33d81c8e7ec0d8bb915242a Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Tue, 4 Aug 2026 15:51:57 +0530 Subject: [PATCH 04/14] fix(core): resolve CI failures on unmasked_connector_response Three checks were red because local verification was weaker than CI's. - Run Tests: `cargo check --workspace` does not compile test targets, so 16 `PaymentFlowData` literals in razorpay/calida/adyen `test.rs` were never seen. Adding `unmasked_connector_response: None` to each. `--all-targets` is what makes "let rustc enumerate every miss" actually hold. - Clippy: removing `max_bytes` left the manual `Default` identical to what the derive produces, tripping `derivable_impls` under `-D warnings`. Dropped the impl and added `Default` to the derive list. - Spell check: `unparseable` -> `unparsable`, matching how the repo already spells it in kount and affirm rather than adding a `.typos.toml` exception. Verified with CI's own commands: `cargo check --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings` (exit 0), `cargo test --workspace --no-run` (exit 0), `cargo fmt --all -- --check`. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/connectors/adyen/test.rs | 2 ++ .../src/connectors/calida/test.rs | 2 ++ .../src/connectors/razorpay/test.rs | 12 ++++++++++++ .../src/connector_response_masking.rs | 15 ++------------- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/integrations/connector-integration/src/connectors/adyen/test.rs b/crates/integrations/connector-integration/src/connectors/adyen/test.rs index 23eefcb8aa..4627e8eacd 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 { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: common_utils::id_type::MerchantId::default(), customer_id: None, @@ -244,6 +245,7 @@ mod tests { > = RouterDataV2 { flow: PhantomData::, resource_common_data: PaymentFlowData { + unmasked_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 26e72d31e2..2eaa24a391 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 { + unmasked_connector_response: None, raw_connector_status: None, vault_headers: None, merchant_id: common_utils::id_type::MerchantId::default(), @@ -229,6 +230,7 @@ mod tests { > = RouterDataV2 { flow: PhantomData::, resource_common_data: PaymentFlowData { + unmasked_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 102f1f08cf..f73477acdd 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 { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -273,6 +274,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -425,6 +427,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -587,6 +590,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -959,6 +963,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1167,6 +1172,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1377,6 +1383,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: domain_types::connector_types::PaymentFlowData { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1513,6 +1520,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: domain_types::connector_types::PaymentFlowData { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1640,6 +1648,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1801,6 +1810,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1937,6 +1947,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -2062,6 +2073,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { + unmasked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, diff --git a/crates/types-traits/domain_types/src/connector_response_masking.rs b/crates/types-traits/domain_types/src/connector_response_masking.rs index 403aa4d5b2..53382901f8 100644 --- a/crates/types-traits/domain_types/src/connector_response_masking.rs +++ b/crates/types-traits/domain_types/src/connector_response_masking.rs @@ -33,7 +33,7 @@ pub const MASKED: &str = "***"; /// There is deliberately no global key list: a field name that is safe on one gateway is not /// necessarily safe on another. A connector with no entry gets every value masked, with every key /// still visible. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(default)] pub struct ConnectorResponseMaskingConfig { /// Whether to populate `unmasked_connector_response` at all. @@ -98,15 +98,6 @@ where .serialize(serializer) } -impl Default for ConnectorResponseMaskingConfig { - fn default() -> Self { - Self { - enabled: false, - connector_keys: HashMap::new(), - } - } -} - impl ConnectorResponseMaskingConfig { /// Build the set of keys whose values stay visible for `connector`. /// @@ -459,7 +450,5 @@ pub fn mask_connector_response( }; // Emitted whole: the full body is the point, so there is no truncation. - Some( - masked.unwrap_or_else(|| format!(r#"{{"_format":"unparseable","_bytes":{}}}"#, body.len())), - ) + Some(masked.unwrap_or_else(|| format!(r#"{{"_format":"unparsable","_bytes":{}}}"#, body.len()))) } From 6909fa0afbc931ab091122a50fad1a4660936d75 Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Tue, 4 Aug 2026 17:13:11 +0530 Subject: [PATCH 05/14] chore: merge main, cover new call sites, trim comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI builds the merge of this branch with main, not the branch alone. Four commits landed on main meanwhile — Plaid authenticator, currency conversion, PayPal sender_payment_instrument_id — adding call sites this branch had never seen. That is why CI failed while local checks passed. - merge origin/main - `EventProcessingParams` literal added by main (payments.rs, PaymentMethodToken flow) now carries `connector_response_masking` - 3 `PaymentFlowData` / `MerchantAuthenticationFlowData` literals in the new plaid tests carry `unmasked_connector_response` - trim comments in connector_response_masking.rs from a 23% ratio to 12% (repo norm is 8-10%): design rationale lives in the PR description, not in nine-line doc essays. Section-divider banners dropped — only 12 files in the repo use that style. Kept the non-obvious ones: why array elements inherit the mask decision, why XML whitespace is never masked, why nothing is rebuilt after a patch. Verified with CI's own commands, including the `--all-features` flag missing from earlier rounds: RUSTFLAGS="-D warnings" cargo check --workspace --all-features --all-targets cargo clippy --workspace --all-features --all-targets -- -D warnings (exit 0) cargo test -p integration-tests --all-features --lib --no-run cargo test -p composite-service --all-features --test composite_request_schema_check --no-run cargo fmt --all -- --check Co-Authored-By: Claude Opus 5 (1M context) --- .../common/external-services/src/service.rs | 3 +- .../grpc-server/src/server/payments.rs | 1 + .../authenticator_connectors/plaid/test.rs | 3 + .../src/connector_response_masking.rs | 102 ++++-------------- 4 files changed, 25 insertions(+), 84 deletions(-) diff --git a/crates/common/external-services/src/service.rs b/crates/common/external-services/src/service.rs index a1dae16d1f..b9afa4f013 100644 --- a/crates/common/external-services/src/service.rs +++ b/crates/common/external-services/src/service.rs @@ -348,8 +348,7 @@ fn record_unmasked_connector_response( return; }; - // Looked up by name rather than via `http::header::CONTENT_TYPE`: this `HeaderMap` comes - // from reqwest 0.11 (http 0.2), which does not share constants with the http 1.x in scope. + // 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() diff --git a/crates/grpc-server/grpc-server/src/server/payments.rs b/crates/grpc-server/grpc-server/src/server/payments.rs index 9aebfa6c06..cdf91c9d40 100644 --- a/crates/grpc-server/grpc-server/src/server/payments.rs +++ b/crates/grpc-server/grpc-server/src/server/payments.rs @@ -2747,6 +2747,7 @@ 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, + connector_response_masking: &config.connector_response_masking, connector_latency: metadata_payload.connector_latency.clone(), runtime_metadata: &config.runtime_metadata, }; 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..44972aad95 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 { + unmasked_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 { + unmasked_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 { + unmasked_connector_response: None, merchant_id: common_utils::id_type::MerchantId::default(), customer_id: None, connector_customer: None, diff --git a/crates/types-traits/domain_types/src/connector_response_masking.rs b/crates/types-traits/domain_types/src/connector_response_masking.rs index 53382901f8..6f8b509766 100644 --- a/crates/types-traits/domain_types/src/connector_response_masking.rs +++ b/crates/types-traits/domain_types/src/connector_response_masking.rs @@ -1,13 +1,6 @@ -//! Selective, per-connector masking of the raw connector response. -//! -//! `raw_connector_response` is a `Secret`, so any logger collapses the whole body into a -//! single placeholder. This module produces the sibling `unmasked_connector_response`: the same -//! body with **every key preserved** and **every value masked** unless that connector's configured -//! list names it. -//! -//! The output is emitted in the **same format** the gateway used — JSON in, JSON out; XML in, XML -//! out; form-encoded in, form-encoded out. The only thing the three paths share is the -//! per-connector key set. +//! Builds `unmasked_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}; @@ -24,15 +17,9 @@ use crate::connector_types::ConnectorEnum; /// Replacement written in place of a masked value. pub const MASKED: &str = "***"; -// --------------------------------------------------------------------------- -// Configuration -// --------------------------------------------------------------------------- - /// Per-connector configuration controlling which response keys keep their value. /// -/// There is deliberately no global key list: a field name that is safe on one gateway is not -/// necessarily safe on another. A connector with no entry gets every value masked, with every key -/// still visible. +/// 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 { @@ -41,14 +28,8 @@ pub struct ConnectorResponseMaskingConfig { /// Connector -> comma-separated list of keys whose values stay visible. /// - /// Keyed by [`ConnectorEnum`] so an unknown name in TOML or env aborts startup naming the bad - /// key, rather than silently masking everything. - /// - /// The *value* stays a comma-separated string because it is the only shape that can be set per - /// connector from the environment: env vars are always text, and the config crate only splits - /// a key it has been told about by literal path — which cannot be done for an open-ended set - /// of connectors. This is plain deserialized TOML, not a cache; nothing is derived ahead of - /// time. + /// Comma-separated rather than a list because that is the only shape settable per connector + /// from the environment. #[serde( deserialize_with = "deserialize_connector_keys", serialize_with = "serialize_connector_keys" @@ -56,14 +37,8 @@ pub struct ConnectorResponseMaskingConfig { pub connector_keys: HashMap, } -/// Parse map keys through `ConnectorEnum`'s `FromStr` rather than its serde derive. -/// -/// `#[strum(serialize_all = "snake_case")]` governs `FromStr`/`Display`; serde would instead expect -/// the PascalCase variant names. Two reasons that matters: config files spell connectors in -/// lowercase, and the config crate lowercases environment-variable keys, so -/// `CS__…__CONNECTOR_KEYS__ADYEN` arrives as `adyen` and could never match `Adyen`. -/// -/// This is the same route `WebhookSourceVerificationCall` takes (`deserialize_hashset`). +/// Parse map keys via `FromStr`, not the serde derive: strum's `snake_case` applies to `FromStr` +/// only, and the config crate lowercases env-var keys. Same route as `WebhookSourceVerificationCall`. fn deserialize_connector_keys<'de, D>( deserializer: D, ) -> Result, D::Error> @@ -99,13 +74,8 @@ where } impl ConnectorResponseMaskingConfig { - /// Build the set of keys whose values stay visible for `connector`. - /// - /// Called once per request, for the single connector in play — a split plus a handful of small - /// allocations, against a gateway call measured in hundreds of milliseconds. Building on demand - /// rather than caching means a runtime config patch can never leave a stale set behind. - /// - /// A connector with no entry yields an empty set: every value masked, every key still visible. + /// Keys whose values stay visible for `connector`. Built per request rather than cached, so a + /// runtime config patch can never leave a stale set behind. No entry yields an empty set. pub fn keys_for(&self, connector: &ConnectorEnum) -> HashSet> { self.connector_keys .get(connector) @@ -152,13 +122,8 @@ impl Patch for ConnectorResponseMaskingConf } } -// --------------------------------------------------------------------------- -// Key policy -// --------------------------------------------------------------------------- - -/// Keys never revealed whatever the configuration says, matched as a **substring** after stripping -/// non-alphanumerics — so `card_number`, `cardNumber`, `card-number` and `ssl_card_number` all -/// match `cardnumber`. Every entry here must be safe to match mid-word. +/// Never revealed regardless of config. Substring match after stripping non-alphanumerics, so +/// `card_number`, `cardNumber` and `ssl_card_number` all match `cardnumber`. const ALWAYS_MASKED_SUBSTRING: &[&str] = &[ "cardnumber", "cardnum", @@ -176,12 +141,8 @@ const ALWAYS_MASKED_SUBSTRING: &[&str] = &[ "apikey", ]; -/// Keys never revealed, matched **exactly**. -/// -/// `authorization` is here rather than above because substring-matching it would also block -/// `authorizationCode` — a routine, non-sensitive field that connectors return and operators will -/// legitimately want visible. Blocking it would be unfixable from config, and would present as the -/// same "I configured it but it is still `***`" confusion this feature exists to remove. +/// 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. @@ -211,8 +172,7 @@ fn allowed(keys: &HashSet>, key: &str) -> bool { if keys.contains(key.to_ascii_lowercase().as_str()) { return true; } - // XML names may be prefixed (`s:authCode`); configuring the local name is what a - // reader expects, so accept that too. + // 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())) } @@ -223,15 +183,8 @@ fn is_namespace_declaration(name: &str) -> bool { name == "xmlns" || name.starts_with("xmlns:") } -// --------------------------------------------------------------------------- -// JSON — mask while serializing, never mutate the tree -// --------------------------------------------------------------------------- - -/// Serializes a [`Value`], substituting `"***"` for scalars whose key is not allowed. -/// -/// `mask` carries the decision made about the *parent key*, which is what lets array elements -/// inherit it — masking a tree in place would leave array scalars untouched, because they reach -/// the walker with no key in scope. +/// Serializes a [`Value`], substituting `"***"` for scalars whose key is not allowed. `mask` +/// carries the parent key's decision so array elements inherit it. struct Masked<'a> { value: &'a Value, keys: &'a HashSet>, @@ -288,12 +241,8 @@ fn mask_json(body: &[u8], keys: &HashSet>) -> Option { .ok() } -// --------------------------------------------------------------------------- -// XML — copy the event stream, rewrite only values -// --------------------------------------------------------------------------- - -/// Rebuild a start/empty tag with the same name, masking attribute values whose name is not -/// allowed. Values are unescaped before being pushed back, because `push_attribute` re-escapes. +/// 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); @@ -338,8 +287,7 @@ fn mask_xml(body: &[u8], keys: &HashSet>) -> Option { writer.write_event(Event::End(tag)).ok()?; } Event::Text(text) => { - // Whitespace between elements is layout, not data — never mask it, or - // pretty-printed XML turns into a wall of markers. + // 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 { @@ -369,10 +317,6 @@ fn mask_xml(body: &[u8], keys: &HashSet>) -> Option { String::from_utf8(writer.into_inner()).ok() } -// --------------------------------------------------------------------------- -// Form-urlencoded -// --------------------------------------------------------------------------- - fn mask_form(body: &[u8], keys: &HashSet>) -> Option { // A Vec rather than a map so repeated keys survive. let pairs: Vec<(String, String)> = serde_urlencoded::from_bytes(body).ok()?; @@ -389,10 +333,6 @@ fn mask_form(body: &[u8], keys: &HashSet>) -> Option { serde_urlencoded::to_string(masked).ok() } -// --------------------------------------------------------------------------- -// Entry point -// --------------------------------------------------------------------------- - /// Wire format of a connector response body. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Format { @@ -438,8 +378,6 @@ pub fn mask_connector_response( return None; } - // Built here, for this connector only. Empty if it has no configured list, which still shows - // every key with every value masked. let keys = config.keys_for(connector); let masked = match detect(content_type, body) { From 73ee3ff559ff69f9fc883a659c4083fe6d72538d Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Tue, 4 Aug 2026 17:39:37 +0530 Subject: [PATCH 06/14] perf(core): check the allowlist before the denylist in allowed() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_always_masked` normalises the key (allocating a String) then scans it against 16 constant patterns. It ran first, for every field — including the majority that are not allowlisted and get masked regardless. Swapping the two is behaviour-preserving: the denylist only ever overrides a key the allowlist would have revealed, so for a key absent from the allowlist its verdict never mattered. On the verified Adyen response — 51 additionalData fields, 1 allowlisted — the denylist scan drops from 51 executions to 1. Also sharpens the denylist's documented scope. It covers full PAN, CVV, expiry and credentials; it does not cover truncated values such as cardSummary, last4 or cardBin. Those are not PAN, they appear on receipts, and a connector that needs them for reconciliation can name them in its own list. The previous wording implied broader coverage than the patterns actually provide. Verified against the Adyen sandbox: `expiryDate` placed deliberately in the allowlist still returns "***", proving the denylist still overrides config; the shipped-config baseline is byte-identical to before the change, with no PAN anywhere in the body. Co-Authored-By: Claude Opus 5 (1M context) --- config/development.toml | 3 +- config/production.toml | 3 +- config/sandbox.toml | 3 +- .../src/connector_response_masking.rs | 28 +++++++++++++------ 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/config/development.toml b/config/development.toml index 1d79887162..84b46bb2e7 100644 --- a/config/development.toml +++ b/config/development.toml @@ -247,7 +247,8 @@ enabled = true # Per-connector unmask lists, comma-separated and case-insensitive. # A connector with no entry gets every value masked (keys still visible). -# Never list card/CVV/token-like keys: those stay masked regardless. +# Full PAN, CVV, expiry and credentials stay masked regardless of what is listed here. +# Truncated values (cardSummary, last4, cardBin) are not covered — name them if needed. # # 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 diff --git a/config/production.toml b/config/production.toml index e5699c61fc..0a447515f2 100644 --- a/config/production.toml +++ b/config/production.toml @@ -188,7 +188,8 @@ enabled = true # Per-connector unmask lists, comma-separated and case-insensitive. # A connector with no entry gets every value masked (keys still visible). -# Never list card/CVV/token-like keys: those stay masked regardless. +# Full PAN, CVV, expiry and credentials stay masked regardless of what is listed here. +# Truncated values (cardSummary, last4, cardBin) are not covered — name them if needed. # # 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 diff --git a/config/sandbox.toml b/config/sandbox.toml index f6195122a2..88db67e2dd 100644 --- a/config/sandbox.toml +++ b/config/sandbox.toml @@ -189,7 +189,8 @@ enabled = true # Per-connector unmask lists, comma-separated and case-insensitive. # A connector with no entry gets every value masked (keys still visible). -# Never list card/CVV/token-like keys: those stay masked regardless. +# Full PAN, CVV, expiry and credentials stay masked regardless of what is listed here. +# Truncated values (cardSummary, last4, cardBin) are not covered — name them if needed. # # 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 diff --git a/crates/types-traits/domain_types/src/connector_response_masking.rs b/crates/types-traits/domain_types/src/connector_response_masking.rs index 6f8b509766..36fcc7cac5 100644 --- a/crates/types-traits/domain_types/src/connector_response_masking.rs +++ b/crates/types-traits/domain_types/src/connector_response_masking.rs @@ -124,6 +124,11 @@ impl Patch for ConnectorResponseMaskingConf /// 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", @@ -164,17 +169,22 @@ fn is_always_masked(key: &str) -> bool { .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 { - if is_always_masked(key) { - return false; - } - if keys.contains(key.to_ascii_lowercase().as_str()) { - return true; - } - // 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())) + in_allowlist(keys, key) && !is_always_masked(key) } /// Namespace declarations are structural: masking them would break prefix resolution, and they From 95ecc2de83d84c7826e198a4b4a3e9963ad0a3ba Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Tue, 4 Aug 2026 17:57:02 +0530 Subject: [PATCH 07/14] fix(core): handle newline-separated and BOM-prefixed connector responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking every RequestContent variant against the response path found two defects. The masker reads the pre-preprocessing bytes — what the gateway put on the wire — which is right for a diagnostic but means it must cope with raw shapes that 10+ connectors normalise away in preprocess_response_bytes. Newline-separated bodies could leak. Fiuu replies with `key=value` pairs separated by newlines, not `&` (see connectors/fiuu.rs:176). serde_urlencoded splits only on `&`, so the whole body folded into the first pair's value: key='Status' value='00\nTranID=…\nCardNo=411111xxxxxx1111\nAmount=…' An operator allowlisting `status` — the natural first choice — would have revealed that value and everything inside it. Normalising literal newline bytes to `&` before parsing keeps each pair distinct. Percent-encoded %0A inside a genuine form value is untouched, so real urlencoded bodies are unaffected. Verified end-to-end against a mock serving Fiuu's exact shape: the masked body is now `Status=00&TranID=***&CardNo=***&Amount=***&Domain=***`. BOM defeated every parser. Authorize.Net prefixes responses with a UTF-8 BOM and strips it in preprocess_response_bytes — which runs inside handle_response_v2, after masking. serde_json rejected the BOM and every such response silently yielded the `_format` stub. Stripping it before `detect` also fixes the no-Content-Type case, where the BOM made the first meaningful byte 0xEF and misrouted sniffing to the form parser. Multipart and binary bodies still yield the size-only stub, deliberately: RequestContent::FormData is only ever built request-side, and binary cannot be masked meaningfully. Adyen unchanged: pspReference, resultCode, refusalReason, merchantReference visible, 51 additionalData keys, everything else masked. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/connector_response_masking.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/types-traits/domain_types/src/connector_response_masking.rs b/crates/types-traits/domain_types/src/connector_response_masking.rs index 36fcc7cac5..d1a808f099 100644 --- a/crates/types-traits/domain_types/src/connector_response_masking.rs +++ b/crates/types-traits/domain_types/src/connector_response_masking.rs @@ -328,8 +328,20 @@ fn mask_xml(body: &[u8], keys: &HashSet>) -> Option { } 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(); + // A Vec rather than a map so repeated keys survive. - let pairs: Vec<(String, String)> = serde_urlencoded::from_bytes(body).ok()?; + let pairs: Vec<(String, String)> = serde_urlencoded::from_bytes(&normalised).ok()?; let masked = pairs .into_iter() .map(|(key, value)| { @@ -388,6 +400,12 @@ pub fn mask_connector_response( 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 = body.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(body); + let keys = config.keys_for(connector); let masked = match detect(content_type, body) { From c8ac48f3335acb563f26567ce6de9535bc4eb29e Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Thu, 6 Aug 2026 13:21:21 +0530 Subject: [PATCH 08/14] fix(core): resolve the masking connector by name, not a ConnectorEnum re-parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review comments on #2050. The middle one turned out to sit on a live bug. record_unmasked_connector_response re-parsed an already-validated connector name against the wrong authority. Ingress resolves a connector per flow family, each against its own enum — x-connector via ConnectorEnum, x-payout-connector via PayoutConnectorEnum, and so on (metadata.rs:113) — and rejects an unknown name with InvalidDataFormat before any connector call. Masking then asked "is it a payment connector?" when the only question it needed answered was "what is its name?", and treated the mismatch as absence: let Ok(connector) = ConnectorEnum::from_str(connector_name) else { return; }; Three ingress-valid connectors have no ConnectorEnum counterpart, so the field was silently None for them: Interpayments (surcharge), Deutschebank (payout), Plaid (authenticator). The breakage was partial rather than uniform — Loonio, Paypal, Itaubank, Worldpayxml, Cybersource (payout) and Kount (FRM) collide with ConnectorEnum names and worked — so behaviour differed per connector inside the same flow with no signal. The comment above the branch claimed it always round-trips; it does not. Same root cause reached config load: deserialize_connector_keys also parsed keys with ConnectorEnum::from_str, so `deutschebank = "..."` aborted startup naming a perfectly real connector. Rather than logging the silent exit, the re-parse is gone. connector_keys is keyed by the snake_case name, which is canonical by the time it arrives, and config-load validation accepts any name the five enums recognise. A lookup miss degrades to the empty allowlist — already the right outcome for an unconfigured connector — so no failure branch remains. hyperswitch::Connector was considered as a single authority and rejected: it omits 15 UCS connectors including Kount, and is a pinned git snapshot. Verified against the built binary: with interpayments/deutschebank/plaid as config keys the server boots; `paysafee` still aborts with "unknown connector `paysafee`". Also in this commit: log_to_span gates the span record independently of enabled, so the caller is sent unmasked_connector_response without a copy being retained in our logs. The containment argument holds under static config too — a typo in the TOML writes to our logs today. true in development, false in sandbox and production. strip_utf8_bom moves to common_utils::bytes_utils, backing both the masker and strip_bom_and_convert_to_string. The existing helper could not be reused in place: it is private to external-services, domain_types cannot depend on that crate, and it returns Option where the masker needs &[u8] -> &[u8] before detect() sniffs raw bytes. Repeated BOMs are still stripped, so service.rs is unchanged behaviourally. The const denylist stays. Default is mask-everything, so an unknown sensitive field is masked because it was never allowlisted; the denylist only fires on keys an operator explicitly allowlisted, making it a backstop rather than the primary defence. Co-Authored-By: Claude Opus 5 (1M context) --- config/development.toml | 8 ++ config/production.toml | 5 + config/sandbox.toml | 5 + crates/common/common_utils/src/bytes_utils.rs | 16 ++++ crates/common/common_utils/src/lib.rs | 1 + .../common/external-services/src/service.rs | 34 +++---- .../src/connector_response_masking.rs | 96 +++++++++++-------- 7 files changed, 105 insertions(+), 60 deletions(-) create mode 100644 crates/common/common_utils/src/bytes_utils.rs diff --git a/config/development.toml b/config/development.toml index 84b46bb2e7..bd98d1de0b 100644 --- a/config/development.toml +++ b/config/development.toml @@ -245,11 +245,19 @@ keys = ["x-request-id","x-merchant-id","x-lineage-ids","x-reference-id","x-conne [connector_response_masking] enabled = true +# Whether to ALSO write the masked view to our own logs (`response.unmasked_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). # Full PAN, CVV, expiry and credentials stay masked regardless of what is listed here. # Truncated values (cardSummary, last4, cardBin) are not covered — name them if needed. # +# 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. diff --git a/config/production.toml b/config/production.toml index 0a447515f2..724bc50f93 100644 --- a/config/production.toml +++ b/config/production.toml @@ -186,6 +186,11 @@ connectors_with_webhook_source_verification_call = "paypal, truelayer" [connector_response_masking] enabled = true +# Whether to ALSO write the masked view to our own logs (`response.unmasked_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). # Full PAN, CVV, expiry and credentials stay masked regardless of what is listed here. diff --git a/config/sandbox.toml b/config/sandbox.toml index 88db67e2dd..fb5a16788a 100644 --- a/config/sandbox.toml +++ b/config/sandbox.toml @@ -187,6 +187,11 @@ connectors_with_webhook_source_verification_call = "paypal, truelayer" [connector_response_masking] enabled = true +# Whether to ALSO write the masked view to our own logs (`response.unmasked_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). # Full PAN, CVV, expiry and credentials stay masked regardless of what is listed here. 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/lib.rs b/crates/common/common_utils/src/lib.rs index c9b842f21b..e7e1025ba2 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; diff --git a/crates/common/external-services/src/service.rs b/crates/common/external-services/src/service.rs index b9afa4f013..c51bc23fa3 100644 --- a/crates/common/external-services/src/service.rs +++ b/crates/common/external-services/src/service.rs @@ -331,6 +331,10 @@ fn flow_status_label(flow_status: &domain_types::router_data::FlowStatus) -> Str /// /// 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. fn record_unmasked_connector_response( resource_common_data: &mut ResourceCommonData, body: &Response, @@ -339,15 +343,6 @@ fn record_unmasked_connector_response( ) where ResourceCommonData: RawConnectorRequestResponse, { - use std::str::FromStr; - - // `connector_name` came from `ConnectorEnum::get_connector_name()`, and the enum derives - // `EnumString` with snake_case, so this always round-trips. - let Ok(connector) = domain_types::connector_types::ConnectorEnum::from_str(connector_name) - else { - return; - }; - // By name: this HeaderMap is reqwest 0.11 (http 0.2), not the http 1.x in scope. let content_type = body .headers @@ -358,12 +353,17 @@ fn record_unmasked_connector_response( let masked = domain_types::connector_response_masking::mask_connector_response( &body.response, content_type, - &connector, + connector_name, config, ); - if let Some(masked) = masked.as_deref() { - tracing::Span::current().record("response.unmasked_body", tracing::field::display(masked)); + // 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.unmasked_body", tracing::field::display(masked)); + } } resource_common_data.set_unmasked_connector_response(masked); @@ -1686,14 +1686,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/types-traits/domain_types/src/connector_response_masking.rs b/crates/types-traits/domain_types/src/connector_response_masking.rs index d1a808f099..155a801ad9 100644 --- a/crates/types-traits/domain_types/src/connector_response_masking.rs +++ b/crates/types-traits/domain_types/src/connector_response_masking.rs @@ -12,7 +12,10 @@ use serde_json::Value; use common_utils::config_patch::Patch; -use crate::connector_types::ConnectorEnum; +use crate::connector_types::{ + AuthenticatorConnectorEnum, ConnectorEnum, FrmConnectorEnum, PayoutConnectorEnum, + SurchargeConnectorEnum, +}; /// Replacement written in place of a masked value. pub const MASKED: &str = "***"; @@ -26,59 +29,67 @@ pub struct ConnectorResponseMaskingConfig { /// Whether to populate `unmasked_connector_response` at all. pub enabled: bool, - /// Connector -> comma-separated list of keys whose values stay visible. + /// Whether to *also* record the masked view on the outgoing span. Separate from + /// [`Self::enabled`] so the caller can be sent the field without a copy being retained in our + /// own logs, keeping a mistaken allowlist entry contained to whoever configured it. + 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. - #[serde( - deserialize_with = "deserialize_connector_keys", - serialize_with = "serialize_connector_keys" - )] - pub connector_keys: HashMap, + /// 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() } -/// Parse map keys via `FromStr`, not the serde derive: strum's `snake_case` applies to `FromStr` -/// only, and the config crate lowercases env-var keys. Same route as `WebhookSourceVerificationCall`. +/// Validate map keys via `FromStr`, not the serde derive: strum's `snake_case` applies to +/// `FromStr` only, and the config crate lowercases env-var keys. Same route as +/// `WebhookSourceVerificationCall`. fn deserialize_connector_keys<'de, D>( deserializer: D, -) -> Result, D::Error> +) -> Result, String>, D::Error> where D: serde::Deserializer<'de>, { use serde::de::Error; - use std::str::FromStr; HashMap::::deserialize(deserializer)? .into_iter() .map(|(name, keys)| { - ConnectorEnum::from_str(&name.to_lowercase()) - .map(|connector| (connector, keys)) - .map_err(|_| D::Error::custom(format!("unknown connector `{name}`"))) + 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() } -/// Mirror of [`deserialize_connector_keys`] — emit the snake_case name, not the variant name. -fn serialize_connector_keys( - connector_keys: &HashMap, - serializer: S, -) -> Result -where - S: Serializer, -{ - connector_keys - .iter() - .map(|(connector, keys)| (connector.to_string(), keys)) - .collect::>() - .serialize(serializer) -} - impl ConnectorResponseMaskingConfig { - /// Keys whose values stay visible for `connector`. Built per request rather than cached, so a - /// runtime config patch can never leave a stale set behind. No entry yields an empty set. - pub fn keys_for(&self, connector: &ConnectorEnum) -> HashSet> { + /// 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) + .get(connector_name) .map(|keys| { keys.split(',') .map(str::trim) @@ -96,14 +107,16 @@ impl ConnectorResponseMaskingConfig { 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>, + pub connector_keys: Option, String>>, } fn deserialize_optional_connector_keys<'de, D>( deserializer: D, -) -> Result>, D::Error> +) -> Result, String>>, D::Error> where D: serde::Deserializer<'de>, { @@ -115,6 +128,9 @@ impl Patch for ConnectorResponseMaskingConf 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; } @@ -386,14 +402,14 @@ fn detect(content_type: Option<&str>, body: &[u8]) -> Option { } } -/// Mask `body` for `connector` and re-emit it in the same format. +/// 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: &ConnectorEnum, + connector_name: &str, config: &ConnectorResponseMaskingConfig, ) -> Option { if !config.enabled || body.is_empty() { @@ -404,9 +420,9 @@ pub fn mask_connector_response( // 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 = body.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(body); + let body = common_utils::bytes_utils::strip_utf8_bom(body); - let keys = config.keys_for(connector); + let keys = config.keys_for(connector_name); let masked = match detect(content_type, body) { Some(Format::Json) => mask_json(body, &keys), From 709b850937dba41b9eb8cdebdbbecc7be30c3a7f Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Thu, 6 Aug 2026 15:33:28 +0530 Subject: [PATCH 09/14] refactor(core): rename unmasked_connector_response to masked_connector_response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from @jarnura on payment.proto: the field name is backwards. It carries the connector's reply with every value masked ("***") except those an operator allowlisted per connector — the masked view, not an unmasked one. Read literally, "unmasked_connector_response" promises masking has been removed, which is the opposite of what it holds. That mattered more than usual because of what sits beside it. The same structs already carry raw_connector_response, which IS the verbatim unmasked body. So the file offered a reviewer two fields, `raw_` and `unmasked_`, and the safe one was the one whose name promised no masking. Anyone reasoning about PII exposure from field names alone would have picked wrong. Pure identifier rename, no behavioural change: unmasked_connector_response -> masked_connector_response (260) unmasked_body -> masked_body (5) Proto field tags are untouched, so the wire format is unchanged; only the generated accessor names move. Not a compatibility break — these fields do not exist on main, they are introduced by this PR. Deliberately left alone: raw_connector_response (394 occurrences, the genuinely unmasked sibling), unmasked_headers (11, unrelated log config), and mask_connector_response / ConnectorResponseMaskingConfig / MASKED, all of which already describe masking correctly. Incidentally unblocks SDK Tests, which should not be relied on. That job fails because protoc emits every payment.proto message into one outer class (java_multiple_files is unset) and the generated Payment.java reached 20,973,967 bytes against the Kotlin compiler's embedded IntelliJ cap of 20,971,520 — over by 2,447 bytes, surfacing as FileTooBigException -> "Internal compiler error". The shorter identifier reclaims 3,006 bytes across every generated accessor, constant and descriptor string, landing ~559 bytes under the cap. That is a coincidence, not a fix: main alone sits ~1% below the same ceiling, and arduino/setup-protoc@v3 is unpinned in ci.yml so generated size drifts upward with each protoc release. Pinning protoc and raising kotlin.daemon.jvmargs remains open, and belongs on main rather than buried in a review-feedback PR. Verified: zero residual occurrences, guard-string counts unchanged, cargo check --workspace --all-targets / clippy / fmt clean, test_config_override green. Co-Authored-By: Claude Opus 5 (1M context) --- config/development.toml | 4 +- config/production.toml | 4 +- config/sandbox.toml | 4 +- .../common/external-services/src/service.rs | 14 +- crates/common/ucs_env/src/configs.rs | 2 +- .../grpc-server/src/server/events.rs | 2 +- .../grpc-server/src/server/payments.rs | 8 +- .../authenticator_connectors/plaid/test.rs | 6 +- .../src/connectors/adyen/test.rs | 4 +- .../src/connectors/calida/test.rs | 4 +- .../src/connectors/razorpay/test.rs | 24 +- .../src/connector_response_masking.rs | 4 +- .../domain_types/src/connector_types.rs | 54 ++-- .../domain_types/src/frm/frm_types.rs | 10 +- .../domain_types/src/frm/types.rs | 22 +- .../src/merchant_authentication_flow_data.rs | 10 +- .../domain_types/src/payouts/payouts_types.rs | 10 +- .../domain_types/src/payouts/types.rs | 18 +- .../src/surcharge/surcharge_types.rs | 10 +- .../domain_types/src/surcharge/types.rs | 4 +- crates/types-traits/domain_types/src/types.rs | 262 +++++++++--------- .../grpc-api-types/proto/frm.proto | 4 +- .../grpc-api-types/proto/payment.proto | 42 +-- 23 files changed, 263 insertions(+), 263 deletions(-) diff --git a/config/development.toml b/config/development.toml index bd98d1de0b..154920b3b9 100644 --- a/config/development.toml +++ b/config/development.toml @@ -239,13 +239,13 @@ enqueue_timeout_ms = 5000 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 -# `unmasked_connector_response`. Every key is preserved; a value is shown only if +# `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. [connector_response_masking] enabled = true -# Whether to ALSO write the masked view to our own logs (`response.unmasked_body`). +# 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 diff --git a/config/production.toml b/config/production.toml index 724bc50f93..53186568f4 100644 --- a/config/production.toml +++ b/config/production.toml @@ -180,13 +180,13 @@ keys = ["x-request-id","x-merchant-id","x-lineage-ids","x-reference-id","x-conne connectors_with_webhook_source_verification_call = "paypal, truelayer" # Selectively-masked view of the connector response, exposed as -# `unmasked_connector_response`. Every key is preserved; a value is shown only if +# `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. [connector_response_masking] enabled = true -# Whether to ALSO write the masked view to our own logs (`response.unmasked_body`). +# 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 diff --git a/config/sandbox.toml b/config/sandbox.toml index fb5a16788a..44cf743c55 100644 --- a/config/sandbox.toml +++ b/config/sandbox.toml @@ -181,13 +181,13 @@ keys = ["x-request-id","x-merchant-id","x-lineage-ids","x-reference-id","x-conne connectors_with_webhook_source_verification_call = "paypal, truelayer" # Selectively-masked view of the connector response, exposed as -# `unmasked_connector_response`. Every key is preserved; a value is shown only if +# `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. [connector_response_masking] enabled = true -# Whether to ALSO write the masked view to our own logs (`response.unmasked_body`). +# 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 diff --git a/crates/common/external-services/src/service.rs b/crates/common/external-services/src/service.rs index c51bc23fa3..64f5e77181 100644 --- a/crates/common/external-services/src/service.rs +++ b/crates/common/external-services/src/service.rs @@ -335,7 +335,7 @@ fn flow_status_label(flow_status: &domain_types::router_data::FlowStatus) -> Str /// `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. -fn record_unmasked_connector_response( +fn record_masked_connector_response( resource_common_data: &mut ResourceCommonData, body: &Response, connector_name: &str, @@ -362,11 +362,11 @@ fn record_unmasked_connector_response( if config.log_to_span { if let Some(masked) = masked.as_deref() { tracing::Span::current() - .record("response.unmasked_body", tracing::field::display(masked)); + .record("response.masked_body", tracing::field::display(masked)); } } - resource_common_data.set_unmasked_connector_response(masked); + resource_common_data.set_masked_connector_response(masked); } /// Handles the connector response, processing both successful and error responses @@ -413,7 +413,7 @@ where if let Some(params) = event_params.filter(|p| p.connector_response_masking.enabled) { - record_unmasked_connector_response( + record_masked_connector_response( &mut updated_router_data.resource_common_data, &body, params.connector_name, @@ -482,7 +482,7 @@ where if let Some(params) = event_params.filter(|p| p.connector_response_masking.enabled) { - record_unmasked_connector_response( + record_masked_connector_response( &mut updated_router_data.resource_common_data, &body, params.connector_name, @@ -599,7 +599,7 @@ 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 `unmasked_connector_response`. Gated by its own + /// Per-connector key lists driving `masked_connector_response`. Gated by its own /// `enabled` flag, deliberately independent of `return_raw_connector_data`. pub connector_response_masking: &'a domain_types::connector_response_masking::ConnectorResponseMaskingConfig, @@ -616,7 +616,7 @@ pub struct EventProcessingParams<'a> { request.url = Empty, request.method = Empty, response.body = Empty, - response.unmasked_body = Empty, + response.masked_body = Empty, response.headers = Empty, response.error_message = Empty, response.status_code = Empty, diff --git a/crates/common/ucs_env/src/configs.rs b/crates/common/ucs_env/src/configs.rs index d6ff5013c4..d373bf537d 100644 --- a/crates/common/ucs_env/src/configs.rs +++ b/crates/common/ucs_env/src/configs.rs @@ -38,7 +38,7 @@ pub struct Config { #[serde(default)] pub unmasked_headers: HeaderMaskingConfig, /// Per-connector key lists controlling which response values stay visible in - /// `unmasked_connector_response`. + /// `masked_connector_response`. #[serde(default)] pub connector_response_masking: ConnectorResponseMaskingConfig, #[serde(default)] diff --git a/crates/grpc-server/grpc-server/src/server/events.rs b/crates/grpc-server/grpc-server/src/server/events.rs index 40ffa6b1c5..bd495e7bae 100644 --- a/crates/grpc-server/grpc-server/src/server/events.rs +++ b/crates/grpc-server/grpc-server/src/server/events.rs @@ -980,7 +980,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, - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_request: None, connector_response_headers: None, }; diff --git a/crates/grpc-server/grpc-server/src/server/payments.rs b/crates/grpc-server/grpc-server/src/server/payments.rs index cdf91c9d40..660b73f36a 100644 --- a/crates/grpc-server/grpc-server/src/server/payments.rs +++ b/crates/grpc-server/grpc-server/src/server/payments.rs @@ -3860,9 +3860,9 @@ pub fn generate_mandate_revoke_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -3892,7 +3892,7 @@ pub fn generate_mandate_revoke_response( network_transaction_id: None, merchant_revoke_id: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_request, }), Err(e) => Ok(RecurringPaymentServiceRevokeResponse { @@ -3913,7 +3913,7 @@ pub fn generate_mandate_revoke_response( network_transaction_id: None, merchant_revoke_id: e.connector_transaction_id, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_request, }), } 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 44972aad95..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,7 +41,7 @@ mod tests { RouterDataV2 { flow: PhantomData, resource_common_data: MerchantAuthenticationFlowData { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id: common_utils::id_type::MerchantId::default(), connectors: Connectors::default(), connector_request_reference_id: "ref_test".to_owned(), @@ -202,7 +202,7 @@ mod tests { RouterDataV2 { flow: PhantomData, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id: common_utils::id_type::MerchantId::default(), customer_id: None, connector_customer: None, @@ -361,7 +361,7 @@ mod tests { RouterDataV2 { flow: PhantomData, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + 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 441290c0ff..fe7281855d 100644 --- a/crates/integrations/connector-integration/src/connectors/adyen/test.rs +++ b/crates/integrations/connector-integration/src/connectors/adyen/test.rs @@ -38,7 +38,7 @@ mod tests { > = RouterDataV2 { flow: PhantomData::, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: common_utils::id_type::MerchantId::default(), customer_id: None, @@ -246,7 +246,7 @@ mod tests { > = RouterDataV2 { flow: PhantomData::, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + 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 d7dd17d5ae..4ee4017a4d 100644 --- a/crates/integrations/connector-integration/src/connectors/calida/test.rs +++ b/crates/integrations/connector-integration/src/connectors/calida/test.rs @@ -43,7 +43,7 @@ mod tests { > = RouterDataV2 { flow: PhantomData::, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, vault_headers: None, merchant_id: common_utils::id_type::MerchantId::default(), @@ -231,7 +231,7 @@ mod tests { > = RouterDataV2 { flow: PhantomData::, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + 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 69d698d392..4f975cb090 100644 --- a/crates/integrations/connector-integration/src/connectors/razorpay/test.rs +++ b/crates/integrations/connector-integration/src/connectors/razorpay/test.rs @@ -59,7 +59,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -275,7 +275,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -429,7 +429,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -593,7 +593,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -967,7 +967,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1177,7 +1177,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1389,7 +1389,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: domain_types::connector_types::PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1526,7 +1526,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: domain_types::connector_types::PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1654,7 +1654,7 @@ mod tests { let test_router_data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1817,7 +1817,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -1954,7 +1954,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, @@ -2080,7 +2080,7 @@ mod tests { let data = RouterDataV2 { flow: std::marker::PhantomData, resource_common_data: PaymentFlowData { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: MerchantId::default(), customer_id: None, diff --git a/crates/types-traits/domain_types/src/connector_response_masking.rs b/crates/types-traits/domain_types/src/connector_response_masking.rs index 155a801ad9..899d6a8b98 100644 --- a/crates/types-traits/domain_types/src/connector_response_masking.rs +++ b/crates/types-traits/domain_types/src/connector_response_masking.rs @@ -1,4 +1,4 @@ -//! Builds `unmasked_connector_response`: the connector's reply with every key preserved and every +//! 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. @@ -26,7 +26,7 @@ pub const MASKED: &str = "***"; #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(default)] pub struct ConnectorResponseMaskingConfig { - /// Whether to populate `unmasked_connector_response` at all. + /// Whether to populate `masked_connector_response` at all. pub enabled: bool, /// Whether to *also* record the masked view on the outgoing span. Separate from diff --git a/crates/types-traits/domain_types/src/connector_types.rs b/crates/types-traits/domain_types/src/connector_types.rs index df38528d67..9fb861eb9c 100644 --- a/crates/types-traits/domain_types/src/connector_types.rs +++ b/crates/types-traits/domain_types/src/connector_types.rs @@ -595,8 +595,8 @@ pub trait RawConnectorRequestResponse { /// 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_unmasked_connector_response(&mut self, response: Option); - fn get_unmasked_connector_response(&self) -> Option; + fn set_masked_connector_response(&mut self, response: Option); + fn get_masked_connector_response(&self) -> Option; } pub trait ConnectorResponseHeaders { @@ -797,7 +797,7 @@ pub struct PaymentFlowData { pub connectors: Connectors, pub raw_connector_response: Option>, /// Same body, values masked per the connector's config. Already sanitized — not a `Secret`. - pub unmasked_connector_response: Option, + 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 @@ -1488,12 +1488,12 @@ impl RawConnectorRequestResponse for PaymentFlowData { self.raw_connector_response.clone() } - fn set_unmasked_connector_response(&mut self, response: Option) { - self.unmasked_connector_response = response; + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; } - fn get_unmasked_connector_response(&self) -> Option { - self.unmasked_connector_response.clone() + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() } fn get_raw_connector_request(&self) -> Option> { @@ -2691,7 +2691,7 @@ pub struct RefundFlowData { 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 unmasked_connector_response: Option, + pub masked_connector_response: Option, pub connector_response_headers: Option, pub raw_connector_request: Option>, pub access_token: Option, @@ -2715,12 +2715,12 @@ impl RawConnectorRequestResponse for RefundFlowData { self.raw_connector_response.clone() } - fn set_unmasked_connector_response(&mut self, response: Option) { - self.unmasked_connector_response = response; + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; } - fn get_unmasked_connector_response(&self) -> Option { - self.unmasked_connector_response.clone() + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() } fn get_raw_connector_request(&self) -> Option> { @@ -3736,7 +3736,7 @@ pub struct DisputeFlowData { 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 unmasked_connector_response: Option, + pub masked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -3750,12 +3750,12 @@ impl RawConnectorRequestResponse for DisputeFlowData { self.raw_connector_response.clone() } - fn set_unmasked_connector_response(&mut self, response: Option) { - self.unmasked_connector_response = response; + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; } - fn get_unmasked_connector_response(&self) -> Option { - self.unmasked_connector_response.clone() + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() } fn set_raw_connector_request(&mut self, request: Option>) { @@ -3783,7 +3783,7 @@ pub struct VerifyWebhookSourceFlowData { 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 unmasked_connector_response: Option, + pub masked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -3797,12 +3797,12 @@ impl RawConnectorRequestResponse for VerifyWebhookSourceFlowData { self.raw_connector_response.clone() } - fn set_unmasked_connector_response(&mut self, response: Option) { - self.unmasked_connector_response = response; + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; } - fn get_unmasked_connector_response(&self) -> Option { - self.unmasked_connector_response.clone() + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() } fn get_raw_connector_request(&self) -> Option> { @@ -3831,7 +3831,7 @@ pub struct RefreshPaymentMethodFlowData { /// 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 unmasked_connector_response: Option, + pub masked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -3845,12 +3845,12 @@ impl RawConnectorRequestResponse for RefreshPaymentMethodFlowData { self.raw_connector_response.clone() } - fn set_unmasked_connector_response(&mut self, response: Option) { - self.unmasked_connector_response = response; + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; } - fn get_unmasked_connector_response(&self) -> Option { - self.unmasked_connector_response.clone() + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() } fn get_raw_connector_request(&self) -> Option> { 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 c2041e46ea..a99168f13b 100644 --- a/crates/types-traits/domain_types/src/frm/frm_types.rs +++ b/crates/types-traits/domain_types/src/frm/frm_types.rs @@ -20,7 +20,7 @@ pub struct FrmFlowData { pub access_token: Option, pub raw_connector_response: Option>, /// Same body, values masked per the connector's config. Already sanitized — not a `Secret`. - pub unmasked_connector_response: Option, + pub masked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -34,12 +34,12 @@ impl RawConnectorRequestResponse for FrmFlowData { self.raw_connector_response.clone() } - fn set_unmasked_connector_response(&mut self, response: Option) { - self.unmasked_connector_response = response; + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; } - fn get_unmasked_connector_response(&self) -> Option { - self.unmasked_connector_response.clone() + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() } fn get_raw_connector_request(&self) -> Option> { diff --git a/crates/types-traits/domain_types/src/frm/types.rs b/crates/types-traits/domain_types/src/frm/types.rs index 5cc9ce1abb..24d03346f2 100644 --- a/crates/types-traits/domain_types/src/frm/types.rs +++ b/crates/types-traits/domain_types/src/frm/types.rs @@ -87,7 +87,7 @@ impl .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id, connectors, access_token, @@ -124,7 +124,7 @@ impl .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id, connectors, access_token, @@ -161,7 +161,7 @@ impl .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id, connectors, access_token, @@ -859,9 +859,9 @@ pub fn generate_pre_risk_check_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -890,7 +890,7 @@ pub fn generate_pre_risk_check_response( error: None, raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, response_headers, } } @@ -913,7 +913,7 @@ pub fn generate_pre_risk_check_response( }), raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, response_headers, }, }; @@ -934,9 +934,9 @@ pub fn generate_post_risk_check_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -965,7 +965,7 @@ pub fn generate_post_risk_check_response( error: None, raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, response_headers, } } @@ -988,7 +988,7 @@ pub fn generate_post_risk_check_response( }), raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, response_headers, }, }; 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 94b5496168..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 @@ -50,7 +50,7 @@ pub struct MerchantAuthenticationFlowData { // ── Observability ────────────────────────────────────────────────────── pub raw_connector_response: Option>, /// Same body, values masked per the connector's config. Already sanitized — not a `Secret`. - pub unmasked_connector_response: Option, + pub masked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -71,12 +71,12 @@ impl RawConnectorRequestResponse for MerchantAuthenticationFlowData { self.raw_connector_response.clone() } - fn set_unmasked_connector_response(&mut self, response: Option) { - self.unmasked_connector_response = response; + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; } - fn get_unmasked_connector_response(&self) -> Option { - self.unmasked_connector_response.clone() + 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 d634c196fd..49c948d84d 100644 --- a/crates/types-traits/domain_types/src/payouts/payouts_types.rs +++ b/crates/types-traits/domain_types/src/payouts/payouts_types.rs @@ -20,7 +20,7 @@ pub struct PayoutFlowData { 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 unmasked_connector_response: Option, + pub masked_connector_response: Option, pub connector_response_headers: Option, pub raw_connector_request: Option>, pub access_token: Option, @@ -37,12 +37,12 @@ impl RawConnectorRequestResponse for PayoutFlowData { self.raw_connector_response.clone() } - fn set_unmasked_connector_response(&mut self, response: Option) { - self.unmasked_connector_response = response; + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; } - fn get_unmasked_connector_response(&self) -> Option { - self.unmasked_connector_response.clone() + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() } fn get_raw_connector_request(&self) -> Option> { diff --git a/crates/types-traits/domain_types/src/payouts/types.rs b/crates/types-traits/domain_types/src/payouts/types.rs index e8d84323ae..24ca87039e 100644 --- a/crates/types-traits/domain_types/src/payouts/types.rs +++ b/crates/types-traits/domain_types/src/payouts/types.rs @@ -30,7 +30,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -1514,7 +1514,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -1556,7 +1556,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -1598,7 +1598,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -1640,7 +1640,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id, payout_id: value.merchant_quote_id.clone().unwrap_or_default(), connectors, @@ -1682,7 +1682,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -1724,7 +1724,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -1766,7 +1766,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id, payout_id: value.merchant_payout_id.clone().unwrap_or_default(), connectors, @@ -2232,7 +2232,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + 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 9950ba3e5c..69ca3a966d 100644 --- a/crates/types-traits/domain_types/src/surcharge/surcharge_types.rs +++ b/crates/types-traits/domain_types/src/surcharge/surcharge_types.rs @@ -14,7 +14,7 @@ pub struct SurchargeFlowData { pub connectors: Connectors, pub raw_connector_response: Option>, /// Same body, values masked per the connector's config. Already sanitized — not a `Secret`. - pub unmasked_connector_response: Option, + pub masked_connector_response: Option, pub raw_connector_request: Option>, pub connector_response_headers: Option, } @@ -28,12 +28,12 @@ impl RawConnectorRequestResponse for SurchargeFlowData { self.raw_connector_response.clone() } - fn set_unmasked_connector_response(&mut self, response: Option) { - self.unmasked_connector_response = response; + fn set_masked_connector_response(&mut self, response: Option) { + self.masked_connector_response = response; } - fn get_unmasked_connector_response(&self) -> Option { - self.unmasked_connector_response.clone() + fn get_masked_connector_response(&self) -> Option { + self.masked_connector_response.clone() } fn get_raw_connector_request(&self) -> Option> { diff --git a/crates/types-traits/domain_types/src/surcharge/types.rs b/crates/types-traits/domain_types/src/surcharge/types.rs index 57c998ea8c..1e65f0e8bc 100644 --- a/crates/types-traits/domain_types/src/surcharge/types.rs +++ b/crates/types-traits/domain_types/src/surcharge/types.rs @@ -33,7 +33,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id, connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_surcharge_id, @@ -205,7 +205,7 @@ impl let merchant_id = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + 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 ee6cbf712a..0a1d484e1f 100644 --- a/crates/types-traits/domain_types/src/types.rs +++ b/crates/types-traits/domain_types/src/types.rs @@ -5018,7 +5018,7 @@ impl .transpose()?; Ok(Self { - unmasked_connector_response: None, + 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( @@ -5107,7 +5107,7 @@ impl ForeignTryFrom<(PaymentServiceAuthorizeRequest, Connectors, &MaskedMetadata .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5222,7 +5222,7 @@ impl ForeignTryFrom<(AuthorizationRequest, Connectors, &MaskedMetadata)> for Pay .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5311,7 +5311,7 @@ impl ForeignTryFrom<(SetupRecurringRequest, Connectors, &MaskedMetadata)> for Pa .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5426,7 +5426,7 @@ impl })?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5510,7 +5510,7 @@ impl .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5600,7 +5600,7 @@ impl ForeignTryFrom<(PaymentServiceVoidRequest, Connectors, &MaskedMetadata)> fo .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -5684,7 +5684,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -6058,9 +6058,9 @@ pub fn generate_create_order_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -6091,7 +6091,7 @@ pub fn generate_create_order_response( merchant_order_id: None, raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_status, session_data: grpc_session_data, } @@ -6120,7 +6120,7 @@ pub fn generate_create_order_response( merchant_order_id: None, raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_status, session_data: None, }, @@ -6154,9 +6154,9 @@ pub fn generate_payment_method_eligibility_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -6170,7 +6170,7 @@ pub fn generate_payment_method_eligibility_response( error_info: None, raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, response_headers, }), Err(err) => Ok(PaymentMethodServiceEligibilityResponse { @@ -6192,7 +6192,7 @@ pub fn generate_payment_method_eligibility_response( }), raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, response_headers, }), } @@ -6364,9 +6364,9 @@ pub fn generate_payment_authorize_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -6464,7 +6464,7 @@ pub fn generate_payment_authorize_response( status: grpc_status as i32, error: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_request, status_code: status_code as u32, response_headers, @@ -6537,7 +6537,7 @@ pub fn generate_payment_authorize_response( status_code: err.status_code as u32, response_headers, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_request, connector_feature_data: None, state, @@ -7476,9 +7476,9 @@ pub fn generate_payment_void_response( raw_connector_response: router_data_v2 .resource_common_data .get_raw_connector_response(), - unmasked_connector_response: router_data_v2 + masked_connector_response: router_data_v2 .resource_common_data - .get_unmasked_connector_response(), + .get_masked_connector_response(), state, mandate_reference: mandate_reference_grpc, mandate_reference_details, @@ -7527,9 +7527,9 @@ pub fn generate_payment_void_response( raw_connector_response: router_data_v2 .resource_common_data .get_raw_connector_response(), - unmasked_connector_response: router_data_v2 + masked_connector_response: router_data_v2 .resource_common_data - .get_unmasked_connector_response(), + .get_masked_connector_response(), error: Some(grpc_api_types::payments::ErrorInfo { unified_details: None, connector_details: Some(grpc_api_types::payments::ConnectorErrorDetails { @@ -7591,9 +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 unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_status = router_data_v2 .resource_common_data @@ -7635,7 +7635,7 @@ pub fn generate_payment_void_post_capture_response( .get_connector_response_headers_as_map(), raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_status, }) } @@ -7669,7 +7669,7 @@ pub fn generate_payment_void_post_capture_response( .get_connector_response_headers_as_map(), raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_status, }) } @@ -7716,7 +7716,7 @@ pub fn generate_payment_void_post_capture_response( .get_connector_response_headers_as_map(), raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_status, }) } @@ -7828,9 +7828,9 @@ pub fn generate_payment_sync_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .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() @@ -7958,7 +7958,7 @@ pub fn generate_payment_sync_response( metadata: None, status_code: status_code as u32, raw_connector_response, - unmasked_connector_response, + masked_connector_response, response_headers: router_data_v2 .resource_common_data .get_connector_response_headers_as_map(), @@ -8074,7 +8074,7 @@ pub fn generate_payment_sync_response( metadata: None, status_code: status_code as u32, raw_connector_response, - unmasked_connector_response, + masked_connector_response, response_headers: router_data_v2 .resource_common_data .get_connector_response_headers_as_map(), @@ -8168,7 +8168,7 @@ pub fn generate_payment_sync_response( merchant_order_id: None, metadata: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, status_code: e.status_code as u32, response_headers: router_data_v2 .resource_common_data @@ -8276,7 +8276,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, connector_request_reference_id: extract_connector_request_reference_id( @@ -8328,7 +8328,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, status: common_enums::RefundStatus::Success, @@ -8393,7 +8393,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; let refund_id = value.merchant_refund_id.clone(); Ok(Self { - unmasked_connector_response: None, + 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), @@ -8712,7 +8712,7 @@ impl ), ) -> Result> { Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, dispute_id: None, connectors, connector_dispute_id: value.dispute_id, @@ -8744,7 +8744,7 @@ impl ), ) -> Result> { Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_dispute_id.clone(), ), @@ -8839,7 +8839,7 @@ impl ), ) -> Result> { Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, dispute_id: None, connectors, connector_dispute_id: value.dispute_id, @@ -8869,7 +8869,7 @@ impl ), ) -> Result> { Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_dispute_id, ), @@ -8892,9 +8892,9 @@ pub fn generate_refund_sync_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data @@ -8942,7 +8942,7 @@ pub fn generate_refund_sync_response( metadata: None, refund_metadata: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, status_code: response.status_code as u32, response_headers, state: None, @@ -8993,7 +8993,7 @@ pub fn generate_refund_sync_response( customer_name: None, email: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, merchant_order_id: None, metadata: None, refund_metadata: None, @@ -9106,7 +9106,7 @@ impl ForeignTryFrom for PaymentServiceGetResponse { metadata: None, status_code: value.status_code as u32, raw_connector_response: None, - unmasked_connector_response: None, + masked_connector_response: None, response_headers, state: None, raw_connector_request: None, @@ -9300,9 +9300,9 @@ pub fn generate_void_post_refund_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -9356,7 +9356,7 @@ pub fn generate_void_post_refund_response( metadata: None, refund_metadata: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, status_code: response.status_code as u32, response_headers, state: Some(ConnectorState { @@ -9408,7 +9408,7 @@ pub fn generate_void_post_refund_response( customer_name: None, email: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, merchant_order_id: None, metadata: None, refund_metadata: None, @@ -9494,7 +9494,7 @@ impl }); Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -9607,7 +9607,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -9697,7 +9697,7 @@ impl ForeignTryFrom for RefundResponse { issuer_details: None, }), raw_connector_response: None, - unmasked_connector_response: None, + masked_connector_response: None, refund_amount: None, payment_amount: None, refund_reason: None, @@ -10356,9 +10356,9 @@ pub fn generate_refund_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); // RefundFlowData doesn't have access_token field, so no state to return let state = None; @@ -10405,7 +10405,7 @@ pub fn generate_refund_response( email: None, merchant_order_id: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, metadata: None, refund_metadata: None, status_code: response.status_code as u32, @@ -10457,7 +10457,7 @@ pub fn generate_refund_response( customer_name: None, email: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, merchant_order_id: None, metadata: None, refund_metadata: None, @@ -10679,7 +10679,7 @@ impl .map(|m| ForeignTryFrom::foreign_try_from((m, "feature data"))) .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "PAYMENT_ID".to_string(), @@ -10749,7 +10749,7 @@ impl }; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, merchant_id: merchant_id_from_header, connector_request_reference_id: value.merchant_client_session_id, connector_feature_data: value @@ -10818,9 +10818,9 @@ pub fn generate_payment_incremental_authorization_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); match router_data_v2.response { Ok(response) => match response { @@ -10843,7 +10843,7 @@ pub fn generate_payment_incremental_authorization_response( state, raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, }) } _ => Err(report!(ConnectorError::UnexpectedResponseError { @@ -10876,7 +10876,7 @@ pub fn generate_payment_incremental_authorization_response( state, raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, }), } } @@ -10925,9 +10925,9 @@ pub fn generate_payment_capture_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let connector_response = router_data_v2 .resource_common_data @@ -10997,7 +10997,7 @@ pub fn generate_payment_capture_response( state, raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, incremental_authorization_allowed, mandate_reference: mandate_reference_grpc, mandate_reference_details, @@ -11062,7 +11062,7 @@ pub fn generate_payment_capture_response( state, raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, incremental_authorization_allowed: None, mandate_reference: None, mandate_reference_details: None, @@ -11141,7 +11141,7 @@ impl .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -11243,7 +11243,7 @@ impl .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -12012,9 +12012,9 @@ pub fn generate_setup_mandate_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let connector_response = router_data_v2 .resource_common_data @@ -12146,7 +12146,7 @@ pub fn generate_setup_mandate_response( state, raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, connector_response, connector_feature_data: convert_connector_metadata_to_secret_string( connector_metadata, @@ -12214,7 +12214,7 @@ pub fn generate_setup_mandate_response( state, raw_connector_request, raw_connector_response, - unmasked_connector_response, + masked_connector_response, connector_response, connector_feature_data: None, captured_amount: None, @@ -12232,7 +12232,7 @@ impl ForeignTryFrom<(DisputeServiceDefendRequest, Connectors)> for DisputeFlowDa (value, connectors): (DisputeServiceDefendRequest, Connectors), ) -> Result> { Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, dispute_id: Some(value.dispute_id.clone()), connectors, connector_dispute_id: value.dispute_id, @@ -12256,7 +12256,7 @@ impl ForeignTryFrom<(DisputeServiceDefendRequest, Connectors, &MaskedMetadata)> (value, connectors, _metadata): (DisputeServiceDefendRequest, Connectors, &MaskedMetadata), ) -> Result> { Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, connector_request_reference_id: extract_connector_request_reference_id( &value.merchant_dispute_id, ), @@ -12478,7 +12478,7 @@ impl .and_then(|state| state.connector_customer_id.clone()); Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13085,7 +13085,7 @@ impl .transpose()?; Ok(Self { - unmasked_connector_response: None, + 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(), @@ -13232,7 +13232,7 @@ impl .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13420,7 +13420,7 @@ impl .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13538,7 +13538,7 @@ impl .map(ServerAuthenticationTokenResponseData::foreign_try_from) .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13647,7 +13647,7 @@ impl .map(ServerAuthenticationTokenResponseData::foreign_try_from) .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -13750,7 +13750,7 @@ impl ), ) -> Result> { Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, connectors, connector_request_reference_id: String::new(), raw_connector_response: None, @@ -13774,7 +13774,7 @@ impl ForeignTryFrom error: None, response_headers: std::collections::HashMap::new(), raw_connector_response: None, - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_request: None, }) } @@ -13859,7 +13859,7 @@ impl }), response_headers: std::collections::HashMap::new(), raw_connector_response: None, - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_request: None, } } @@ -13945,7 +13945,7 @@ impl .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -14033,7 +14033,7 @@ impl ) -> Result> { let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -14398,9 +14398,9 @@ pub fn generate_repeat_payment_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data @@ -14476,7 +14476,7 @@ pub fn generate_repeat_payment_response( mandate_reference_details, status_code: status_code as u32, raw_connector_response, - unmasked_connector_response, + masked_connector_response, response_headers: router_data_v2 .resource_common_data .get_connector_response_headers_as_map(), @@ -14539,7 +14539,7 @@ pub fn generate_repeat_payment_response( connector_feature_data: None, mandate_reference_details: None, raw_connector_response: None, - unmasked_connector_response: None, + masked_connector_response: None, status_code: err.status_code as u32, response_headers: router_data_v2 .resource_common_data @@ -14915,9 +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 unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); match transaction_response { Ok(response) => match response { @@ -15002,7 +15002,7 @@ pub fn generate_payment_sdk_session_token_response( session_data: grpc_session_data, error: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, status_code: status_code as u32, raw_connector_request, }, @@ -15032,7 +15032,7 @@ pub fn generate_payment_sdk_session_token_response( issuer_details: Some(grpc_payment_types::IssuerErrorDetails::from(&e)), }), raw_connector_response, - unmasked_connector_response, + masked_connector_response, status_code: e.status_code as u32, raw_connector_request, }, @@ -16021,7 +16021,7 @@ impl .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -16124,7 +16124,7 @@ impl .transpose()?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -16235,7 +16235,7 @@ impl .map(|s| s.to_string()); Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -16321,7 +16321,7 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; Ok(Self { - unmasked_connector_response: None, + masked_connector_response: None, raw_connector_status: None, merchant_id: merchant_id_from_header, payment_id: "MANDATE_REVOKE_ID".to_string(), @@ -16417,7 +16417,7 @@ impl ForeignTryFrom<(bool, RedirectDetailsResponse)> // 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. - unmasked_connector_response: None, + masked_connector_response: None, }) } } @@ -16439,9 +16439,9 @@ pub fn generate_payment_pre_authenticate_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let response_headers = router_data_v2 .resource_common_data .get_connector_response_headers_as_map(); @@ -16594,7 +16594,7 @@ pub fn generate_payment_pre_authenticate_response( status: grpc_status.into(), error: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, status_code: status_code.into(), response_headers, network_transaction_id: None, @@ -16643,7 +16643,7 @@ pub fn generate_payment_pre_authenticate_response( status_code: err.status_code.into(), response_headers, raw_connector_response, - unmasked_connector_response, + masked_connector_response, connector_feature_data: None, state: None, authentication_data: None, @@ -16670,9 +16670,9 @@ pub fn generate_payment_authenticate_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let response_headers = router_data_v2 .resource_common_data .get_connector_response_headers_as_map(); @@ -16804,7 +16804,7 @@ pub fn generate_payment_authenticate_response( status: grpc_status.into(), error: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_status, status_code: status_code.into(), response_headers, @@ -16853,7 +16853,7 @@ pub fn generate_payment_authenticate_response( }), status_code: err.status_code.into(), raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_status, response_headers, connector_feature_data: None, @@ -16881,9 +16881,9 @@ pub fn generate_payment_post_authenticate_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let response_headers = router_data_v2 .resource_common_data .get_connector_response_headers_as_map(); @@ -16916,7 +16916,7 @@ pub fn generate_payment_post_authenticate_response( status: grpc_status.into(), error: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_status, status_code: status_code.into(), response_headers, @@ -16966,7 +16966,7 @@ pub fn generate_payment_post_authenticate_response( status_code: err.status_code.into(), response_headers, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_status, connector_feature_data: None, state: None, @@ -17415,9 +17415,9 @@ pub fn generate_mandate_revoke_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17447,7 +17447,7 @@ pub fn generate_mandate_revoke_response( network_transaction_id: None, merchant_revoke_id: None, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_request, }), Err(e) => Ok(RecurringPaymentServiceRevokeResponse { @@ -17468,7 +17468,7 @@ pub fn generate_mandate_revoke_response( network_transaction_id: None, merchant_revoke_id: e.connector_transaction_id, raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_request, }), } @@ -17657,9 +17657,9 @@ pub fn generate_recharge_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17684,7 +17684,7 @@ pub fn generate_recharge_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_request, }) } @@ -17714,7 +17714,7 @@ pub fn generate_recharge_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_request, }) } @@ -17733,9 +17733,9 @@ pub fn generate_create_payment_method_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17757,7 +17757,7 @@ pub fn generate_create_payment_method_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_request, }), Err(error) => Ok(PaymentMethodServiceCreateResponse { @@ -17782,7 +17782,7 @@ pub fn generate_create_payment_method_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_request, }), } @@ -17806,9 +17806,9 @@ pub fn generate_refresh_payment_method_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17834,7 +17834,7 @@ pub fn generate_refresh_payment_method_response( })?; proto.response_headers = response_headers; proto.raw_connector_response = raw_connector_response; - proto.unmasked_connector_response = unmasked_connector_response; + proto.masked_connector_response = masked_connector_response; proto.raw_connector_request = raw_connector_request; Ok(proto) } @@ -17847,7 +17847,7 @@ pub fn generate_refresh_payment_method_response( )); proto.response_headers = response_headers; proto.raw_connector_response = raw_connector_response; - proto.unmasked_connector_response = unmasked_connector_response; + proto.masked_connector_response = masked_connector_response; proto.raw_connector_request = raw_connector_request; Ok(proto) } @@ -17866,9 +17866,9 @@ pub fn generate_get_payment_method_response( let raw_connector_response = router_data_v2 .resource_common_data .get_raw_connector_response(); - let unmasked_connector_response = router_data_v2 + let masked_connector_response = router_data_v2 .resource_common_data - .get_unmasked_connector_response(); + .get_masked_connector_response(); let raw_connector_request = router_data_v2 .resource_common_data .get_raw_connector_request(); @@ -17890,7 +17890,7 @@ pub fn generate_get_payment_method_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, - unmasked_connector_response, + masked_connector_response, raw_connector_request, }), Err(error) => Ok(PaymentMethodServiceGetResponse { @@ -17915,7 +17915,7 @@ pub fn generate_get_payment_method_response( .resource_common_data .get_connector_response_headers_as_map(), raw_connector_response, - unmasked_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 98ea76d344..9cfd095464 100644 --- a/crates/types-traits/grpc-api-types/proto/frm.proto +++ b/crates/types-traits/grpc-api-types/proto/frm.proto @@ -94,7 +94,7 @@ message FrmServicePreRiskCheckResponse { // 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 unmasked_connector_response = 10; + optional string masked_connector_response = 10; // Response headers from the connector map response_headers = 9; @@ -167,7 +167,7 @@ message FrmServicePostRiskCheckResponse { // 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 unmasked_connector_response = 10; + 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 886f429bf4..ee04015c39 100644 --- a/crates/types-traits/grpc-api-types/proto/payment.proto +++ b/crates/types-traits/grpc-api-types/proto/payment.proto @@ -2645,7 +2645,7 @@ message PaymentServiceAuthorizeResponse { // 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 unmasked_connector_response = 25; + optional string masked_connector_response = 25; optional SecretString raw_connector_request = 12; // Payment Details @@ -2787,7 +2787,7 @@ message PaymentServiceGetResponse { // 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 unmasked_connector_response = 38; + optional string masked_connector_response = 38; optional SecretString raw_connector_request = 25; // Redirection and Transaction Details @@ -2890,7 +2890,7 @@ message PaymentServiceVoidResponse { // 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 unmasked_connector_response = 17; + optional string masked_connector_response = 17; // Mandate reference details returned by the connector for future recurring payments. optional MandateReferenceDetails mandate_reference_details = 14; @@ -2946,7 +2946,7 @@ message PaymentServiceReverseResponse { // 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 unmasked_connector_response = 11; + optional string masked_connector_response = 11; // The connector's reported status for this response. optional RawConnectorStatus raw_connector_status = 9; @@ -3071,7 +3071,7 @@ message MerchantAuthenticationServiceCreateClientAuthenticationTokenResponse { // 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 unmasked_connector_response = 6; + optional string masked_connector_response = 6; optional SecretString raw_connector_request = 5; } @@ -3165,7 +3165,7 @@ message PaymentServiceCaptureResponse { // 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 unmasked_connector_response = 19; + 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. @@ -3223,7 +3223,7 @@ message PaymentServiceCreateOrderResponse { // 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 unmasked_connector_response = 11; + optional string masked_connector_response = 11; // SDK Session Data optional ClientAuthenticationTokenData session_data = @@ -3331,7 +3331,7 @@ message RefundResponse { // 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 unmasked_connector_response = 27; + optional string masked_connector_response = 27; optional SecretString raw_connector_request = 22; // Connector/domain state metadata to persist across calls. @@ -3587,7 +3587,7 @@ message PaymentServiceSetupRecurringResponse { // 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 unmasked_connector_response = 20; + optional string masked_connector_response = 20; } // Request message for repeat payment (MIT - Merchant Initiated Transaction) @@ -3713,7 +3713,7 @@ message RecurringPaymentServiceChargeResponse { // 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 unmasked_connector_response = 21; + optional string masked_connector_response = 21; optional SecretString raw_connector_request = 12; // Payment Details @@ -3771,7 +3771,7 @@ message RecurringPaymentServiceRevokeResponse { // 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 unmasked_connector_response = 9; + optional string masked_connector_response = 9; optional SecretString raw_connector_request = 8; } @@ -3847,7 +3847,7 @@ message PaymentMethodAuthenticationServicePreAuthenticateResponse { // 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 unmasked_connector_response = 13; + optional string masked_connector_response = 13; // Authentication Results optional AuthenticationData authentication_data = 12; } @@ -3928,7 +3928,7 @@ message PaymentMethodAuthenticationServiceAuthenticateResponse { // 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 unmasked_connector_response = 14; + optional string masked_connector_response = 14; // The connector's reported status (code/message/reason). optional RawConnectorStatus raw_connector_status = 13; @@ -4005,7 +4005,7 @@ message PaymentMethodAuthenticationServicePostAuthenticateResponse { // 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 unmasked_connector_response = 15; + optional string masked_connector_response = 15; // The connector's reported status for this response. optional RawConnectorStatus raw_connector_status = 14; @@ -4049,7 +4049,7 @@ message PaymentServiceIncrementalAuthorizationResponse { // 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 unmasked_connector_response = 9; + optional string masked_connector_response = 9; } // IDs extracted from a payment webhook. @@ -4211,7 +4211,7 @@ message PaymentServiceVerifyRedirectResponseResponse { // 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 unmasked_connector_response = 8; + optional string masked_connector_response = 8; } // ============================================================================ @@ -4456,7 +4456,7 @@ message PaymentMethodServiceCreateResponse { // 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 unmasked_connector_response = 11; + optional string masked_connector_response = 11; optional SecretString raw_connector_request = 10; } @@ -4494,7 +4494,7 @@ message PaymentMethodServiceGetResponse { // 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 unmasked_connector_response = 11; + optional string masked_connector_response = 11; optional SecretString raw_connector_request = 10; } @@ -4514,7 +4514,7 @@ message PaymentMethodServiceRefreshResponse { // 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 unmasked_connector_response = 7; + optional string masked_connector_response = 7; optional SecretString raw_connector_request = 6; } @@ -4671,7 +4671,7 @@ message PaymentMethodServiceRechargeResponse { // 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 unmasked_connector_response = 12; + optional string masked_connector_response = 12; optional SecretString raw_connector_request = 11; // Raw request sent to the connector for debugging } @@ -6222,7 +6222,7 @@ message PaymentMethodServiceEligibilityResponse { // 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 unmasked_connector_response = 7; + optional string masked_connector_response = 7; // Response headers from the connector. map response_headers = 6; From d2eeb6ae25cbcc0ce6541cd5b24849e63915c8ca Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Fri, 7 Aug 2026 02:41:23 +0530 Subject: [PATCH 10/14] fix(core): mask response paths that have no key to gate on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every value is masked unless the connector's allowlist names its key. Four paths reached a value with no key in scope and defaulted to revealing it. - `mask_json` seeded the root `mask: false`, so a body with nothing to gate on went out in the clear: `["4111111111111111"]` and a bare `"4111111111111111"` were returned verbatim. Seeded `true`; an object re-decides per key immediately, so nothing correctly revealed today changes. - Array elements inherited the parent key's decision, so one allowlisted key revealed every scalar beneath it — `{"success":["4111111111111111"]}` with `success` listed. This contradicted the invariant the object arm upholds: elements carry no key, so an operator can never name one. Elements now always mask. - `detect` falls through to `Format::Form` for anything not JSON/XML-shaped, and `serde_urlencoded` never fails on arbitrary bytes — a segment with no `=` parses as `(whole_segment, "")`. Since keys are emitted verbatim by design, a `text/plain` decline message came back out as its own key: "Transaction declined for card 4111111111111111" -> "Transaction+declined+for+card+4111111111111111=***" The `{"_format":"unparsable"}` stub was therefore unreachable on this branch. `is_pair_shaped` now gates `mask_form`, which sends non-form bodies to the stub. Placed in `mask_form` rather than `detect` so a *declared* `x-www-form-urlencoded` body that isn't pair-shaped is caught too. - `mask_xml`'s catch-all forwarded `Comment`, `PI` and `DocType` verbatim, so a gateway echoing the request into a comment leaked it. `Decl` still passes through (version/encoding), a comment keeps its presence but not its content, and the default is now to drop rather than forward — so a variant a future `quick-xml` adds fails closed. Restores the test module dropped in b4431bc, ported to the current API (`Box` keys, no `max_bytes`, `&str` connector name) and extended to cover each bypass above plus the invariants that already held. All 11 new tests fail on the parent commit. Also corrects the `deserialize_connector_keys` doc comment: strum's `snake_case` governs `Display` as well as `FromStr`; what it does not reach is serde, which is the actual reason for the manual route. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + crates/types-traits/domain_types/Cargo.toml | 3 + .../src/connector_response_masking.rs | 608 +++++++++++++++++- 3 files changed, 603 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9aa45f70b6..62def4833f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1807,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/crates/types-traits/domain_types/Cargo.toml b/crates/types-traits/domain_types/Cargo.toml index 9d1b223d2d..c5926cff75 100644 --- a/crates/types-traits/domain_types/Cargo.toml +++ b/crates/types-traits/domain_types/Cargo.toml @@ -46,6 +46,9 @@ 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"] diff --git a/crates/types-traits/domain_types/src/connector_response_masking.rs b/crates/types-traits/domain_types/src/connector_response_masking.rs index 899d6a8b98..27118f1c23 100644 --- a/crates/types-traits/domain_types/src/connector_response_masking.rs +++ b/crates/types-traits/domain_types/src/connector_response_masking.rs @@ -59,8 +59,13 @@ fn is_known_connector(name: &str) -> bool { || AuthenticatorConnectorEnum::from_str(name).is_ok() } -/// Validate map keys via `FromStr`, not the serde derive: strum's `snake_case` applies to -/// `FromStr` only, and the config crate lowercases env-var keys. Same route as +/// 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, @@ -209,8 +214,12 @@ 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` -/// carries the parent key's decision so array elements inherit it. +/// 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>, @@ -238,13 +247,16 @@ impl Serialize for Masked<'_> { } 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: self.mask, + mask: true, })?; } state.end() @@ -260,9 +272,11 @@ impl Serialize for Masked<'_> { 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: false, + mask: true, }) .ok() } @@ -333,16 +347,55 @@ fn mask_xml(body: &[u8], keys: &HashSet>) -> Option { .ok()?; } } - // Declaration, comments, processing instructions, doctype: structural, copied as-is. - other => { - writer.write_event(other).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 @@ -356,6 +409,12 @@ fn mask_form(body: &[u8], keys: &HashSet>) -> Option { }) .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 @@ -434,3 +493,534 @@ pub fn mask_connector_response( // 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"); + } +} From 52de18039482cd30b1bba20afe41824edee8bd1a Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Fri, 7 Aug 2026 02:41:33 +0530 Subject: [PATCH 11/14] chore(config): default connector response masking off outside development MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `enabled = true` in sandbox.toml and production.toml put the feature live on the first deploy, with no chance to review the key lists first. Turning it on returns connector response bytes to the caller, so each deployment should opt in. Both go `false`, matching the struct `Default`; development.toml stays `true` so the path is exercised. Config-only. Also corrects the comment block above the lists. It promised that PAN, CVV, expiry and credentials "stay masked regardless of what is listed here", which is a claim about keyed fields only. It now states what naming a key actually grants — that key's own value, not the subtree beneath it — and that a body matching no structured format is replaced by a stub carrying only its size. Co-Authored-By: Claude Opus 5 (1M context) --- config/development.toml | 6 +++++- config/production.toml | 10 ++++++++-- config/sandbox.toml | 10 ++++++++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/config/development.toml b/config/development.toml index 154920b3b9..a36c861811 100644 --- a/config/development.toml +++ b/config/development.toml @@ -252,8 +252,12 @@ 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). -# Full PAN, CVV, expiry and credentials stay masked regardless of what is listed here. +# 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. diff --git a/config/production.toml b/config/production.toml index 53186568f4..2d4713bd62 100644 --- a/config/production.toml +++ b/config/production.toml @@ -184,7 +184,9 @@ connectors_with_webhook_source_verification_call = "paypal, truelayer" # 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. [connector_response_masking] -enabled = true +# 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, @@ -193,8 +195,12 @@ 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). -# Full PAN, CVV, expiry and credentials stay masked regardless of what is listed here. +# 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 diff --git a/config/sandbox.toml b/config/sandbox.toml index 44cf743c55..61d5d4b8d7 100644 --- a/config/sandbox.toml +++ b/config/sandbox.toml @@ -185,7 +185,9 @@ connectors_with_webhook_source_verification_call = "paypal, truelayer" # 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. [connector_response_masking] -enabled = true +# 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, @@ -194,8 +196,12 @@ 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). -# Full PAN, CVV, expiry and credentials stay masked regardless of what is listed here. +# 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 From e5169b412acb42614347435a87da04d9b1c42205 Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Fri, 7 Aug 2026 15:25:09 +0530 Subject: [PATCH 12/14] build(core): gate connector response masking behind a Cargo feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A runtime `enabled` flag can be flipped by a misconfiguration, an env var or a config patch. This code turns raw gateway bytes into a string handed back to the caller, so the guarantee should be structural: a build that does not opt in does not contain it. Adds `connector-response-masking`, off by default, chained domain_types -> external-services -> ucs_env -> grpc-server. Under the gate: the `connector_response_masking` module (with quick-xml and serde_urlencoded, which nothing else in domain_types uses, demoted to optional deps), the `Config` section, the `EventProcessingParams` field, and `record_masked_connector_response` with its two call sites. The runtime flag is kept alongside it — the build decides whether the code exists, config still lets an operator switch it off without a redeploy. The boundary stops at the proto: prost generates the field unconditionally, so it and the inert `Option` on the flow-data structs stay in every build and are simply always unset when the feature is off. The release image is unaffected: the Dockerfile already pins its feature list, so it excludes masking by construction. CI's nextest invocations pass the feature so the module's 40 unit tests keep running. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 4 ++-- Dockerfile | 2 ++ config/development.toml | 4 ++++ config/production.toml | 4 ++++ config/sandbox.toml | 4 ++++ crates/common/external-services/Cargo.toml | 3 +++ crates/common/external-services/src/service.rs | 9 +++++++-- crates/common/ucs_env/Cargo.toml | 6 ++++++ crates/common/ucs_env/src/configs.rs | 12 ++++++++---- crates/grpc-server/grpc-server/Cargo.toml | 6 ++++++ .../grpc-server/grpc-server/src/server/disputes.rs | 2 ++ crates/grpc-server/grpc-server/src/server/events.rs | 6 ++++++ .../grpc-server/grpc-server/src/server/payments.rs | 8 ++++++++ crates/grpc-server/grpc-server/src/utils.rs | 2 ++ crates/types-traits/domain_types/Cargo.toml | 9 +++++++-- crates/types-traits/domain_types/src/lib.rs | 1 + 16 files changed, 72 insertions(+), 10 deletions(-) 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/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 a36c861811..e4f0e44ced 100644 --- a/config/development.toml +++ b/config/development.toml @@ -242,6 +242,10 @@ keys = ["x-request-id","x-merchant-id","x-lineage-ids","x-reference-id","x-conne # `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 diff --git a/config/production.toml b/config/production.toml index 2d4713bd62..30180e0d4c 100644 --- a/config/production.toml +++ b/config/production.toml @@ -183,6 +183,10 @@ connectors_with_webhook_source_verification_call = "paypal, truelayer" # `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. diff --git a/config/sandbox.toml b/config/sandbox.toml index 61d5d4b8d7..1b887d67ff 100644 --- a/config/sandbox.toml +++ b/config/sandbox.toml @@ -184,6 +184,10 @@ connectors_with_webhook_source_verification_call = "paypal, truelayer" # `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. 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 64f5e77181..879f063b03 100644 --- a/crates/common/external-services/src/service.rs +++ b/crates/common/external-services/src/service.rs @@ -335,6 +335,7 @@ fn flow_status_label(flow_status: &domain_types::router_data::FlowStatus) -> Str /// `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, @@ -410,6 +411,7 @@ where } // 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) { @@ -479,6 +481,7 @@ where } // 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) { @@ -599,8 +602,10 @@ 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`. Gated by its own - /// `enabled` flag, deliberately independent of `return_raw_connector_data`. + /// 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, 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 d373bf537d..6b27063686 100644 --- a/crates/common/ucs_env/src/configs.rs +++ b/crates/common/ucs_env/src/configs.rs @@ -11,12 +11,14 @@ use common_utils::{ SuperpositionConfig, }; use domain_types::{ - connector_response_masking::{ - ConnectorResponseMaskingConfig, ConnectorResponseMaskingConfigPatch, - }, 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, @@ -38,7 +40,9 @@ pub struct Config { #[serde(default)] pub unmasked_headers: HeaderMaskingConfig, /// Per-connector key lists controlling which response values stay visible in - /// `masked_connector_response`. + /// `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)] 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 210789f18d..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,7 @@ 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, }; @@ -425,6 +426,7 @@ 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 bd495e7bae..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,7 @@ 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(), }; @@ -614,6 +615,7 @@ 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(), }; @@ -724,6 +726,7 @@ 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(), }; @@ -831,6 +834,7 @@ 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(), }; @@ -938,6 +942,7 @@ 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(), }; @@ -1035,6 +1040,7 @@ 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 660b73f36a..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,7 @@ 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(), }; @@ -716,6 +717,7 @@ 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(), }; @@ -1131,6 +1133,7 @@ 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(), }; @@ -2610,6 +2613,7 @@ 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(), }; @@ -2747,6 +2751,7 @@ 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, @@ -2865,6 +2870,7 @@ 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(), }; @@ -3007,6 +3013,7 @@ 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(), }; @@ -3587,6 +3594,7 @@ 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(), }; diff --git a/crates/grpc-server/grpc-server/src/utils.rs b/crates/grpc-server/grpc-server/src/utils.rs index 882bd3614a..85397247f3 100644 --- a/crates/grpc-server/grpc-server/src/utils.rs +++ b/crates/grpc-server/grpc-server/src/utils.rs @@ -709,6 +709,7 @@ 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(), }; @@ -1071,6 +1072,7 @@ 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(), }; diff --git a/crates/types-traits/domain_types/Cargo.toml b/crates/types-traits/domain_types/Cargo.toml index c5926cff75..c6568ca986 100644 --- a/crates/types-traits/domain_types/Cargo.toml +++ b/crates/types-traits/domain_types/Cargo.toml @@ -25,8 +25,10 @@ thiserror = { workspace = true } strum = { version = "0.26", features = ["derive"] } serde = { workspace = true } serde_json = { workspace = true, features = ["preserve_order"] } -serde_urlencoded = "0.7" -quick-xml = { version = "0.31.0", features = ["serialize"] } +# 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" @@ -53,3 +55,6 @@ 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/lib.rs b/crates/types-traits/domain_types/src/lib.rs index 8fce9a0b27..b358140320 100644 --- a/crates/types-traits/domain_types/src/lib.rs +++ b/crates/types-traits/domain_types/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod connector_flow; +#[cfg(feature = "connector-response-masking")] pub mod connector_response_masking; pub mod connector_types; pub mod errors; From 44527068baacbe9d56dca2da856d2a48e40ac2bf Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Fri, 7 Aug 2026 16:58:33 +0530 Subject: [PATCH 13/14] fix(core): honour log_to_span across every gRPC-level log sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `log_to_span` promised the caller could be sent `masked_connector_response` "without a copy being retained in our own logs". It did not hold: with the flag off, the masked view still reached the logs 3 times per request. Unlike its sibling `raw_connector_response` — declared `SecretString`, which build.rs maps to `hyperswitch_masking::Secret` whose `Debug` prints a type stub — this field is a plain `string`, so it has no type-level masking to fall back on and every generic response log printed it in full. Two sinks carried it, both in grpc-server::utils: - `response_body`, recorded via `field::debug` of the whole response struct. - the gRPC event payload, which `emit_event_with_config` logs as JSON on every request regardless of `events.enabled`. This one is the only sink for the payout endpoints, which carry no `#[tracing::instrument]` at all. Both now go through `response_for_logging`, which serializes with `masked_serialize` — matching what the request side already does — and drops the field while the flag is off. When the flag is on, behaviour is unchanged. `response_body` consequently changes from Rust-`Debug` to masked JSON: removing one key requires a structured value. That aligns it with `request_body` on the same golden log line, and means response logging now honours `Secret` masking through serde rather than relying on each field's `Debug`. Verified against the Adyen sandbox: with log_to_span off the field is absent from every log line while still returned to the caller (1379 bytes), including on the 4xx path; with it on, the previous shape is preserved. Co-Authored-By: Claude Opus 5 (1M context) --- crates/grpc-server/grpc-server/src/utils.rs | 67 +++++++++++++++++-- .../src/connector_response_masking.rs | 12 +++- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/crates/grpc-server/grpc-server/src/utils.rs b/crates/grpc-server/grpc-server/src/utils.rs index 85397247f3..2bb37c2bf9 100644 --- a/crates/grpc-server/grpc-server/src/utils.rs +++ b/crates/grpc-server/grpc-server/src/utils.rs @@ -289,15 +289,65 @@ where Ok(()) } -pub fn log_after_initialization(result: &Result, tonic::Status>) +/// Serde key of the one response field that carries a connector's reply back to the caller. +const MASKED_CONNECTOR_RESPONSE_KEY: &str = "masked_connector_response"; + +/// 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`. +fn response_for_logging(response: &R, log_masked: bool) -> Value where - T: serde::Serialize + std::fmt::Debug, + R: serde::Serialize, +{ + let mut 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 { + if let Value::Object(map) = &mut value { + map.remove(MASKED_CONNECTOR_RESPONSE_KEY); + } + } + + value +} + +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 +426,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 +490,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 +591,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)); diff --git a/crates/types-traits/domain_types/src/connector_response_masking.rs b/crates/types-traits/domain_types/src/connector_response_masking.rs index 27118f1c23..a2ce4376a6 100644 --- a/crates/types-traits/domain_types/src/connector_response_masking.rs +++ b/crates/types-traits/domain_types/src/connector_response_masking.rs @@ -29,9 +29,15 @@ pub struct ConnectorResponseMaskingConfig { /// Whether to populate `masked_connector_response` at all. pub enabled: bool, - /// Whether to *also* record the masked view on the outgoing span. Separate from - /// [`Self::enabled`] so the caller can be sent the field without a copy being retained in our - /// own logs, keeping a mistaken allowlist entry contained to whoever configured it. + /// Whether the masked view may reach our own logs at all. Separate from [`Self::enabled`] so + /// the caller can be sent the field without a copy being retained here, keeping a mistaken + /// allowlist entry contained to whoever configured it. + /// + /// While this is off the value is stripped from every gRPC-level log sink, not just the + /// dedicated `response.masked_body` span field: it is also removed from `response_body` and + /// from the event payload, both of which otherwise serialize the whole response + /// (`grpc-server::utils::response_for_logging`). Being a plain `String` rather than a + /// `Secret`, it has no type-level masking of its own to fall back on. pub log_to_span: bool, /// Connector name -> comma-separated list of keys whose values stay visible. From 2a98da5a9d37739b85390e34ec8312267dba7c47 Mon Sep 17 00:00:00 2001 From: Tushar Shukla Date: Fri, 7 Aug 2026 23:46:42 +0530 Subject: [PATCH 14/14] feat(events): publish masked_connector_response on the connector event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Euler needs the masked connector reply off the event stream, not only on the gRPC response. Nothing reached Kafka before: the previous commit stripped the field from the `Event` itself to keep it out of the logs, which removed it from the published payload too. That was the wrong cut. `emit_event_with_config` builds one payload and sends it to both `tracing::info!` and `publish_event_to_kafka`, so keeping a field out of the logs by removing it from the event necessarily withholds it from consumers. The two copies have to diverge instead. - Attach the masked view to the connector-call event in `record_masked_connector_response`, where it is produced, next to the request and response it describes. `additional_fields` is flattened, so it lands as a top-level `masked_connector_response` key on the `connector_events` topic. Attached unconditionally: delivery is the point of the field and must not depend on whether we log it. - Add `emit_event_with_config_redacting`, which omits given keys from the logged copy while publishing the payload intact. `emit_event_with_config` delegates to it, so no existing call site changes. - Removal is recursive. The key sits at a different depth on each event — top level on the connector event, under `response_data` on the gRPC event, and nested again inside `event_content.content` for webhook responses. This also closes a latent gap in the previous commit, whose top-level-only strip would have missed the nested case; nothing leaked only because the webhook path hard-codes the field to None. `log_to_span` therefore returns to meaning what its name says: UCS logs only. It no longer decides what downstream consumers receive, and the doc comment now says so — containment is within UCS, not end to end, since the published record is a retained copy we no longer control. Verified against the Adyen sandbox: with log_to_span off the field appears in no log line yet is still returned to the caller; with it on, the connector event carries it at top level. Six unit tests cover recursive removal, the borrow-when- unmatched path, and the property this rests on — that redaction leaves the published value untouched. Co-Authored-By: Claude Opus 5 (1M context) --- crates/common/common_utils/src/events.rs | 170 +++++++++++++++++- crates/common/common_utils/src/lib.rs | 5 +- .../common/external-services/src/service.rs | 43 ++++- crates/grpc-server/grpc-server/src/utils.rs | 17 +- .../src/connector_response_masking.rs | 19 +- 5 files changed, 231 insertions(+), 23 deletions(-) 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 e7e1025ba2..37fc860782 100644 --- a/crates/common/common_utils/src/lib.rs +++ b/crates/common/common_utils/src/lib.rs @@ -24,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/src/service.rs b/crates/common/external-services/src/service.rs index 879f063b03..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")] @@ -341,6 +341,7 @@ fn record_masked_connector_response( body: &Response, connector_name: &str, config: &domain_types::connector_response_masking::ConnectorResponseMaskingConfig, + event: Option<&mut Event>, ) where ResourceCommonData: RawConnectorRequestResponse, { @@ -367,9 +368,35 @@ fn record_masked_connector_response( } } + // 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( @@ -420,6 +447,7 @@ where &body, params.connector_name, params.connector_response_masking, + event.as_deref_mut(), ); } @@ -490,6 +518,7 @@ where &body, params.connector_name, params.connector_response_masking, + event.as_deref_mut(), ); } @@ -970,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), @@ -1088,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), diff --git a/crates/grpc-server/grpc-server/src/utils.rs b/crates/grpc-server/grpc-server/src/utils.rs index 2bb37c2bf9..6a0b3a8577 100644 --- a/crates/grpc-server/grpc-server/src/utils.rs +++ b/crates/grpc-server/grpc-server/src/utils.rs @@ -289,9 +289,6 @@ where Ok(()) } -/// Serde key of the one response field that carries a connector's reply back to the caller. -const MASKED_CONNECTOR_RESPONSE_KEY: &str = "masked_connector_response"; - /// 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 @@ -313,11 +310,14 @@ fn should_log_masked(_config: &configs::Config) -> bool { /// `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 R: serde::Serialize, { - let mut value = match hyperswitch_masking::masked_serialize(response) { + let value = match hyperswitch_masking::masked_serialize(response) { Ok(value) => value, Err(e) => { tracing::error!("Masked serialization error: {:?}", e); @@ -325,13 +325,10 @@ where } }; - if !log_masked { - if let Value::Object(map) = &mut value { - map.remove(MASKED_CONNECTOR_RESPONSE_KEY); - } + if log_masked { + return value; } - - value + common_utils::without_keys(&value, &[common_utils::MASKED_CONNECTOR_RESPONSE_KEY]).into_owned() } pub fn log_after_initialization( diff --git a/crates/types-traits/domain_types/src/connector_response_masking.rs b/crates/types-traits/domain_types/src/connector_response_masking.rs index a2ce4376a6..0318645083 100644 --- a/crates/types-traits/domain_types/src/connector_response_masking.rs +++ b/crates/types-traits/domain_types/src/connector_response_masking.rs @@ -29,15 +29,18 @@ pub struct ConnectorResponseMaskingConfig { /// Whether to populate `masked_connector_response` at all. pub enabled: bool, - /// Whether the masked view may reach our own logs at all. Separate from [`Self::enabled`] so - /// the caller can be sent the field without a copy being retained here, keeping a mistaken - /// allowlist entry contained to whoever configured it. + /// 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 gRPC-level log sink, not just the - /// dedicated `response.masked_body` span field: it is also removed from `response_body` and - /// from the event payload, both of which otherwise serialize the whole response - /// (`grpc-server::utils::response_for_logging`). Being a plain `String` rather than a - /// `Secret`, it has no type-level masking of its own to fall back on. + /// 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.