DEV Community

Charlie Fubon
Charlie Fubon

Posted on

Huhb


package com.bmo.dc.securityapi.era.task.riskengine;

import com.bmo.dc.securityapi.era.task.helper.APITask;
import com.bmo.dc.securityapi.era.util.RSACryptoUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.apache.log4j.Logger;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.StringReader;
import java.util.HashMap;
import java.util.Map;

import org.xml.sax.InputSource;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;

/**
 * RSAAdapter — the "black box" vendor adapter for RSA Adaptive Authentication.
 *
 * Per the BCAdapter design pattern (KT doc §3), this class absorbs everything the
 * old RSA Lambda did EXCEPT:
 *   - Vendor request mapping    (handled by RSATask via callRequestMapper)
 *   - Vendor response mapping   (handled here — Envelope.Body[callType+Response] unwrapping)
 *   - EDF call                  (explicitly dropped per design doc)
 *
 * 1:1 port source:
 *   CG2V_SCCG-RSAAdaptiveAuthentication-CDK_26777/service/lambda-functions/service/sub/
 *   - analyzeAxios.js   (FULLY PORTED — template for all call types)
 *   - notifyAxios.js    (FULLY PORTED — confirmed near-identical to analyzeAxios.js)
 *   - createUserAxios.js  (STUBBED — not yet read, fill in after reviewing file)
 *   - updateUserAxios.js  (STUBBED — not yet read, fill in after reviewing file)
 *   - queryAxios.js       (STUBBED — not yet read, fill in after reviewing file)
 *
 * File location:
 *   service/src/main/java/com/bmo/dc/securityapi/era/task/riskengine/RSAAdapter.java
 *
 * Wiring still needed (separate commit):
 *   1. Add HelperTaskFactory.BuildRSAAdapterTask(...) method
 *   2. Swap BuildAWSAPIKeyTask → BuildRSAAdapterTask in RiskEngineTaskFactory RSA case
 */
public class RSAAdapter extends APITask {

    private static final Logger logger = Logger.getLogger(RSAAdapter.class);
    private static final ObjectMapper objectMapper = new ObjectMapper();

    // ---- SOAPAction values per call type ----------------------------------------
    // analyze/notify confirmed from analyzeAxios.js line 177 / notifyAxios.js.
    // createuser/updateuser/query: assumed pattern — MUST VERIFY against each
    // *Axios.js file before treating as production-ready. See KT doc §6.3.
    private static final String SOAP_ACTION_ANALYZE     = "rsa:analyze:Analyze";
    private static final String SOAP_ACTION_NOTIFY      = "rsa:notify:Notify";
    private static final String SOAP_ACTION_CREATE_USER = "rsa:createUser:CreateUser";   // TODO verify
    private static final String SOAP_ACTION_UPDATE_USER = "rsa:updateUser:UpdateUser";   // TODO verify
    private static final String SOAP_ACTION_QUERY       = "rsa:query:Query";             // TODO verify

    // ---- SOAP namespace constants (from analyzeAxios.js lines 168-170) ----------
    private static final String SOAP_NS =
            "xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" " +
            "xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" " +
            "xmlns:tns=\"http://ws.csd.rsa.com\" " +
            "xmlns:rsa_csd=\"http://ws.csd.rsa.com\" " +
            "xmlns:atm=\"http://ws.csd.rsa.com\"";

    // ---- Config fields ----------------------------------------------------------
    // Mirror the env-var names in analyzeAxios.js, resolved via ERA config system.
    // Exact key names must be confirmed against actual config JSON in AWS.
    private final String nlbUrlCA;
    private final String nlbUrlUS;
    private final String apicNlbUrl;
    private final String harrisDPHost;
    private final String harrisDPPort;
    private final String olbDPHost;
    private final String olbDPPort;
    private final String uriForRSAAA;
    private final String uriForAsyncRSAAA;
    private final String olbbAPICHost;
    private final String olbbAPICPort;
    private final String uriOLBBRSAAA;
    private final String apiKeyOLBBRSAAA;
    private final String olbbRSACrSM;
    private final String olbbRSACrSMRole;
    private final String apicALESecret;
    private final String apicALESecretRole;

