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;
}
}
For further actions, you may consider blocking this person and/or reporting abuse
Top comments (0)