Difficulty: Advanced
TL;DR — The Fusion OTBI/BIP SOAP stack (SAWSessionService.logon) requires a plaintext username and password in the request body. The moment activity tracing in an OIC integration is bumped to Debug, those Fusion OTBI credentials in OIC payloads land in OIC’s monitoring database in clear. We rebuilt the OTBI-session step as an Oracle Function backed by OCI Vault: OIC sends only a Fusion URL, the function reads credentials from Vault over OCI’s internal network via the Service Gateway — credentials never traverse the public internet — calls Fusion, and returns a sessionId. Stack: OIC Gen 3 (native OCI Functions action) + OCI Vault + OCI Functions + Fusion OTBI SOAP. Read this if you have any OIC integration that talks to OTBI, BIP CatalogService, or any other Fusion SOAP endpoint whose payload contains credentials.
Tested on: Fusion 26A · OIC Gen 3 · OCI region eu-frankfurt-1 · Fn CLI 0.6.48 · Python runtime · function on a private subnet with Service Gateway + NAT Gateway.
Why Fusion OTBI credentials leak through OIC
Our customer runs several Fusion instances — dev, test, prod — and has a portfolio of OIC integrations that utilise the OBIEE catalog services for downstream analytics. Every one of those integrations follows the same well-known pattern: invoke /analytics-ws/saw.dll?SoapImpl=nQSessionService with a SOAP envelope containing the <v6:logon> element, capture the returned sessionID, then reuse it across subsequent CatalogService calls. This is the same building block we’ve used elsewhere — for example, in our earlier work on submitting OIC integrations through ESS Jobs, the BIP HTTP outbound depends on a usable OTBI/BIP session.
Where the credential exposure happens
The structural problem with the OTBI logon pattern is that the contract has no header-based authentication option. The username and password must travel in the SOAP body. As long as that call lives inside an OIC integration, those credentials sit one toggle away from being written to OIC monitoring storage — any operator who enables activity tracing at Debug level for diagnosis exports the full payload to the activity stream. The Fusion accounts in question typically have BI Consumer + BI Author + report execution privileges across multiple pillars; a compromised one is not a small event.
Where the credential should actually live
The credential has to exist somewhere OIC can reach without ever appearing in an OIC artefact. The architectural question is where, and how the code that needs it actually retrieves it. Our answer: store credentials in OCI Vault, retrieve them at runtime from an Oracle Function authenticated via Resource Principal — and run the function on a private subnet so the entire vault-read happens over OCI’s internal network via the Service Gateway. No API keys, no shared secrets, no credential material in any OIC artefact, and no credential traffic on the public internet.
The same anti-pattern (URLs and credentials embedded in OIC artefacts) is what we eliminated for the Fusion → OIC direction in our Custom Object Integration Events pattern. This post handles the inverse — OIC → Fusion — and uses a different mechanism because OIC, not Fusion, is the caller.
Our Solution
The flow has six steps:
- OIC obtains the Fusion base URL from an existing ERP REST response (the BU LOV is a convenient anchor — its
links/hrefalways carries the full server URL). - OIC invokes the Oracle Function via the OIC Gen 3 native OCI Functions action — no REST adapter, no connection to maintain, no API keys. OIC authenticates to OCI Functions automatically using its own IDCS Application identity.
- The function parses the Fusion URL and derives an instance key (
dev1,test,prod, etc.) from the hostname patternfa-{code}-{instance}-saasfaprod1.fa.ocs.oraclecloud.com. Production hostnames omit the instance segment. - The function reads two secrets from OCI Vault by deterministic name:
fusion-{instance}-otbi-usernameandfusion-{instance}-otbi-password. The function lives on a private subnet; the vault read is routed through the Service Gateway, so the request never leaves OCI’s internal network. Vault access uses Resource Principal authentication — the function inherits an identity from its membership in a Dynamic Group, no static credentials anywhere. - The function calls Fusion OTBI at
/analytics-ws/saw.dll?SoapImpl=nQSessionServicewith the SOAP<v6:logon>envelope and parses thesessionIDout of the response. This is the one outbound-internet hop in the whole flow, routed through the NAT Gateway. - OIC receives
{sessionId, instanceKey, customerCode}and usessessionIdin the SOAP header of every downstream OTBI / BIPCatalogServicecall.
The OIC integration never sees the password. The function never holds the password longer than a single invocation. The credential retrieval traffic stays inside OCI. The vault audit trail records every GetSecretBundle event. Adding a new Fusion instance becomes a vault-only operation: create two new secrets named to the convention; no function redeploy.
Why the Service Gateway matters here
This is the security property most worth understanding before reading the code. The function sits on a private subnet with no public IP and no Internet Gateway. Its outbound routing table contains two rules:
- All OCI service traffic (anything destined for
*.oraclecloud.comservices such as Vault and OCIR) is routed to the Service Gateway, which keeps the packets inside OCI’s internal backbone. The vaultGetSecretBundlecall — the one that returns the OTBI credentials in plaintext — uses this path. The credentials never traverse the public internet on the way to the function. - All other outbound traffic (everything else, in our case just the Fusion SOAP call) is routed to the NAT Gateway, which gives the function outbound-only access to the public internet. No inbound path exists; the function is not reachable from the internet.
The practical implication: a packet capture anywhere on the public internet would never see the OTBI credentials in transit, because they travel from Vault to the function over OCI’s internal network. They appear on the wire only once — in the SOAP body of the call to Fusion OTBI — and that call uses HTTPS to a Fusion endpoint that requires those credentials anyway, so no new exposure is introduced.
Implementation Details
1. Vault layout — single vault, naming-convention isolation
We standardised on one vault per tenancy and isolated environments by secret name. Tag namespaces are not required.
Vault layoutOCI Compartment: funcs (or Security) └── Vault: fusion-security-vault ├── Key: fusion-security-key (shared, AES-256) ├── Secret: fusion-dev1-otbi-username ├── Secret: fusion-dev1-otbi-password ├── Secret: fusion-test-otbi-username ├── Secret: fusion-test-otbi-password ├── Secret: fusion-prod-otbi-username └── Secret: fusion-prod-otbi-password
The hostname-to-secret mapping is the entire reason this works:
| Fusion hostname pattern | Instance key | Secret names |
|---|---|---|
fa-{code}-saasfaprod1.fa.ocs.oraclecloud.com | prod | fusion-prod-otbi-{username,password} |
fa-{code}-dev1-saasfaprod1.fa.ocs.oraclecloud.com | dev1 | fusion-dev1-otbi-{username,password} |
fa-{code}-test-saasfaprod1.fa.ocs.oraclecloud.com | test | fusion-test-otbi-{username,password} |
fa-{code}-uat-saasfaprod1.fa.ocs.oraclecloud.com | uat | fusion-uat-otbi-{username,password} |
2. IAM — three policies, zero shared secrets
Three IAM objects do the heavy lifting:
- A Dynamic Group that contains the function (
resource.type = 'fnfunc'in the function compartment) — this is the function’s Resource Principal identity. - A policy that grants the Dynamic Group
read secret-bundleson the specific vault OCID (least privilege — scoped to the vault, not the compartment). - A tenancy-level policy that allows the OIC IDCS Application principal to
use functions-familyandinspect compartmentsin the tenancy.
IAM — three policies, zero shared secrets# Function reads vault — scoped to the vault OCID, not the whole compartment oci iam policy create --compartment-id $FUNCTION_COMPARTMENT_OCID --name "fusion-security-function-policy" --statements '[ "Allow dynamic-group fusion-security-functions-dg to inspect secrets in compartment funcs", "Allow dynamic-group fusion-security-functions-dg to read secret-bundles in compartment funcs where target.vault.id = '$VAULT_OCID'" ]' # OIC invokes function — scoped to the actual OIC IDCS principal ID oci iam policy create --compartment-id $TENANCY_OCID --name "fusion-oic-tenancy-policy" --statements '[ "Allow any-user to use functions-family in tenancy where request.principal.id = 'OIC-IDCS-APP-APPID'", "Allow any-user to inspect compartments in tenancy where request.principal.id = 'OIC-IDCS-APP-APPID'" ]'
The OIC IDCS principal ID is not visible in the OIC console. Discover it from OCI Audit after the first (deliberately failing) connection attempt — see Gotchas below.
3. The function — Python, Resource Principal, two secrets, one SOAP call
import io import json import logging import os import re import base64 import requests from urllib.parse import urlparse from xml.etree import ElementTree as ET import oci from fdk import response logger = logging.getLogger(__name__) _FUSION_HOST_RE = re.compile( r'^fa-(?P<full_middle>.+?)-saasfaprod1\.fa\.ocs\.oraclecloud\.com$', re.IGNORECASE ) _KNOWN_INSTANCES = { "dev", "dev1", "dev2", "dev3", "dev4", "test", "test1", "test2", "uat", "uat1", "uat2", "stage", "staging", "sit", "preprod", "sandbox", "train", "training", "demo", "perf", "perftest", "dr" } SOAP_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?> <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:v6="urn://oracle.bi.webservices/v6"> <soapenv:Header/> <soapenv:Body> <v6:logon> <v6:name>{username}</v6:name> <v6:password>{password}</v6:password> </v6:logon> </soapenv:Body> </soapenv:Envelope>""" NS = { "soapenv": "http://schemas.xmlsoap.org/soap/envelope/", "v6": "urn://oracle.bi.webservices/v6" } def get_signer(): return oci.auth.signers.get_resource_principals_signer() def extract_instance_key(fusion_url): hostname = (urlparse(fusion_url).hostname or fusion_url).lower().strip() match = _FUSION_HOST_RE.match(hostname) if not match: raise ValueError( f"URL does not match Oracle Fusion SaaS pattern: {hostname}" ) segments = match.group("full_middle").split("-") last = segments[-1] if last in _KNOWN_INSTANCES: return "-".join(segments[:-1]), last return match.group("full_middle"), "prod" def find_secret_ocid(secret_name, signer): vault_ocid = os.environ.get("VAULT_OCID", "").strip() compartment_id = os.environ.get("SECURITY_COMPARTMENT_OCID", "").strip() if not vault_ocid: raise ValueError("Function config 'VAULT_OCID' is not set.") if not compartment_id: raise ValueError("Function config 'SECURITY_COMPARTMENT_OCID' is not set.") client = oci.vault.VaultsClient({}, signer=signer) secrets = oci.pagination.list_call_get_all_results( client.list_secrets, compartment_id=compartment_id, vault_id=vault_ocid, name=secret_name ).data for secret in secrets: if secret.secret_name == secret_name and secret.lifecycle_state == "ACTIVE": logger.info(f"Resolved: '{secret_name}' -> '{secret.id}'") return secret.id raise RuntimeError(f"No ACTIVE secret named '{secret_name}' in vault.") def get_secret_value(secret_ocid, signer): bundle = oci.secrets.SecretsClient({}, signer=signer) \ .get_secret_bundle(secret_id=secret_ocid).data return base64.b64decode(bundle.secret_bundle_content.content).decode("utf-8") def build_soap_url(fusion_url): base = fusion_url.rstrip("/") if "saw.dll" in base: return base return f"{base}/analytics-ws/saw.dll?SoapImpl=nQSessionService" def xml_escape(v): return (v.replace("&", "&").replace("<", "<") .replace(">", ">").replace('"', """) .replace("'", "'")) def call_logon(soap_url, username, password): body = SOAP_TEMPLATE.format( username=xml_escape(username), password=xml_escape(password) ) try: resp = requests.post( soap_url, data=body, headers={ "Content-Type": "text/xml; charset=UTF-8", "SOAPAction": "urn:logon" }, timeout=30 ) resp.raise_for_status() except requests.Timeout: raise RuntimeError(f"Timeout connecting to '{soap_url}'") except requests.HTTPError as e: raise RuntimeError(f"HTTP {e.response.status_code} from Fusion SOAP") root = ET.fromstring(resp.text) fault = root.find(".//soapenv:Fault", NS) if fault is not None: raise RuntimeError( f"SOAP Fault [{fault.findtext('faultcode')}]: " f"{fault.findtext('faultstring')}" ) for tag in ("sessionID", "sessionId", "return"): node = root.find(f".//{{{NS['v6']}}}{tag}") if node is not None and node.text: return node.text.strip() raise RuntimeError("sessionId not found in response") def handler(ctx, data: io.BytesIO = None): fusion_url = "" instance_key = "" try: payload = json.loads(data.getvalue()) if data else {} fusion_url = (payload.get("fusionUrl") or "").strip() if not fusion_url: return _resp(ctx, 400, {"error": "Missing required field: fusionUrl"}) signer = get_signer() customer, instance = extract_instance_key(fusion_url) soap_url = build_soap_url(fusion_url) instance_key = instance username = get_secret_value( find_secret_ocid(f"fusion-{instance}-otbi-username", signer), signer) password = get_secret_value( find_secret_ocid(f"fusion-{instance}-otbi-password", signer), signer) session_id = call_logon(soap_url, username, password) return _resp(ctx, 200, { "sessionId": session_id, "instanceKey": instance, "customerCode": customer, "vaultOcid": os.environ.get("VAULT_OCID", "") }) except ValueError as e: return _resp(ctx, 400, {"error": str(e), "instanceKey": instance_key}) except RuntimeError as e: return _resp(ctx, 502, {"error": str(e), "instanceKey": instance_key}) except Exception as e: logger.exception("Unhandled exception") return _resp(ctx, 500, {"error": f"Internal error: {e}"}) def _resp(ctx, code, body): return response.Response( ctx, response_data=json.dumps(body), headers={"Content-Type": "application/json"}, status_code=code )
Three things to note in this code:
oci.auth.signers.get_resource_principals_signer()is the entire authentication story. There are no API keys, no PEMs, no config files. The signer works only inside an OCI Function whose enclosing compartment matches a Dynamic Group rule — try to run this from your laptop and it will fail.- The instance-key parser is deliberately conservative. If the trailing segment is not in
_KNOWN_INSTANCES, the hostname is assumed to be production (prod hostnames have no instance segment at all). This rule is the one Oracle SaaS hostnames have followed in our experience — confirm yours matches before going live. - The OTBI response tag name has varied. We’ve seen
sessionID,sessionId, and barereturnacross releases — the loop covers all three.
4. OIC — native Functions action, no REST adapter
In the OIC Gen 3 designer, add the OCI Functions action and point it at the compartment / application / function. There is no connection to create. OIC’s IDCS identity is the one that the tenancy policy authorised.
The integration flow is straightforward:
Trigger → Map → Invoke ERP REST (BU LOV, 1 row) → Assign fusionUrl → Map → OCI Function → use sessionId
The trick we use to derive the base URL without hardcoding it is an XPath against the BU LOV response:
substring-before( $GetBU/ns13:getAllResponse/ns13:links/ns12:href, '/fscmRestApi' )
That always returns https://fa-{code}-{instance}-saasfaprod1.fa.ocs.oraclecloud.com regardless of pod or environment — so the same integration runs in dev, test and prod without environment-specific configuration.
In subsequent OTBI / BIP CatalogService calls, use the sessionId in the SOAP header:
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:v6="urn://oracle.bi.webservices/v6"> <soapenv:Header> <v6:SAWSessionParameters> <v6:sessionID>{$OTBISessionId}</v6:sessionID> </v6:SAWSessionParameters> </soapenv:Header> <soapenv:Body> <!-- CatalogService.readObjects, BIP runReport, etc. --> </soapenv:Body> </soapenv:Envelope>
Gotchas / Production notes
Networking gotchas
- The Function App subnet cannot be changed after creation. If you provisioned it on the wrong subnet (or, classically, on a public subnet without a NAT Gateway), the only fix is delete-and-recreate the app, then redeploy the function. Decide on private subnet + Service Gateway + NAT Gateway before you create the app.
- The function needs both gateways, and they do different jobs. Service Gateway carries OCI service traffic (OCIR image pull at startup, Vault
GetSecretBundleat runtime) over OCI’s internal network — this is the path that keeps credentials off the public internet. NAT Gateway gives the function outbound-only access to the public internet so it can call Fusion OTBI. Miss the NAT and you get a 504 timeout on/analytics-ws; miss the Service Gateway and you get a 502 “failed to pull function image” at first invocation. - The Service Gateway route must use a SERVICE_CIDR_BLOCK destination, not a regular CIDR. Use the regional aggregate (e.g.
all-fra-services-in-oracle-services-networkfor Frankfurt) so the route covers both OCIR and Vault. A0.0.0.0/0route to Service Gateway will not work.
Deployment gotchas
- The OIC IDCS principal ID is not exposed in the OIC console. First attempt to add an OCI Functions action will fail with “Couldn’t load applications list”. That failure is exactly what you want — it generates an audit event in OCI. Open OCI Audit → Search for
ListApplicationsevents in the last hour; theprincipal-idfield ending in_APPIDis your OIC IDCS principal. Plug it into thewhere request.principal.id = '..._APPID'clause and retry. - Use
runtime: pythoninfunc.yaml, notpython39orpython3.9. Fn CLI 0.6.48 only knows the generic identifier; the version-specific ones produce a confusing “runtime not supported” deploy error. - OCIR Docker login username format depends on identity setup. Native OCI users:
<namespace>/<username>. Federated IDCS in the default domain:<namespace>/oracleidentitycloudservice/<email>. Federated IDCS in a custom domain:<namespace>/<IdentityDomainName>/<email>. Get this wrong anddocker loginreturns an unhelpfulunauthorized. - Auth-token rotation is silent. OCIR auth tokens expire (or get revoked) without notifying the function. If a redeploy starts failing with
unauthorizedafter months of working, regenerate the token from OCI Console → Profile → Tokens and keys.
Security and runtime gotchas
- Resource Principal works only inside the function. Don’t try
oci secrets get-secret-bundlefrom your laptop using the same setup — your CLI uses your user-level config, not the function’s resource principal. Test the function end-to-end viafn invoke, not by simulating the OCI calls locally. - Vault secret list is paginated.
find_secret_ocidusesoci.pagination.list_call_get_all_resultsdeliberately — a single-pagelist_secretscall will silently miss your secret once the compartment grows past the default page size. VAULT_OCIDandSECURITY_COMPARTMENT_OCIDare the only environment-aware values the function needs. Inject them via the Function App config. Do not hard-code secret OCIDs anywhere — secrets get rotated and OCIDs change.
How to verify
Function reachable end-to-end:
echo '{"fusionUrl":"https://fa-{code}-dev1-saasfaprod1.fa.ocs.oraclecloud.com"}' | fn invoke fusion-security-app fusion-otbi-session
Expected: a JSON document with sessionId, instanceKey: "dev1", customerCode: "{code}".
Vault traffic stayed on the Service Gateway path. In the VCN flow logs (if enabled) the GetSecretBundle calls do not appear on the NAT Gateway interface — they show up only on the Service Gateway path. This is the operational proof that the credential read never traversed the public internet.
Vault read auditable:
oci audit event list --compartment-id $SECURITY_COMPARTMENT_OCID --start-time $(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ) --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) | jq '.data[] | select(."event-name" == "GetSecretBundle")'
Every legitimate sessionId issued maps to two GetSecretBundle events (username + password) signed by the function’s Resource Principal.
OIC payloads carry no credentials. Re-enable Debug-level activity tracing on the integration and re-run. The OIC monitoring console will show the function request body as {"fusionUrl":"https://..."} and the response as {"sessionId":"...","instanceKey":"...",...}. There is no point in the activity stream at which a Fusion username or password appears.
Conclusion
OTBI credentials never leave OCI Vault except over OCI’s internal network, into a function that holds them for the duration of a single invocation. OIC integrations carry no Fusion identity material in any artefact, parameter, connection, or log. Adding a new Fusion instance (UAT, perf, training) is a vault-only operation — two new secrets named to the convention, no function redeploy, no OIC change. P2T from prod to test leaves both ends intact because nothing in OIC is bound to a specific hostname; the function derives the instance key from the URL it is given at runtime. And every secret access is recorded in the OCI Audit log, so a security review has a real trail rather than a screenshot of an OIC connection page.
Want this in your environment?
Facing the same exposure with OTBI, BIP CatalogService, or any other Fusion SOAP endpoint that demands credentials in the payload? We’ve shipped this pattern end-to-end — OCI Vault, IAM with least-privilege scoping, private-subnet networking with the right Service Gateway and NAT Gateway routing, the Python function, the OIC native action wiring, and the cutover from credential-in-OIC to credential-in-Vault — and we can do the same for you. Reach out at info@cidsolutions.co.il or on WhatsApp — let’s get it solved.