    public RSAAdapter(JsonNode config) throws Exception {
        super(config);
        this.nlbUrlCA          = cfg(config, "nlbUrlCA",          "sc-sccg-ue-sbox01-nlb04-d0a0a5d04a429c71.elb.us-east-1.amazonaws.com");
        this.nlbUrlUS          = cfg(config, "nlbUrlUS",          "sc-sccg-ue-sbox01-nlb04-d0a0a5d04a429c71.elb.us-east-1.amazonaws.com");
        this.apicNlbUrl        = cfg(config, "apicNlbUrl",        "nlb-egress-shr01-ue1-sbx-10-37764d264ad5f28d.elb.us-east-1.amazonaws.com");
        this.harrisDPHost      = cfg(config, "harrisDPHost",      "cgsg-in-dit1.bmogc.net");
        this.harrisDPPort      = cfg(config, "harrisDPPort",      ":7579");
        this.olbDPHost         = cfg(config, "olbDPHost",         "cgsg-in-dit1.bmogc.net");
        this.olbDPPort         = cfg(config, "olbDPPort",         ":8771");
        this.uriForRSAAA       = cfg(config, "uriForRSAAA",       "/AdaptiveAuthentication/services/AdaptiveAuthentication");
        this.uriForAsyncRSAAA  = "/AdaptiveAuthentication/services/AsyncAdaptiveAuthentication";
        this.olbbAPICHost      = cfg(config, "olbbAPICHost",      "api2-dev.bmogc.net");
        this.olbbAPICPort      = cfg(config, "olbbAPICPort",      ":443");
        this.uriOLBBRSAAA      = cfg(config, "uriOLBBRSAAA",      "/api/sys-olbbrsaaaop/fraud-evaluation/fraud-assessment/user-valid");
        this.apiKeyOLBBRSAAA   = cfg(config, "apiKeyOLBBRSAAA",   "5a36de7a5cb3622a9857d5efea2ffa4b");
        this.olbbRSACrSM       = cfg(config, "olbbRSACrSM",       "");
        this.olbbRSACrSMRole   = cfg(config, "olbbRSACrSMRole",   "");
        this.apicALESecret     = cfg(config, "apicALESecret",     "");
        this.apicALESecretRole = cfg(config, "apicALESecretRole", "na");
    }

    /**
     * Main entry point — called by RiskEngineTask.callAPITask().
     *
     * Contract (KT doc §5.3):
     *   input  = { headers: { x-request-id, ... }, data: <mapped RSA request payload> }
     *   output = { status: "200", data: <unwrapped call-type response JSON> }
     */
    @Override
    public JsonNode execute(JsonNode input) throws Exception {
        ObjectNode headers = (ObjectNode) input.get("headers");
        JsonNode payload   = input.get("data");

        String callType = payload.path("request").path("messageHeader")
                .path("requestType").asText("").toLowerCase();

        logger.info("RSAAdapter.execute callType=" + callType);

        switch (callType) {
            case "analyze":
                return doVendorCall(payload, headers, SOAP_ACTION_ANALYZE, callType);
            case "notify":
                return doVendorCall(payload, headers, SOAP_ACTION_NOTIFY, callType);
            case "createuser":
                // TODO: read createUserAxios.js — confirm pattern matches analyze/notify
                // before treating this path as verified. See KT doc §6.3.
                return doVendorCall(payload, headers, SOAP_ACTION_CREATE_USER, callType);
            case "updateuser":
                // TODO: read updateUserAxios.js — same as above.
                return doVendorCall(payload, headers, SOAP_ACTION_UPDATE_USER, callType);
            case "query":
                // TODO: read queryAxios.js — same as above.
                return doVendorCall(payload, headers, SOAP_ACTION_QUERY, callType);
            default:
                throw new Exception("RSAAdapter: unrecognized callType '" + callType + "'");
        }
    }

    // ============================================================================
    // Core vendor-call pipeline
    // Mirrors analyzeAxios.js / notifyAxios.js logic end-to-end.
    // All 5 call types share this pipeline — differ only in SOAPAction + response
    // root element (derived from callType).
    // ============================================================================

