Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
287603d
feat(oracle-epm): add guarded foundation
Sep 4, 2026
7ba3213
fix(oracle-epm): harden guarded foundation
Sep 4, 2026
c000b7b
fix(oracle-epm): address follow-up review findings
Sep 4, 2026
5e32a64
fix(oracle-epm): validate raw returned-link paths
Sep 4, 2026
893d086
fix(oracle-epm): reject unusable link policies
Sep 4, 2026
849968a
fix(credentials): authorize before provider matching
Sep 4, 2026
1a37ec6
refactor(oracle-epm): narrow shared infrastructure changes
Sep 4, 2026
3f631c6
refactor(oracle-epm): defer shared tool auth helper
Sep 4, 2026
fa2fc7f
refactor(oracle-epm): keep API contract tests provider-neutral
Sep 4, 2026
ad88fb2
fix(oracle-epm): clarify credential REST base URL
Sep 4, 2026
cb8b98a
fix(oracle-epm): correct credential authentication docs link
Sep 4, 2026
b81803b
feat(oracle-epm): support repository-path parameters
Sep 4, 2026
a818f7a
fix(oracle-epm): support multiword returned-link relations
Sep 4, 2026
8c5f26c
test(oracle-epm): cover credential setup guidance
Sep 4, 2026
7840639
fix(oracle-epm): bound DNS waits and validate header values
Sep 4, 2026
fbb5a4e
fix(oracle-epm): reject malformed returned-link entries
Sep 4, 2026
68df997
fix(oracle-epm): validate typed returned-link queries
Sep 4, 2026
9ec1be0
fix(oracle-epm): reject raw backslashes before link parsing
Sep 4, 2026
9e738a3
fix(oracle-epm): support slash-containing link relations
Sep 5, 2026
4e18e8e
feat(oracle-epm): add Profitability and Cost Management integration
Sep 5, 2026
9e95590
Merge staging and repair Oracle EPM foundation compatibility
Oct 3, 2026
a2686f0
Merge feat/oracle-epm-foundation and resolve PR #7541 conflicts
Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/components/ui/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
onedrive: MicrosoftOneDriveIcon,
onepassword: OnePasswordIcon,
openai: OpenAIIcon,
oracle_epm_profitability: NetSuiteIcon,
otter: OtterIcon,
outlook: OutlookIcon,
pagerduty: PagerDutyIcon,
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/integrations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@
"okta",
"onedrive",
"onepassword",
"oracle_epm_profitability",
"otter",
"outlook",
"pagerduty",
Expand Down
511 changes: 511 additions & 0 deletions apps/docs/content/docs/integrations/oracle_epm_profitability.mdx

Large diffs are not rendered by default.

995 changes: 995 additions & 0 deletions apps/sim/blocks/blocks/oracle_epm_profitability.ts

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions apps/sim/blocks/registry-maps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ import { OktaBlock, OktaBlockMeta } from '@/blocks/blocks/okta'
import { OneDriveBlock, OneDriveBlockMeta } from '@/blocks/blocks/onedrive'
import { OnePasswordBlock, OnePasswordBlockMeta } from '@/blocks/blocks/onepassword'
import { OpenAIBlock, OpenAIBlockMeta } from '@/blocks/blocks/openai'
import { OraclePcmBlock, OraclePcmBlockMeta } from '@/blocks/blocks/oracle_epm_profitability'
import { OtterBlock, OtterBlockMeta } from '@/blocks/blocks/otter'
import { OutlookBlock, OutlookBlockMeta } from '@/blocks/blocks/outlook'
import { PagerDutyBlock, PagerDutyBlockMeta } from '@/blocks/blocks/pagerduty'
Expand Down Expand Up @@ -611,6 +612,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
onedrive: OneDriveBlock,
onepassword: OnePasswordBlock,
openai: OpenAIBlock,
oracle_epm_profitability: OraclePcmBlock,
otter: OtterBlock,
outlook: OutlookBlock,
pagerduty: PagerDutyBlock,
Expand Down Expand Up @@ -946,6 +948,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
onedrive: OneDriveBlockMeta,
onepassword: OnePasswordBlockMeta,
openai: OpenAIBlockMeta,
oracle_epm_profitability: OraclePcmBlockMeta,
otter: OtterBlockMeta,
outlook: OutlookBlockMeta,
pagerduty: PagerDutyBlockMeta,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
BOX_SERVICE_ACCOUNT_PROVIDER_ID,
getClientCredentialAccountDescriptor,
normalizeNetSuiteSuiteTalkOrigin,
ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID,
partitionClientCredentialFields,
resolveClientCredentialAuthMethod,
resolveSalesforceAuthMethod,
Expand All @@ -12,6 +13,8 @@ import {

const salesforce = getClientCredentialAccountDescriptor(SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID)!
const box = getClientCredentialAccountDescriptor(BOX_SERVICE_ACCOUNT_PROVIDER_ID)!
const oracleEpm = getClientCredentialAccountDescriptor(ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID)
if (!oracleEpm) throw new Error('Oracle EPM credential descriptor is missing')

const ids = (fields: { id: string }[]) => fields.map((field) => field.id)

Expand All @@ -21,6 +24,20 @@ describe('partitionClientCredentialFields', () => {
const { required } = partitionClientCredentialFields(box, 'jwt_bearer')
expect(ids(required)).toEqual(['clientId', 'clientSecret', 'orgId'])
})

it('guides Oracle EPM users to the REST base URL and authentication docs', () => {
const restBaseUrl = oracleEpm.fields.find((field) => field.id === 'orgId')

expect(restBaseUrl).toMatchObject({
label: 'REST Base URL',
placeholder: 'https://example.oraclecloud.com',
})
expect(restBaseUrl?.hint).toContain('without /epmcloud')
expect(restBaseUrl?.hint).toContain('gateway prefix')
expect(oracleEpm.docsUrl).toBe(
'https://docs.oracle.com/en/cloud/saas/enterprise-performance-management-common/prest/authentication.html'
)
})
})

