logo

Are you need IT Support Engineer? Free Consultant

SAP Cloud Integration (CI/CPI) – Reusable Script C…

  • By sujay
  • 26/05/2026
  • 7 Views

When working with SAP Cloud Integration (CI/CPI), one of the most common questions from compliance teams is this:

“Our integration team is based outside Europe. They have access to Message Monitoring. Does that mean they can read the personal data in the payloads? Is that a GDPR violation?”

The answer, in most standard configurations, is yes. And it is not a theoretical risk.

Log Payload in SAP Cloud Integration is not intended to run on every successful message. SAP recommends enabling it only for exception handling and active troubleshooting, because it adds processing overhead and stores message content in the system. In practice, teams configure Log Payload inside the Exception Subprocess so it fires only when something goes wrong.

Reference: SAP Help: Message Processing Log (https://help.sap.com/docs/cloud-integration/sap-cloud-integration/message-processing-log-log-level)

Reference: SAP Help: Define Exception Subprocess (https://help.sap.com/docs/cloud-integration/sap-cloud-integration/define-exception-subprocess)

The problem is that when an exception does fire, the logged payload contains everything: customer names, IBANs, addresses, salary figures, health data. Integration teams get the field structure and format they need to diagnose the issue. But they also get the actual sensitive values of real data subjects, which they have no legitimate need to access and, under GDPR, no legal right to see.

This post describes a technical solution that removes the access risk without removing the diagnostic capability. Integration teams can still see the full message structure, field names, data types, and representative values when an exception fires. The actual sensitive values are masked before the payload is ever logged. The target system always receives the real, original data.

And critically: you add this protection to any iFlow with minimal effort, with zero changes to the iFlow logic itself.

 

1. Integrity and Confidentiality (Article 5(1)(f))

Article 5(1)(f) says personal data must be processed in a way that ensures appropriate security, including protection against unauthorized or unlawful processing and against accidental loss, destruction, or damage, using appropriate technical/organizational measures. 

What this means in SAP CI/CPI

In CI/CPI, if a message fails and you log the payload (e.g., via Log Payload in an exception subprocess), the message processing log can contain PII (names, emails, IBAN, etc.). If people with monitoring access can open that payload and read sensitive values without a legitimate purpose, that is exactly the kind of unauthorized access Article 5(1)(f) is meant to prevent.

Reference: GDPR Article 5 – Principles relating to processing of personal data (https://gdpr-info.eu/art-5-gdpr/)

2. Data Protection by Design and by Default (Article 25)

Article 25 requires controllers to implement technical and organizational measures designed to apply data protection principles effectively (“by design”), and to ensure that, by default, only personal data necessary for each specific purpose is processed and accessible (“by default”).

What this means in SAP CI/CPI

In CI/CPI troubleshooting, teams typically need:

  • message structure (XML/JSON layout),
  • field names,
  • representative values,
  • correlation keys (OrderNumber, BusinessPartner), but they usually do not need real PII values from production (real names, emails, IBANs) to diagnose a mapping error or routing problem.

So “by default” in CPI should mean: logs don’t expose real PII unless there is a strict, justified operational need.

Reference: GDPR Article 25 – Data protection by design and by default (https://gdpr-info.eu/art-25-gdpr/)

3. Security of Processing (Article 32)

Article 32 requires implementing appropriate technical and organizational measures to ensure a security level appropriate to risk. It explicitly mentions measures such as pseudonymisation and encryption, the ability to ensure ongoing confidentiality/integrity/availability, restoration after incidents, and regular testing of controls. 

What this means in SAP CI/CPI

CI/CPI message logs are a “processing system” component. If they store real payloads containing personal data, the platform must protect those logs and limit exposure. Practical Article 32 themes in CPI include:

  • pseudonymisation of log content (masking values),
  • access control for monitoring roles,
  • operational controls around trace (because trace can capture pre-script payloads),
  • ensuring you can still troubleshoot and restore safely.

Reference: GDPR Article 32 – Security of processing (https://gdpr-info.eu/art-32-gdpr/)

Reference: GDPR Article 4(5) – Definition of pseudonymization (https://gdpr-info.eu/art-4-gdpr/)

4. Cross-Border Access and Transfers (Articles 44-49)

This is the most commonly overlooked risk for organizations with offshore integration teams.

GDPR Chapter V governs transfers of personal data to third countries. A common assumption is that this applies only to physically moving data outside the EU. The EDPB (European Data Protection Board) clarified in Guidelines 05/2021 that making EU personal data accessible to a person located in a third country, including via remote access to a system hosted in the EU, can constitute a transfer under Chapter V.

Reference: EDPB Guidelines 05/2021 on the interplay between Article 3 and Chapter V of the GDPR (https://edpb.europa.eu/system/files/2021-11/edpb_guidelines_202105_transfers_part2_en.pdf)

Reference: GDPR Article 44 – General principle for transfers (https://gdpr-info.eu/art-44-gdpr/)

In practice: according to the EDPB's interpretation in Guidelines 05/2021, if an integration consultant based in India, the US, or any country without an EU adequacy decision can log into SAP IS Message Monitoring and view personal data from a logged payload, that constitutes a cross-border transfer. Without Standard Contractual Clauses or another valid transfer mechanism covering that specific access, the organization is likely in breach of GDPR Articles 44-49. Organizations should obtain qualified legal advice specific to their jurisdiction, team structure, and applicable transfer mechanisms.

The server being hosted in Europe does not protect against this. What matters is who can access the data, not only where it is stored.

GDPR: Note on pseudonymization: this solution applies pseudonymization to the logged copy of the payload only. The original payload is restored before reaching the target system, so the target always receives real data. Under GDPR Recital 26 and Article 4(5), the logged copy constitutes pseudonymized personal data: the values are replaced but the original exists and can be recovered. This solution does not claim to anonymize the data. It claims to prevent unauthorized access to real personal data through middleware logging. (Reference: https://gdpr-info.eu/recital-26-gdpr/)

 Mask the payload whenever payload needs to be logged in the main flow or exception subprocess for troubleshooting, encrypt the original payload in headers or properties and restore the original payload for the actual interface logic.

The design has four parts:

1)  Setter iFlow: SetGlobalPayloadMaskingConfig   A dedicated iFlow validates the JSON config and writes it to a global variable. Invalid config is rejected before it is ever written. The previous valid config is always preserved for rollback.

2)  Script Collection: PayloadMaskingLibrary   Three Groovy scripts handle everything. ConfigValidatorUtil runs in the setter iFlow. PayloadMaskUtil runs at the start of each protected iFlow. PayloadRestoreUtil runs just before the sender in each protected iFlow.

3)  Global Variable: GlobalPayloadMaskingConfig   The single source of truth for all masking rules. All iFlows read this at runtime. No iFlow holds its own copy of the configuration.

4)  Per-iFlow addition: two script steps   PayloadMaskUtil near the start of the iFlow. PayloadRestoreUtil just before the sender. Log Payload, whether in the main flow, an exception subprocess, or a central exception handler called via Process Direct, does not need to move. The body is always masked from step one onward, so any Log Payload anywhere captures the masked body automatically.

Step 1: Create the Script Collection

  1. Go to your Integration Package
  2. Add artifact: Script Collection
  3. Name: PayloadMaskingLibrary

Adarshrao_0-1779724982956.Png

  1. Add three scripts: ConfigValidatorUtil.groovy, PayloadMaskUtil.groovy, PayloadRestoreUtil.groovy
  2. Deploy the Script Collection

Reference: SAP Help: Creating Script Collections https://help.sap.com/docs/cloud-integration/sap-cloud-integration/creating-script-collection?locale=… 

 

ConfigValidatorUtil.groovy:

Validates the JSON config before it is written. Reports all errors at once so the person updating the config sees every problem in one message monitoring entry.

import groovy.json.JsonSlurper

def processData(message) {

    // 1) Read body ONCE from a Reader (streaming)
    Reader r = message.getBody(java.io.Reader)
    if (r == null) {
        throw new RuntimeException(
            "[CONFIG VALIDATION FAILED] Message body is empty. " +
            "Global variable NOT updated. Send the JSON config as the message body."
        )
    }

    // 2) Convert to String once (for later global-variable write), still from the same Reader
    String configText = r.text?.trim()
    if (!configText) {
        throw new RuntimeException(
            "[CONFIG VALIDATION FAILED] Message body is empty. " +
            "Global variable NOT updated. Send the JSON config as the message body."
        )
    }

    // 3) Parse from a Reader (streaming) to satisfy the guideline
    //    Use StringReader so we don't re-read the message body stream.
    def config
    try {
        config = new JsonSlurper().parse(new StringReader(configText))
    } catch (Exception e) {
        throw new RuntimeException(
            "[CONFIG VALIDATION FAILED] Invalid JSON syntax: ${e.message}. " +
            "Global variable NOT updated. Fix the JSON and re-run."
        )
    }

    def errors = []

    if (!config?.root) {
        errors << "Missing top-level root key"
    } else {
        def root = config.root

        // Optional Settings validation
        def settings = root.Settings
        if (settings != null) {
            if (!(settings instanceof Map))
                errors << "root.Settings must be an object"

            def mode = settings?.StorageMode?.toString()?.trim()
            if (mode && !["HEADER","PROPERTY","AUTO"].contains(mode.toUpperCase()))
                errors << "root.Settings.StorageMode must be one of HEADER|PROPERTY|AUTO"

            def mhb = settings?.MaxHeaderBytes
            if (mhb != null && !(mhb instanceof Number))
                errors << "root.Settings.MaxHeaderBytes must be a number"

            def enabled = settings?.Enabled
            if (enabled != null && !(enabled instanceof Boolean))
                errors << "root.Settings.Enabled must be boolean"

            def allowBypass = settings?.AllowBypassHeader
            if (allowBypass != null && !(allowBypass instanceof Boolean))
                errors << "root.Settings.AllowBypassHeader must be boolean"

            def bypassName = settings?.BypassHeaderName
            if (bypassName != null && bypassName.toString().trim().isEmpty())
                errors << "root.Settings.BypassHeaderName cannot be empty"
        }

        // GlobalKeepReal
        if (root.GlobalKeepReal == null)
            errors << "root.GlobalKeepReal is missing (can be empty [])"
        else if (!(root.GlobalKeepReal instanceof List))
            errors << "root.GlobalKeepReal must be an array"
        else
            root.GlobalKeepReal.eachWithIndex { val, i ->
                if (!val?.toString()?.trim())
                    errors << "root.GlobalKeepReal[${i}] contains an empty value"
            }

        // GlobalMasking
        if (!root.GlobalMasking)
            errors << "root.GlobalMasking is missing (must include a DEFAULT key)"
        else if (!(root.GlobalMasking instanceof Map))
            errors << "root.GlobalMasking must be an object"
        else {
            if (!root.GlobalMasking.containsKey("DEFAULT"))
                errors << "root.GlobalMasking must contain a DEFAULT key"

            root.GlobalMasking.each { key, val ->
                if (val != null && val.toString().trim().isEmpty())
                    errors << "root.GlobalMasking.${key} is an empty string"
            }
        }

        // Iflow
        if (root.Iflow == null)
            errors << "root.Iflow is missing (can be empty [])"
        else if (!(root.Iflow instanceof List))
            errors << "root.Iflow must be an array"
        else {
            def seenIds = [] as Set
            root.Iflow.eachWithIndex { iflow, idx ->
                def id = iflow?.IflowID?.toString()?.trim()
                if (!id) {
                    errors << "Iflow[${idx}]: missing or empty IflowID"
                } else if (seenIds.contains(id.toLowerCase())) {
                    errors << "Iflow[${idx}]: duplicate IflowID ${id}"
                } else {
                    seenIds.add(id.toLowerCase())
                }

                if (iflow?.KeepReal == null)
                    errors << "Iflow[${idx}] ${id ?: ''}: missing KeepReal array"
                else if (!(iflow.KeepReal instanceof List))
                    errors << "Iflow[${idx}] ${id ?: ''}: KeepReal must be an array"
                else
                    iflow.KeepReal.eachWithIndex { v, vi ->
                        if (!v?.toString()?.trim())
                            errors << "Iflow[${idx}] ${id ?: ''}: KeepReal[${vi}] is empty"
                    }

                // Optional per-iFlow overrides
                if (iflow?.Enabled != null && !(iflow.Enabled instanceof Boolean))
                    errors << "Iflow[${idx}] ${id ?: ''}: Enabled must be boolean"

                def sm = iflow?.StorageMode?.toString()?.trim()
                if (sm && !["HEADER","PROPERTY","AUTO"].contains(sm.toUpperCase()))
                    errors << "Iflow[${idx}] ${id ?: ''}: StorageMode must be HEADER|PROPERTY|AUTO"
            }
        }
    }

    if (errors) {
        def errorMsg = errors.collect { "  - ${it}" }.join("\n")
        throw new RuntimeException(
            "[CONFIG VALIDATION FAILED] ${errors.size()} error(s) found. " +
            "Global variable NOT updated.\n${errorMsg}"
        )
    }

    // Persist validated JSON text for GlobalVarWriterUtil
    message.setProperty("MASKING_JSON", configText)
    message.setHeader("CONFIG_VALIDATION", "PASSED")
    message.setHeader("CONFIG_IFLOW_COUNT", (config.root.Iflow?.size() ?: 0).toString())

    return message
}

 

PayloadMaskUtil.groovy:

This is the main masking script. It reads the runtime iFlow ID via ${camelId} and the global variable config directly from the exchange context, using the same technique as the dynamic custom headers blog. No Content Modifier steps are needed per iFlow.

Key design decisions in this script:

  • Auto detects iFlow ID from the Camel runtime: no INTERFACE_ID property needed per iFlow
  • Stores original payload as an AES-encrypted header/property
  • Sets MASKING_INTERFACE_ID header on every message so teams can see the exact camelId to use in the JSON config
  • Auto-detects XML vs JSON payload format
  • XML processing handles elements, attributes, and XML declaration consistency
  • JSON processing preserves original field types: numbers stay numbers, booleans stay booleans
  • Value resolution priority: actual value pattern first, then field name keywords, then DEFAULT
import com.sap.it.api.securestore.SecureStoreService
import com.sap.it.api.ITApiFactory

import groovy.json.JsonSlurper
import groovy.json.JsonOutput

import groovy.util.XmlParser
import groovy.xml.XmlUtil
import groovy.xml.QName

import groovy.transform.Field

import org.apache.camel.Exchange
import org.apache.camel.builder.SimpleBuilder

import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import javax.crypto.spec.IvParameterSpec

import java.security.SecureRandom
import java.util.Base64
import java.util.Arrays
import java.io.Reader

// ----------------------
// Script-level constants
// ----------------------
@Field final String HDR_ORIG   = "X_ORIGINAL_PAYLOAD"
@Field final String HDR_STORE  = "X_ORIGINAL_STORAGE"       // HEADER|PROPERTY
@Field final String HDR_MASKED = "X_MASKING_ACTIVE"         // true|false
@Field final String PROP_ORIG  = "PAYLOAD_MASKING_ORIGINAL" // encrypted

@Field final String PROP_CFG   = "P_GlobalPayloadMaskingConfig" // config injected by Content Modifier

def processData(message) {

    Exchange exchange = message.exchange

    String interfaceId = SimpleBuilder.simple('${camelId}')
            .evaluate(exchange, String)?.toString()?.trim() ?: "UNKNOWN"
    message.setHeader("MASKING_INTERFACE_ID", interfaceId)

    // Read config from Exchange Property (set by Content Modifier)
    String configText = message.getProperty(PROP_CFG) as String

    // Read payload ONCE
    Reader bodyReader = message.getBody(Reader)
    String originalPayload = bodyReader ? bodyReader.text : ""

    // Defaults
    Map globalMasking = [:]
    List globalKeepReal = []
    List iflowList = []
    Map settings = [:]

    if (configText?.trim()) {
        try {
            def cfg = new JsonSlurper().parseText(configText)
            def root = cfg?.root
            settings       = (root?.Settings ?: [:]) as Map
            globalKeepReal = (root?.GlobalKeepReal ?: []) as List
            globalMasking  = (root?.GlobalMasking ?: [:]) as Map
            iflowList      = (root?.Iflow ?: []) as List
        } catch (Exception e) {
            message.setHeader("MASKING_WARNING",
                "Config JSON parse failed from property ${PROP_CFG}. Masking entire payload for: ${interfaceId}"
            )
            configText = null
        }
    } else {
        message.setHeader("MASKING_WARNING",
            "Property ${PROP_CFG} is empty (global variable not assigned?). Masking entire payload for: ${interfaceId}"
        )
    }

    // Find per-iFlow config entry
    def ifaceConfig = iflowList.find {
        it?.IflowID?.toString()?.trim()?.equalsIgnoreCase(interfaceId)
    }
    List ifaceKeepReal = (ifaceConfig?.KeepReal ?: []) as List

    if (!ifaceConfig) {
        message.setHeader("MASKING_WARNING",
            "Interface ${interfaceId} not found in config. All fields masked. " +
            "Check IflowID value matches MASKING_INTERFACE_ID exactly."
        )
    }

    // ---------------- Feature switch ----------------
    boolean globalEnabled = (settings?.Enabled instanceof Boolean) ? (boolean) settings.Enabled : true
    boolean iflowEnabled  = (ifaceConfig?.Enabled instanceof Boolean) ? (boolean) ifaceConfig.Enabled : true
    boolean maskingEnabled = globalEnabled && iflowEnabled

    boolean allowBypass = (settings?.AllowBypassHeader instanceof Boolean) ? (boolean) settings.AllowBypassHeader : false
    String bypassHeaderName = (settings?.BypassHeaderName ?: "X_MASKING_BYPASS").toString()

    if (allowBypass) {
        // CPI API: getHeader(name, Class)
        String bypassVal = message.getHeader(bypassHeaderName, String.class)
        if (bypassVal != null && bypassVal.equalsIgnoreCase("true")) {
            maskingEnabled = false
            message.setHeader("MASKING_WARNING",
                "Masking bypassed via header ${bypassHeaderName}=true (AllowBypassHeader enabled)."
            )
        }
    }

    message.setHeader(HDR_MASKED, maskingEnabled.toString())

    // ---------------- Storage mode (HEADER|PROPERTY|AUTO) ----------------
    String globalMode = (settings?.StorageMode ?: "HEADER").toString().trim().toUpperCase()
    String iflowMode  = (ifaceConfig?.StorageMode ?: "").toString().trim().toUpperCase()
    String mode = (iflowMode ? iflowMode : globalMode)
    if (!["HEADER", "PROPERTY", "AUTO"].contains(mode)) mode = "HEADER"

    int maxHeaderBytes = 32000
    if (settings?.MaxHeaderBytes instanceof Number) {
        maxHeaderBytes = Math.max(1024, ((Number) settings.MaxHeaderBytes).intValue())
    }

    int payloadBytes = (originalPayload ?: "").getBytes("UTF-8").length

    String chosenStorage
    if (mode == "AUTO") {
        chosenStorage = (payloadBytes <= maxHeaderBytes) ? "HEADER" : "PROPERTY"
    } else {
        chosenStorage = mode
    }

    // Encrypt and store original always (so restore can happen)
    String encKey = getEncryptionKeyOrFail()
    String encryptedOriginal = encrypt(originalPayload ?: "", encKey)

    if (chosenStorage == "HEADER") {
        message.setHeader(HDR_ORIG, encryptedOriginal)
        message.setHeader(HDR_STORE, "HEADER")
        message.setProperty(PROP_ORIG, null)
    } else {
        message.setProperty(PROP_ORIG, encryptedOriginal)
        message.setHeader(HDR_STORE, "PROPERTY")
        message.getHeaders().remove(HDR_ORIG)
    }

    // If masking disabled, keep original body
    if (!maskingEnabled) {
        message.setBody(originalPayload)
        return message
    }

    // Combined whitelist (case-insensitive)
    Set keepRealSet = (globalKeepReal + ifaceKeepReal)
            .collect { it?.toString()?.trim() }
            .findAll { it }
            .collect { it.toLowerCase() } as Set

    String trimmed = (originalPayload ?: "").trim()
    String maskedPayload

    if (trimmed.startsWith("<")) {
        maskedPayload = maskXmlPayload(trimmed, keepRealSet, globalMasking)
    } else if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
        maskedPayload = maskJsonPayload(trimmed, keepRealSet, globalMasking)
    } else {
        maskedPayload = (globalMasking["DEFAULT"] ?: "MASKED").toString()
    }

    message.setBody(maskedPayload)
    return message
}

// ── Secure Store / Key ────────────────────────────────────────────────────────
String getEncryptionKeyOrFail() {
    def store = ITApiFactory.getApi(SecureStoreService.class, null)
    if (!store) {
        throw new RuntimeException("[PAYLOAD_MASKING] SecureStoreService is not available.")
    }

    def cred = store.getUserCredential("PayloadMaskingEncKey")
    if (!cred) {
        throw new RuntimeException("[PAYLOAD_MASKING] Missing credential 'PayloadMaskingEncKey' in Security Material.")
    }

    String key = new String(cred.getPassword() ?: "")
    int len = key.getBytes("UTF-8").length
    if (len < 16) {
        throw new RuntimeException("[PAYLOAD_MASKING] Encryption key must be at least 16 bytes for AES-128. Current length=" + len)
    }
    return key
}

String encrypt(String plaintext, String key) {
    byte[] iv = new byte[16]
    new SecureRandom().nextBytes(iv)

    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
    cipher.init(Cipher.ENCRYPT_MODE,
        new SecretKeySpec(Arrays.copyOfRange(key.getBytes("UTF-8"), 0, 16), "AES"),
        new IvParameterSpec(iv)
    )

    byte[] encrypted = cipher.doFinal(plaintext.getBytes("UTF-8"))
    byte[] combined = new byte[16 + encrypted.length]
    System.arraycopy(iv, 0, combined, 0, 16)
    System.arraycopy(encrypted, 0, combined, 16, encrypted.length)

    return Base64.encoder.encodeToString(combined)
}

// ── XML ───────────────────────────────────────────────────────────────────────
String maskXmlPayload(String xml, Set keepRealSet, Map globalMasking) {
    try {
        def parsed = new XmlParser().parseText(xml)
        maskXmlNode(parsed, keepRealSet, globalMasking)

        String serialized = XmlUtil.serialize(parsed)

        // Remove XML declaration if original didn't have it
        if (!xml.trim().startsWith("]*\?>\s*/, "")
        }

        return serialized.trim()
    } catch (Exception e) {
        return (globalMasking["DEFAULT"] ?: "MASKED").toString()
    }
}

void maskXmlNode(node, Set keepRealSet, Map globalMasking) {

    def attrs = node.attributes()
    attrs.keySet().toList().each { attrKey ->
        String attrName = (attrKey instanceof QName) ? ((QName) attrKey).localPart : attrKey.toString()
        if (!keepRealSet.contains(attrName.toLowerCase())) {
            attrs[attrKey] = resolveMaskValue(attrName, (attrs[attrKey] ?: "").toString(), globalMasking)
        }
    }

    node.children().each { child ->
        if (child instanceof groovy.util.Node) {
            String fieldName = (child.name() instanceof QName) ? ((QName) child.name()).localPart : child.name().toString()

            boolean isLeaf = child.children().every { it instanceof String }
            if (isLeaf) {
                if (!keepRealSet.contains(fieldName.toLowerCase())) {
                    child.setValue(resolveMaskValue(fieldName, child.text() ?: "", globalMasking))
                }
            } else {
                maskXmlNode(child, keepRealSet, globalMasking)
            }
        }
    }
}

// ── JSON ───────────────────────────────────────────────────────────────────────
String maskJsonPayload(String json, Set keepRealSet, Map globalMasking) {
    try {
        def parsed = new JsonSlurper().parseText(json)
        maskJsonNode(parsed, keepRealSet, globalMasking)
        return JsonOutput.prettyPrint(JsonOutput.toJson(parsed))
    } catch (Exception e) {
        return (globalMasking["DEFAULT"] ?: "MASKED").toString()
    }
}

void maskJsonNode(obj, Set keepRealSet, Map globalMasking) {
    if (obj instanceof Map) {
        obj.keySet().toList().each { key ->
            def val = obj[key]
            String k = key.toString()

            if (keepRealSet.contains(k.toLowerCase())) {
                // keep real
            } else if (val instanceof Map || val instanceof List) {
                maskJsonNode(val, keepRealSet, globalMasking)
            } else {
                String maskStr = resolveMaskValue(k, val?.toString() ?: "", globalMasking)
                obj[key] = castToOriginalType(val, maskStr)
            }
        }
    } else if (obj instanceof List) {
        obj.each { item -> maskJsonNode(item, keepRealSet, globalMasking) }
    }
}

def castToOriginalType(original, String maskStr) {
    if (original == null) return maskStr
    if (original instanceof Boolean) return false

    if (original instanceof Integer) {
        try { return ((maskStr.replaceAll(/[^\d]/, "").take(6) ?: "0") as String).toInteger() }
        catch (Exception e) { return 0 }
    }

    if (original instanceof Long) {
        try { return ((maskStr.replaceAll(/[^\d]/, "").take(10) ?: "0") as String).toLong() }
        catch (Exception e) { return 0L }
    }

    if (original instanceof Double || original instanceof Float || original instanceof BigDecimal) {
        try {
            String cleaned = maskStr.replaceAll(/[^\d.]/, "")
            return cleaned ? cleaned.toBigDecimal() : 0.0
        } catch (Exception e) { return 0.0 }
    }

    return maskStr
}

// ── Mask Resolver ─────────────────────────────────────────────────────────────
String resolveMaskValue(String fieldName, String actualValue, Map globalMasking) {

    String DEFAULT = (globalMasking["DEFAULT"] ?: "MASKED").toString()

    // Priority 1: actual value pattern
    if (actualValue) {
        if (actualValue ==~ /[A-Z]{2}\d{2}[\dA-Z]{10,30}/)
            return (globalMasking["IBAN"] ?: DEFAULT).toString()

        if (actualValue ==~ /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/)
            return (globalMasking["EMAIL"] ?: DEFAULT).toString()

        if (actualValue ==~ /\+?[\d\s\-\(\)]{7,20}/ && fieldName.toLowerCase().contains("phone"))
            return (globalMasking["PHONE"] ?: DEFAULT).toString()

        if (actualValue ==~ /\d{4}-\d{2}-\d{2}.*/)
            return (globalMasking["DATE"] ?: DEFAULT).toString()

        if (actualValue ==~ /\d+\.\d{2}/)
            return (globalMasking["AMOUNT"] ?: DEFAULT).toString()
    }

    // Priority 2: field name keywords
    String fn = fieldName.toLowerCase()
    if (fn.contains("name"))                                  return (globalMasking["NAME"] ?: DEFAULT).toString()
    if (fn.contains("email") || fn.contains("mail"))          return (globalMasking["EMAIL"] ?: DEFAULT).toString()
    if (fn.contains("iban")  || fn.contains("bank"))          return (globalMasking["IBAN"] ?: DEFAULT).toString()
    if (fn.contains("phone") || fn.contains("mobile") || fn.contains("tel"))
        return (globalMasking["PHONE"] ?: DEFAULT).toString()
    if (fn.contains("street") || fn.contains("address"))      return (globalMasking["ADDRESS"] ?: DEFAULT).toString()
    if (fn.contains("city") || fn.contains("ort"))            return (globalMasking["CITY"] ?: DEFAULT).toString()
    if (fn.contains("zip") || fn.contains("postal") || fn.contains("plz"))
        return (globalMasking["ZIP"] ?: DEFAULT).toString()
    if (fn.contains("amount") || fn.contains("price") || fn.contains("total") || fn.contains("value"))
        return (globalMasking["AMOUNT"] ?: DEFAULT).toString()
    if (fn.contains("date"))                                  return (globalMasking["DATE"] ?: DEFAULT).toString()

    // Priority 3: DEFAULT
    return DEFAULT
}

PayloadRestoreUtil.groovy:

Decrypts the stored original and restores it as the message body.

import com.sap.it.api.securestore.SecureStoreService
import com.sap.it.api.ITApiFactory

import groovy.transform.Field

import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import javax.crypto.spec.IvParameterSpec

import java.util.Base64
import java.util.Arrays

@Field final String HDR_ORIG  = "X_ORIGINAL_PAYLOAD"
@Field final String HDR_STORE = "X_ORIGINAL_STORAGE"
@Field final String PROP_ORIG = "PAYLOAD_MASKING_ORIGINAL"

def processData(message) {

    // CPI Message API: use getHeader(name, Class)
    String encrypted = message.getHeader(HDR_ORIG, String.class)
    String storage   = message.getHeader(HDR_STORE, String.class) ?: ""

    if (encrypted == null) {
        encrypted = message.getProperty(PROP_ORIG) as String
        if (!storage) storage = "PROPERTY"
    } else {
        if (!storage) storage = "HEADER"
    }

    if (encrypted == null) {
        throw new RuntimeException(
            "[PAYLOAD_MASKING] CRITICAL: Original payload not found in header or property. " +
            "Aborting to prevent masked data reaching the target system. " +
            "Ensure PayloadMaskUtil ran before this step."
        )
    }

    String key = getEncryptionKeyOrFail()

    try {
        message.setBody(decrypt(encrypted, key))
    } catch (Exception e) {
        throw new RuntimeException(
            "[PAYLOAD_MASKING] CRITICAL: Failed to decrypt original payload. " +
            "Possible wrong key or corrupted ciphertext. Root cause: ${e.message}", e
        )
    } finally {
        // Remove encrypted data ASAP
        message.getHeaders().remove(HDR_ORIG)
        message.getHeaders().remove(HDR_STORE)
        message.setProperty(PROP_ORIG, null)
    }

    return message
}

String getEncryptionKeyOrFail() {
    def store = ITApiFactory.getApi(SecureStoreService.class, null)
    if (!store) {
        throw new RuntimeException("[PAYLOAD_MASKING] SecureStoreService is not available.")
    }

    def cred = store.getUserCredential("PayloadMaskingEncKey")
    if (!cred) {
        throw new RuntimeException(
            "[PAYLOAD_MASKING] Missing credential 'PayloadMaskingEncKey' in Security Material."
        )
    }

    String key = new String(cred.getPassword() ?: "")
    int len = key.getBytes("UTF-8").length
    if (len < 16) {
        throw new RuntimeException(
            "[PAYLOAD_MASKING] Encryption key must be at least 16 bytes for AES-128. Current length=" + len
        )
    }
    return key
}

String decrypt(String ciphertext, String key) {
    byte[] combined = Base64.decoder.decode(ciphertext)
    byte[] iv = Arrays.copyOfRange(combined, 0, 16)
    byte[] encrypted = Arrays.copyOfRange(combined, 16, combined.length)

    def cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
    cipher.init(Cipher.DECRYPT_MODE,
        new SecretKeySpec(Arrays.copyOfRange(key.getBytes("UTF-8"), 0, 16), "AES"),
        new IvParameterSpec(iv)
    )

    return new String(cipher.doFinal(encrypted), "UTF-8")
}

Step 2: Create the Encryption Key in Secure Parameter Store

The original payload is AES-128 encrypted before being stored in the exchange. The key lives in the SAP CPI Secure Parameter Store, which has its own access controls separate from monitoring roles.

  1. Go to Monitor > Integrations > Security Material
  2. Add a new User Credentials entry
  3. Name: PayloadMaskingEncKey
  4. Username: PayloadMaskKey
  5. Password: any 16-character string (example: DataMaskKey@SAP01!!)
  6. Save and deploy

Reference: SAP Help: Deploying User Credentials Artifact https://help.sap.com/docs/cloud-integration/sap-cloud-integration/deploying-user-credentials-artifac…

 

Step 3: Create the Setter iFlow SetGlobalPayloadMaskingConfig

This iFlow is used to set the global variable and validates the JSON config before writing it to the global variable.

Adarshrao_1-1779727695005.Png

Adarshrao_0-1779728028874.Png

Sample JSON Configuration:

The configuration is stored in global variable GlobalPayloadMaskingConfig and shared at runtime. It contains: 

  • Settings: feature switch and storage mode. 
  • GlobalKeepReal: fields never masked tenant‑wide (structural / routing / correlation). 
  • GlobalMasking: replacement values (NAME/EMAIL/IBAN/DEFAULT, etc.). 
  • Iflow[]: per interface KeepReal plus optional Enabled and StorageMode overrides. 
{ 

  "root": { 

    "Settings": { 

      "Enabled": true, 

      "StorageMode": "AUTO", 

      "MaxHeaderBytes": 32000, 

      "AllowBypassHeader": false, 

      "BypassHeaderName": "X_MASKING_BYPASS" 

    }, 

    "GlobalKeepReal": [ 

      "CompanyCode", "DocumentType", "MessageType", 

      "Plant", "Language", "Currency", "ProcessingStatus", 

      "RecordType", "SegmentName" 

    ], 

    "GlobalMasking": { 

      "NAME":    "Max Mustermann", 

      "EMAIL":   "no-reply@masking.invalid", 

      "IBAN":    "DE00 0000 0000 0000 0000 00", 

      "PHONE":   "+49 000 00000000", 

      "ADDRESS": "Musterstrasse 1", 

      "CITY":    "Musterstadt", 

      "ZIP":     "00000", 

      "AMOUNT":  "0.00", 

      "DATE":    "1900-01-01", 

      "NUMERIC": "00000", 

      "DEFAULT": "MASKED" 

    }, 

    "Iflow": [ 

      { 

        "IflowID": "OrderToS4_iFlow", 

        "KeepReal": ["OrderNumber", "PostingDate", "Material"], 

        "Enabled": true, 

        "StorageMode": "HEADER" 

      }, 

      { 

        "IflowID": "CustomerMaster_iFlow", 

        "KeepReal": ["BusinessPartner", "CreationDate"], 

        "Enabled": true, 

        "StorageMode": "AUTO" 

      } 

    ] 

  } 

} 

Recommended defaults: 

  • Settings.Enabled = true 
  • Settings.StorageMode = AUTO 
  • Settings.MaxHeaderBytes = 32000 

Common scenarios: 

  • Disable masking globally (non‑prod): set Settings.Enabled = false. 
  • Disable masking for one interface: set Iflow[].Enabled = false for that IflowID. 
  • Force lightweight for large payload interface: set Iflow[].StorageMode = PROPERTY. 
  • Controlled runtime bypass (only if allowed): set Settings.AllowBypassHeader=true and pass header X_MASKING_BYPASS=true for the message. 

Adarshrao_1-1779728069572.Png

Adarshrao_2-1779728197445.Png

 

Step 4: Exisiting or new iFlows Configuration

While logging the payload, to mask the sensitive information, please follow below steps.

1. Create a property P_GlobalPayloadMaskingConfig and assign it with global variable “GlobalPayloadMaskingConfig

Adarshrao_0-1779728864688.Png

2. Assign the groovy script “PayloadMaskUtil” from the Script Collection “PayloadMaskingLibrary

3. Assign the normal log Payload script

 

To restore the original payload after data masking, please follow the below step

1. Assign the groovy script “PayloadRestoreUtil” from the Script Collection “PayloadMaskingLibrary

Sample payload after masking the sensitive information for the interface:



4500012345

2026-05-25

1000



Max Mustermann

no-reply@masking.invalid

+49 000 00000000

DE00 0000 0000 0000 0000 00





0.00

EUR

MASKED

MASKED





Musterstrasse 1

Musterstadt

00000



Sample payload after restoring the original payload:


4500012345
2026-05-25
1000


Schmidt GmbH
orders@schmidt-gmbh.de
+49 151 23456789
DE89370400440532013000



14750.00
EUR
true
5



Hauptstrasse 42
Frankfurt
60311

Encrypted Original message in header/property so that, sensitive information is not visible

Adarshrao_1-1779730336337.Png

 

1. Trace mode: CPI trace can capture payload before scripts run. This is a residual risk and must be controlled via roles, change management and audit logging. 

2. Pattern-based masking is heuristic: extend regex/keyword list or move to schema-based masking if you must guarantee detection of all sensitive values.

3. Payload size: large payloads increase memory usage. Use AUTO mode and tune MaxHeaderBytes; test under load.

This pattern is not a debugging convenience. It is a GDPR compliance control.

The principle of data protection by design and by default, required by GDPR Article 25, means the system should be built so that integration teams can only see what they legitimately need to do their job. For a middleware integration team, that means message structure, format, data types, and representative values. It does not mean the actual personal data of real customers, or employees.

Source link

Leave a Reply

Your email address will not be published. Required fields are marked *