    private JsonNode doVendorCall(JsonNode payload, ObjectNode inboundHeaders,
                                   String soapAction, String callType) throws Exception {

        // Step 1: OLBB flag (analyzeAxios.js line 55)
        // orgName === "OLBBUS" → completely different host/auth/secret path
        String orgName = payload.path("request").path("identificationData")
                .path("orgName").asText("");
        boolean isOLBB = "OLBBUS".equals(orgName);

        // Step 2: OLBB secret / callerCredential (analyzeAxios.js lines 57-79)
        if (isOLBB) {
            // TODO: fetch OLBB secret via SecretsManagerUtil/STSManager.
            // Logic: if OLBBRSACrSMRole.startsWith("arn:aws:iam::") →
            //           getSecretValueByAssumeRole(OLBBRSACrSMRole, OLBBRSACrSM)
            //        else → getSecret(OLBBRSACrSM)
            // Then: payload.request.securityHeader.callerCredential = secret.rsaAAOPPwd
            // Requires deep-cloning the payload before mutation (immutable JsonNode).
            logger.warn("RSAAdapter: OLBB secret fetch not yet wired — callerCredential not set");
        }

        // Step 3: Async call flag (analyzeAxios.js lines 89-92)
        // asyncCallFlag="Y" → use async URI. In the new Java flow this flag is set
        // on args by serviceFunc.js after the first iteration of the request array loop.
        // TODO: determine how RSATask surfaces asyncCallFlag into the adapter context.
        // Defaulting to sync URI for now.
        String activeURI = uriForRSAAA;

        // Step 4: Host + URL resolution (analyzeAxios.js lines 94-112)
        String host;
        String url;

        if (isOLBB) {
            host = olbbAPICHost;
            url  = "https://" + apicNlbUrl + olbbAPICPort + uriOLBBRSAAA;
        } else {
            // Country-based override: originatorData.country === "US" → HARRISDP
            String country = payload.path("originatorData").path("country").asText("");
            if ("US".equalsIgnoreCase(country)) {
                host = harrisDPHost;
                url  = "https://" + nlbUrlUS + harrisDPPort + activeURI;
            } else {
                host = olbDPHost;
                url  = "https://" + nlbUrlCA + olbDPPort + activeURI;
            }
        }

        logger.info("RSAAdapter url=" + url + " isOLBB=" + isOLBB + " soapAction=" + soapAction);

        // Step 5: Build SOAP XML (analyzeAxios.js lines 160-170)
        // TODO: parseAttribute() and addNamespace() from utils.js not yet ported.
        // buildSoapEnvelope() below is a structural placeholder — produces valid XML
        // but WITHOUT namespace prefixes. Will not work against real RSA until TODO resolved.
        String xmlBody = buildSoapEnvelope(payload, callType);

        // Step 6: Auth mode + headers (analyzeAxios.js lines 172-207)
        ObjectNode requestHeaders = objectMapper.createObjectNode();
        requestHeaders.put("SOAPAction", soapAction);
        requestHeaders.put("Host", host);

        String requestBodyStr;

        if ("na".equals(apicALESecretRole) || isOLBB) {
            // Plain XML path (analyzeAxios.js lines 187-196)
            requestBodyStr = xmlBody;
            if (isOLBB) {
                requestHeaders.put("x-api-key", apiKeyOLBBRSAAA);
                requestHeaders.put("Content-type", "text/xml");
            }
        } else {
            // Encrypted path (analyzeAxios.js lines 198-207)
            // TODO: wire in RSACryptoUtil.encrypt() once secret fetch is implemented.
            // Blocked on:
            //   - fetching apicALESecret via SecretsManagerUtil (assume-role if apicALESecretRole is ARN)
            //   - getting secretRKP from the request context (pre-fetched in ERA's Lambda equivalent)
            // RSACryptoUtil is ready — just needs the secret values plugged in:
            //
            //   String xApiCryptoKey = inboundHeaders.has("x-apic-crypto-key")
            //           ? inboundHeaders.get("x-apic-crypto-key").asText() : null;
            //   RSACryptoUtil.EncryptResult encryptResult =
            //           RSACryptoUtil.encrypt(xmlBody, xApiCryptoKey, secret, secretRKP);
            //   ObjectNode encBody = objectMapper.createObjectNode();
            //   encBody.put("secure", encryptResult.payload);
            //   requestBodyStr = objectMapper.writeValueAsString(encBody);
            //   requestHeaders.put("x-apic-crypto-key", encryptResult.xApicCryptoKey);
            //   requestHeaders.put("x-apic-crypto-control", "request");
            //
            logger.warn("RSAAdapter: encrypted auth path reached but secret fetch not yet wired");
            requestBodyStr = xmlBody; // TEMPORARY — replace with block above once secrets wired
        }

        // Step 7: mTLS (analyzeAxios.js lines 122-158)
        // TODO: confirm proxy/cert requirement for ERA → RSA network path (KT doc §10).
        // APITask has httpPostRequestJsonViaProxy(..., certPem) for TLS via PEM cert.
        // Defaulting to plain POST until confirmed.

        // Step 8: HTTP call (analyzeAxios.js line 210)
        ObjectNode httpPayload = objectMapper.createObjectNode();
        httpPayload.put("body", requestBodyStr);

        JsonNode rawResponse = httpPostRequestJson(url, requestHeaders, httpPayload);

        // Step 9: Error taxonomy (analyzeAxios.js lines 213-312, KT doc §4.5)
        String statusCode = rawResponse.path("status").asText();
        if (!"200".equals(statusCode) && !statusCode.startsWith("2")) {
            throw buildVendorException(statusCode, rawResponse.path("message").asText());
        }

        // Step 10: XML parse + SOAP envelope unwrap (analyzeAxios.js lines 336-365)
        // Equivalent to old Lambda's parseXml() + mapResponse.js combined.
        String responseXml = rawResponse.path("data").asText("");
        JsonNode parsed = parseAndUnwrapSoapResponse(responseXml, callType);

        // Step 11: Return shape expected by RiskEngineTask.processAPIresult() (KT doc §5.3)
        // processAPIresult() checks response.get("status") and returns response.get("data")
        ObjectNode result = objectMapper.createObjectNode();
        result.put("status", "200");
        result.set("data", parsed);
        return result;
    }