describe('Salesforce, which offers two grants', () => {
Expand Down
34 changes: 34 additions & 0 deletions apps/sim/lib/credentials/client-credential-accounts/descriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,15 @@ export const BOX_SERVICE_ACCOUNT_PROVIDER_ID = 'box-service-account' as const
export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-account' as const
export const ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zoho-desk-service-account' as const
export const NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID = 'netsuite-service-account' as const
export const ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-epm-service-account' as const

export type ClientCredentialAccountProviderId =
| typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID
| typeof BOX_SERVICE_ACCOUNT_PROVIDER_ID
| typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID
| typeof ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID
| typeof NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID
| typeof ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID

/**
* Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and
Expand Down Expand Up @@ -531,6 +533,38 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
helpText:
'Use the account-specific SuiteTalk URL and the client ID, certificate ID, and private key from one OAuth 2.0 client-credentials mapping.',
},
[ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID]: {
providerId: ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID,
serviceLabel: 'Oracle EPM Cloud',
connectNoun: 'integration user',
fields: [
{
id: 'orgId',
label: 'REST Base URL',
placeholder: 'https://example.oraclecloud.com',
secret: false,
hintPattern: /^https:\/\//,
hintMessage: 'Expected the HTTPS REST base URL for one Oracle EPM environment.',
hint: 'Enter the HTTPS base URL for your environment without /epmcloud or an API endpoint path. Include a gateway prefix only if your deployment requires it.',
},
{
id: 'clientId',
label: 'Integration username',
placeholder: 'integration.user@example.com',
secret: false,
},
{
id: 'clientSecret',
label: 'Password',
placeholder: 'Paste the integration user password',
secret: true,
},
],
docsUrl:
'https://docs.oracle.com/en/cloud/saas/enterprise-performance-management-common/prest/authentication.html',
helpText:
'The credential is bound to one EPM environment. Use a dedicated integration user with only the permissions its workflows require.',
},
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/** @vitest-environment node */
import { describe, expect, it, vi } from 'vitest'
import { mintOracleEpmServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-epm'
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'

describe('mintOracleEpmServiceAccountToken', () => {
it('mints Basic authentication locally and binds the normalized destination', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch')
const result = await mintOracleEpmServiceAccountToken({
orgId: ' https://EPM.example.com/gateway/ ',
clientId: 'integration.user@example.com',
clientSecret: 'password',
})
expect(Buffer.from(result.accessToken, 'base64').toString()).toBe(
'integration.user@example.com:password'
)
expect(result).toMatchObject({
expiresInSeconds: 600,
instanceUrl: 'https://epm.example.com/gateway',
identity: {
principal: null,
auditMetadata: { environmentUrl: 'https://epm.example.com/gateway' },
storedMetadata: { environmentUrl: 'https://epm.example.com/gateway' },
},
})
expect(JSON.stringify(result.identity)).not.toContain('password')
expect(JSON.stringify(result.identity)).not.toContain('integration.user')
expect(fetchSpy).not.toHaveBeenCalled()
fetchSpy.mockRestore()
})

it.each([
{ clientId: 'user:name', clientSecret: 'password' },
{ clientId: 'user\nname', clientSecret: 'password' },
{ clientId: 'user', clientSecret: 'pass\nword' },
{ clientId: 'user\uD800', clientSecret: 'password' },
{ clientId: 'user', clientSecret: 'password\uDC00' },
{ clientId: '', clientSecret: 'password' },
])('rejects unsafe Basic credential text', async (credentials) => {
await expect(
mintOracleEpmServiceAccountToken({
orgId: 'https://epm.example.com',
...credentials,
})
).rejects.toBeInstanceOf(TokenServiceAccountValidationError)
})

it('preserves valid surrogate pairs in Basic credential values', async () => {
const result = await mintOracleEpmServiceAccountToken({
orgId: 'https://epm.example.com',
clientId: 'integration-😀',
clientSecret: 'password-🔒',
})
expect(Buffer.from(result.accessToken, 'base64').toString()).toBe('integration-😀:password-🔒')
})

it('does not reflect secrets in validation errors', async () => {
const secret = 'password-with-newline\n'
const error = await mintOracleEpmServiceAccountToken({
orgId: 'https://epm.example.com',
clientId: 'user',
clientSecret: secret,
}).catch((value: unknown) => value)
expect(JSON.stringify(error)).not.toContain(secret)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type {
ClientCredentialAccountFields,
ClientCredentialAccountMintOptions,
ClientCredentialAccountMintResult,
} from '@/lib/credentials/client-credential-accounts/server'
import {
requireClientSecret,
TokenServiceAccountValidationError,
} from '@/lib/credentials/token-service-accounts/errors'
import { normalizeOracleEpmDestination } from '@/lib/internal/oracle-epm/destination'

const SYNTHETIC_TOKEN_TTL_SECONDS = 600
const MAX_USERNAME_BYTES = 255
const MAX_AUTH_VALUE_BYTES = 1_024
const FORBIDDEN_CREDENTIAL_TEXT = /[\u0000-\u001f\u007f]/
const MALFORMED_UTF16 = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/

function invalidCredentials(reason: string): TokenServiceAccountValidationError {
return new TokenServiceAccountValidationError('invalid_credentials', 400, {
step: 'oracle_epm_basic_auth',
reason,
})
}

/**
* Builds credential-bound Basic authentication locally. Oracle EPM does not
* expose a token mint for this v1 flow, so connect performs no network probe.
*/
export async function mintOracleEpmServiceAccountToken(
fields: ClientCredentialAccountFields,
_options?: ClientCredentialAccountMintOptions
): Promise<ClientCredentialAccountMintResult> {
let instanceUrl: string
try {
instanceUrl = normalizeOracleEpmDestination(fields.orgId)
} catch {
throw new TokenServiceAccountValidationError('site_not_found', 400, {
step: 'oracle_epm_destination_validation',
reason: 'environment URL must be a valid HTTPS Oracle EPM destination',
})
}

const username = fields.clientId.trim()
const password = requireClientSecret(
fields.clientSecret,
'oracle_epm_basic_auth',
'Oracle EPM Cloud'
)
if (
!username ||
username.includes(':') ||
FORBIDDEN_CREDENTIAL_TEXT.test(username) ||
MALFORMED_UTF16.test(username) ||
Buffer.byteLength(username, 'utf8') > MAX_USERNAME_BYTES
) {
throw invalidCredentials('integration username is invalid')
}
if (
!password ||
FORBIDDEN_CREDENTIAL_TEXT.test(password) ||
MALFORMED_UTF16.test(password) ||
Buffer.byteLength(password, 'utf8') > MAX_AUTH_VALUE_BYTES
) {
throw invalidCredentials('password is invalid')
}

const hostname = new URL(instanceUrl).hostname
return {
accessToken: Buffer.from(`${username}:${password}`, 'utf8').toString('base64'),
expiresInSeconds: SYNTHETIC_TOKEN_TTL_SECONDS,
instanceUrl,
identity: {
displayName: `Oracle EPM ${hostname}`,
principal: null,
auditMetadata: { environmentUrl: instanceUrl },
storedMetadata: { environmentUrl: instanceUrl },
},
}
}
18 changes: 18 additions & 0 deletions apps/sim/lib/credentials/client-credential-accounts/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,22 @@ describe('parseClientCredentialAccountSecretBlob', () => {
)
).toThrow(MALFORMED)
})

it('requires the complete Oracle EPM integration-user blob', () => {
const oracleBlob = blob({
providerId: 'oracle-epm-service-account',
orgId: 'https://epm.example.com/gateway',
clientId: 'integration.user@example.com',
clientSecret: 'password',
})
expect(
parseClientCredentialAccountSecretBlob(oracleBlob, 'oracle-epm-service-account')
).toMatchObject({ orgId: 'https://epm.example.com/gateway' })
expect(() =>
parseClientCredentialAccountSecretBlob(
blob({ providerId: 'oracle-epm-service-account', clientSecret: '' }),
'oracle-epm-service-account'
)
).toThrow(MALFORMED)
})
})
11 changes: 7 additions & 4 deletions apps/sim/lib/credentials/client-credential-accounts/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import {
getClientCredentialAccountDescriptor,
isClientCredentialAccountProviderId,
NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID,
ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID,
partitionClientCredentialFields,
SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID,
ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID,
ZOOM_SERVICE_ACCOUNT_PROVIDER_ID,
} from '@/lib/credentials/client-credential-accounts/descriptors'
import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box'
import { mintNetSuiteServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/netsuite'
import { mintOracleEpmServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-epm'
import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce'
import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk'
import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom'
Expand All @@ -29,8 +31,8 @@ export interface ClientCredentialAccountFields {
clientSecret?: string
/**
* Provider-specific org identifier (Zoom Account ID, Box Enterprise ID,
* Salesforce My Domain host, Zoho Desk organization ID, or NetSuite
* SuiteTalk origin).
* Salesforce My Domain host, Zoho Desk organization ID, NetSuite SuiteTalk
* origin, or an Oracle EPM environment URL).
*/
orgId: string
/**
Expand Down Expand Up @@ -84,8 +86,8 @@ export interface ClientCredentialAccountMintResult {
accessToken: string
expiresInSeconds: number
/**
* Provider API origin the minted token must be used against (Salesforce or
* NetSuite), forwarded to tools alongside the token.
* Provider API destination the minted token must be used against (Salesforce,
* NetSuite, or Oracle EPM), forwarded to tools alongside the token.
*/
instanceUrl?: string
/**
Expand Down Expand Up @@ -130,6 +132,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record<
[SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID]: mintSalesforceServiceAccountToken,
[ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID]: mintZohoDeskServiceAccountToken,
[NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID]: mintNetSuiteServiceAccountToken,
[ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID]: mintOracleEpmServiceAccountToken,
}

export function getClientCredentialAccountMinter(
Expand Down
36 changes: 35 additions & 1 deletion apps/sim/lib/credentials/service-account-secret.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({
getClientCredentialAccountMinter: (providerId: string) =>
providerId === 'zoom-service-account' ||
providerId === 'box-service-account' ||
providerId === 'netsuite-service-account'
providerId === 'netsuite-service-account' ||
providerId === 'oracle-epm-service-account'
? mockClientCredentialMinter
: undefined,
}))
Expand Down Expand Up @@ -176,6 +177,39 @@ describe('verifyAndBuildServiceAccountSecret', () => {
})
})

it('stores the Oracle EPM environment and integration-user secret through the generic path', async () => {
mockClientCredentialMinter.mockResolvedValue({
accessToken: 'basic-token',
expiresInSeconds: 600,
instanceUrl: 'https://epm.example.com/gateway',
identity: {
displayName: 'Oracle EPM epm.example.com',
principal: null,
auditMetadata: { environmentUrl: 'https://epm.example.com/gateway' },
storedMetadata: { environmentUrl: 'https://epm.example.com/gateway' },
},
})
const result = await verifyAndBuildServiceAccountSecret('oracle-epm-service-account', {
orgId: ' https://epm.example.com/gateway ',
clientId: ' integration.user@example.com ',
clientSecret: ' password ',
})

expect(mockClientCredentialMinter).toHaveBeenCalledWith({
orgId: 'https://epm.example.com/gateway',
clientId: 'integration.user@example.com',
clientSecret: 'password',
})
expect(JSON.parse(result.encryptedServiceAccountKey)).toMatchObject({
providerId: 'oracle-epm-service-account',
orgId: 'https://epm.example.com/gateway',
clientId: 'integration.user@example.com',
clientSecret: 'password',
metadata: { environmentUrl: 'https://epm.example.com/gateway' },
})
expect(result.principal).toBeNull()
})

it('throws when client-credential required fields are missing, without minting', async () => {
await expect(
verifyAndBuildServiceAccountSecret('zoom-service-account', {
Expand Down
Loading