    // ============================================================================
    // SOAP envelope builder
    // Port of analyzeAxios.js lines 160-170.
    // TODO: parseAttribute() + addNamespace() from utils.js not yet ported.
    // This produces structurally correct XML but WITHOUT rsa_csd: namespace prefixes
    // on inner elements. RSA vendor WILL reject this until namespace prefixing is added.
    // ============================================================================

    private String buildSoapEnvelope(JsonNode payload, String callType) throws Exception {
        String innerXml = jsonNodeToXml(payload.path("request"));
        return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
               "<soap:Envelope " + SOAP_NS + ">" +
               "<soap:Body>" +
               "<rsa_csd:" + callType + " xmlns:rsa_csd=\"http://ws.csd.rsa.com\">" +
               innerXml +
               "</rsa_csd:" + callType + ">" +
               "</soap:Body>" +
               "</soap:Envelope>";
    }

    /**
     * Minimal JSON → XML serializer. Placeholder until addNamespace() is ported.
     * Does NOT apply rsa_csd: namespace prefixes — MUST be replaced before
     * sending to a real RSA endpoint.
     */
    private String jsonNodeToXml(JsonNode node) {
        if (node == null || node.isNull() || node.isMissingNode()) return "";
        StringBuilder sb = new StringBuilder();
        node.fields().forEachRemaining(entry -> {
            String key = entry.getKey();
            JsonNode val = entry.getValue();
            if (val.isObject()) {
                sb.append("<").append(key).append(">")
                  .append(jsonNodeToXml(val))
                  .append("</").append(key).append(">");
            } else if (val.isArray()) {
                val.forEach(item -> sb.append("<").append(key).append(">")
                        .append(jsonNodeToXml(item))
                        .append("</").append(key).append(">"));
            } else {
                sb.append("<").append(key).append(">")
                  .append(val.asText())
                  .append("</").append(key).append(">");
            }
        });
        return sb.toString();
    }

    // ============================================================================
    // SOAP response parser + envelope unwrapper
    // Port of analyzeAxios.js parseXml() + mapResponse.js (KT doc §9.1 decision)
    // ============================================================================

    private JsonNode parseAndUnwrapSoapResponse(String xmlBody, String callType) throws Exception {
        if (xmlBody == null || xmlBody.trim().isEmpty()) {
            // parseEmpty() equivalent — canned empty response per call type
            // (e.g. notifyAxios.js: { Envelope: { Body: { notifyResponse: { notifyReturn: {} } } } })
            ObjectNode empty = objectMapper.createObjectNode();
            ObjectNode ret   = objectMapper.createObjectNode();
            ret.set(callType + "Return", objectMapper.createObjectNode());
            empty.set(callType + "Response", ret);
            logger.info("RSAAdapter: empty response for callType=" + callType);
            return empty;
        }

        try {
            // Parse XML with namespace-stripping (ignoreAttrs + stripPrefix equivalent)
            DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
            dbf.setNamespaceAware(false);
            DocumentBuilder db = dbf.newDocumentBuilder();
            Document doc = db.parse(new InputSource(new StringReader(xmlBody)));

            // DOM → JsonNode (simplified, emptyTag:null + explicitArray:false equivalent)
            JsonNode envelope = domToJson(doc.getDocumentElement());

            // mapResponse.js: response.Envelope.Body[callType + "Response"]
            // with callType normalization (createuser→createUser, updateuser→updateUser)
            String normalizedType = normalizeCallType(callType);
            JsonNode body = envelope.path("Body");
            if (body.isMissingNode()) {
                logger.warn("RSAAdapter: no Body in SOAP response, returning raw envelope");
                return envelope;
            }

            JsonNode typeResponse = body.path(normalizedType + "Response");
            if (typeResponse.isMissingNode()) {
                logger.warn("RSAAdapter: no " + normalizedType + "Response in Body, returning raw Body");
                return body;
            }

            return typeResponse;

        } catch (Exception e) {
            logger.error("RSAAdapter: XML parse/unwrap error callType=" + callType, e);
            throw e;
        }
    }

    /**
     * Normalizes callType for response key lookup.
     * Port of mapResponse.js lines 12-13.
     */
    private String normalizeCallType(String callType) {
        switch (callType) {
            case "createuser": return "createUser";
            case "updateuser": return "updateUser";
            default:           return callType;
        }
    }

    /**
     * DOM Element → JsonNode, stripping namespace prefixes.
     * Equivalent to xml2js { ignoreAttrs: true, tagNameProcessors: [stripPrefix],
     * explicitArray: false, emptyTag: null }.
     *
     * TODO: customReplacer() normalization (clientFactList.fact array wrapping,
     * deviceManagementResponse.deviceData array wrapping) NOT yet applied here —
     * needed for correct behavior on real RSA responses. Port customReplacer from
     * utils.js and apply during/after this conversion.
     */
    private JsonNode domToJson(org.w3c.dom.Element element) {
        ObjectNode node = objectMapper.createObjectNode();
        NodeList children = element.getChildNodes();

        for (int i = 0; i < children.getLength(); i++) {
            org.w3c.dom.Node child = children.item(i);
            if (child.getNodeType() != org.w3c.dom.Node.ELEMENT_NODE) continue;

            org.w3c.dom.Element childEl = (org.w3c.dom.Element) child;
            String localName = childEl.getLocalName() != null
                    ? childEl.getLocalName()
                    : stripPrefix(childEl.getNodeName());

            boolean isLeaf = childEl.getChildNodes().getLength() == 1 &&
                             childEl.getFirstChild().getNodeType() == org.w3c.dom.Node.TEXT_NODE;

            JsonNode childJson;
            if (isLeaf) {
                String text = childEl.getFirstChild().getNodeValue();
                childJson = (text == null || text.trim().isEmpty())
                        ? objectMapper.nullNode()   // emptyTag: null
                        : objectMapper.valueToTree(text.trim());
            } else {
                childJson = domToJson(childEl);
            }

            node.set(localName, childJson);
        }
        return node;
    }

    private String stripPrefix(String name) {
        int colon = name.indexOf(':');
        return colon >= 0 ? name.substring(colon + 1) : name;
    }

    // ============================================================================
    // Error taxonomy builder
    // Port of analyzeAxios.js catch blocks (lines 213-312, KT doc §4.5)
    // ============================================================================

    private Exception buildVendorException(String statusCode, String message) {
        ObjectNode errorBody = objectMapper.createObjectNode();
        ObjectNode result    = objectMapper.createObjectNode();
        result.put("status",   "Failure");
        result.set("messages", objectMapper.createArrayNode().add("request could not be completed"));

        ObjectNode detail = objectMapper.createObjectNode();
        detail.put("sourceAppCatId", "26777");
        detail.put("detailCode", "SYSERROR");

        boolean isTimeout  = "504".equals(statusCode) ||
                             (message != null && (message.contains("timeout") ||
                              message.contains("ETIMEDOUT") || message.contains("ECONNREFUSED")));
        boolean isDataPower = message != null && message.contains("DataPower");
        boolean isAborted   = message != null && (message.contains("aborted") ||
                              message.contains("ECONNABORTED"));

        if (isTimeout) {
            result.put("code", "504");
            detail.put("detailMessage", "backend returned with timeout");
        } else if (isDataPower) {
            result.put("code", "500");
            detail.put("detailMessage", "could not establish connection with backend");
        } else if (isAborted) {
            result.put("code", "504");
            detail.put("detailMessage", "Request aborted");
        } else {
            // Fallthrough (analyzeAxios.js line 311: return reject(error) — raw pass-through)
            return new Exception("RSAAdapter vendor error HTTP " + statusCode + ": " + message);
        }

        result.set("resultDetails", objectMapper.createArrayNode().add(detail));
        errorBody.set("result", result);
        return new Exception("RSAAdapter vendor error: " + errorBody.toString());
    }

    // ============================================================================
    // Config helper
    // ============================================================================

    private String cfg(JsonNode config, String key, String defaultValue) {
        return (config != null && config.has(key) && !config.get(key).isNull())
                ? config.get(key).asText()
                : defaultValue;
    }
}

Enter fullscreen mode Exit fullscreen mode

Top comments (0)