Skip to content

Commit 287603d

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(oracle-epm): add guarded foundation
1 parent 71e848a commit 287603d

33 files changed

Lines changed: 3320 additions & 14 deletions

apps/sim/lib/api/contracts/credentials.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import {
6+
createCredentialBodySchema,
67
updateCredentialByIdBodySchema,
78
workspaceCredentialSchema,
89
} from '@/lib/api/contracts/credentials'
@@ -46,3 +47,24 @@ describe('workspaceCredentialSchema unredacted', () => {
4647
expect(workspaceCredentialSchema.safeParse(credential).success).toBe(false)
4748
})
4849
})
50+
51+
describe('Oracle EPM service-account credential contract', () => {
52+
const valid = {
53+
workspaceId: '00000000-0000-4000-8000-000000000001',
54+
type: 'service_account' as const,
55+
providerId: 'oracle-epm-service-account',
56+
orgId: 'https://epm.example.com/gateway',
57+
clientId: 'integration.user@example.com',
58+
clientSecret: 'password',
59+
}
60+
61+
it('accepts the descriptor-required integration-user fields', () => {
62+
expect(createCredentialBodySchema.safeParse(valid).success).toBe(true)
63+
})
64+
65+
it.each(['orgId', 'clientId', 'clientSecret'] as const)('rejects a missing %s', (field) => {
66+
expect(createCredentialBodySchema.safeParse({ ...valid, [field]: undefined }).success).toBe(
67+
false
68+
)
69+
})
70+
})

apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getClientCredentialAccountDescriptor,
88
NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID,
99
normalizeNetSuiteSuiteTalkOrigin,
10+
ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID,
1011
partitionClientCredentialFields,
1112
resolveClientCredentialAuthMethod,
1213
resolveSalesforceAuthMethod,
@@ -19,6 +20,7 @@ const salesforce = getClientCredentialAccountDescriptor(SALESFORCE_SERVICE_ACCOU
1920
const box = getClientCredentialAccountDescriptor(BOX_SERVICE_ACCOUNT_PROVIDER_ID)!
2021
const zohoDesk = getClientCredentialAccountDescriptor(ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID)!
2122
const netSuite = getClientCredentialAccountDescriptor(NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID)!
23+
const oracleEpm = getClientCredentialAccountDescriptor(ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID)!
2224

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

@@ -51,6 +53,14 @@ describe('partitionClientCredentialFields', () => {
5153
multiline: true,
5254
})
5355
})
56+
57+
it('declares the Oracle EPM integration user without a UI-specific implementation', () => {
58+
const { visible, required } = partitionClientCredentialFields(oracleEpm, undefined)
59+
expect(ids(visible)).toEqual(['orgId', 'clientId', 'clientSecret'])
60+
expect(ids(required)).toEqual(['orgId', 'clientId', 'clientSecret'])
61+
expect(oracleEpm.connectNoun).toBe('integration user')
62+
expect(oracleEpm.fields.find((field) => field.id === 'clientSecret')?.secret).toBe(true)
63+
})
5464
})
5565

5666
describe('Salesforce, which offers two grants', () => {

apps/sim/lib/credentials/client-credential-accounts/descriptors.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,15 @@ export const BOX_SERVICE_ACCOUNT_PROVIDER_ID = 'box-service-account' as const
111111
export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-account' as const
112112
export const ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zoho-desk-service-account' as const
113113
export const NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID = 'netsuite-service-account' as const
114+
export const ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-epm-service-account' as const
114115

115116
export type ClientCredentialAccountProviderId =
116117
| typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID
117118
| typeof BOX_SERVICE_ACCOUNT_PROVIDER_ID
118119
| typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID
119120
| typeof ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID
120121
| typeof NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID
122+
| typeof ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID
121123

122124
/**
123125
* Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and
@@ -531,6 +533,37 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
531533
helpText:
532534
'Use the account-specific SuiteTalk URL and the client ID, certificate ID, and private key from one OAuth 2.0 client-credentials mapping.',
533535
},
536+
[ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID]: {
537+
providerId: ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID,
538+
serviceLabel: 'Oracle EPM Cloud',
539+
connectNoun: 'integration user',
540+
fields: [
541+
{
542+
id: 'orgId',
543+
label: 'Environment URL',
544+
placeholder: 'https://example.oraclecloud.com/epmcloud',
545+
secret: false,
546+
hintPattern: /^https:\/\//,
547+
hintMessage: 'Expected the full HTTPS URL for one Oracle EPM environment.',
548+
},
549+
{
550+
id: 'clientId',
551+
label: 'Integration username',
552+
placeholder: 'integration.user@example.com',
553+
secret: false,
554+
},
555+
{
556+
id: 'clientSecret',
557+
label: 'Password',
558+
placeholder: 'Paste the integration user password',
559+
secret: true,
560+
},
561+
],
562+
docsUrl:
563+
'https://docs.oracle.com/en/cloud/saas/enterprise-performance-management-common/prest/overview.html',
564+
helpText:
565+
'The credential is bound to one EPM environment. Use a dedicated integration user with only the permissions its workflows require.',
566+
},
534567
}
535568

536569
/**
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/** @vitest-environment node */
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { mintOracleEpmServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-epm'
4+
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
5+
6+
describe('mintOracleEpmServiceAccountToken', () => {
7+
it('mints Basic authentication locally and binds the normalized destination', async () => {
8+
const fetchSpy = vi.spyOn(globalThis, 'fetch')
9+
const result = await mintOracleEpmServiceAccountToken({
10+
orgId: ' https://EPM.example.com/gateway/ ',
11+
clientId: 'integration.user@example.com',
12+
clientSecret: 'password',
13+
})
14+
expect(Buffer.from(result.accessToken, 'base64').toString()).toBe(
15+
'integration.user@example.com:password'
16+
)
17+
expect(result).toMatchObject({
18+
expiresInSeconds: 600,
19+
instanceUrl: 'https://epm.example.com/gateway',
20+
identity: {
21+
principal: null,
22+
auditMetadata: { environmentUrl: 'https://epm.example.com/gateway' },
23+
storedMetadata: { environmentUrl: 'https://epm.example.com/gateway' },
24+
},
25+
})
26+
expect(JSON.stringify(result.identity)).not.toContain('password')
27+
expect(JSON.stringify(result.identity)).not.toContain('integration.user')
28+
expect(fetchSpy).not.toHaveBeenCalled()
29+
fetchSpy.mockRestore()
30+
})
31+
32+
it.each([
33+
{ clientId: 'user:name', clientSecret: 'password' },
34+
{ clientId: 'user\nname', clientSecret: 'password' },
35+
{ clientId: 'user', clientSecret: 'pass\nword' },
36+
{ clientId: '', clientSecret: 'password' },
37+
])('rejects unsafe Basic credential text', async (credentials) => {
38+
await expect(
39+
mintOracleEpmServiceAccountToken({
40+
orgId: 'https://epm.example.com',
41+
...credentials,
42+
})
43+
).rejects.toBeInstanceOf(TokenServiceAccountValidationError)
44+
})
45+
46+
it('does not reflect secrets in validation errors', async () => {
47+
const secret = 'password-with-newline\n'
48+
const error = await mintOracleEpmServiceAccountToken({
49+
orgId: 'https://epm.example.com',
50+
clientId: 'user',
51+
clientSecret: secret,
52+
}).catch((value: unknown) => value)
53+
expect(JSON.stringify(error)).not.toContain(secret)
54+
})
55+
})
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type {
2+
ClientCredentialAccountFields,
3+
ClientCredentialAccountMintOptions,
4+
ClientCredentialAccountMintResult,
5+
} from '@/lib/credentials/client-credential-accounts/server'
6+
import {
7+
requireClientSecret,
8+
TokenServiceAccountValidationError,
9+
} from '@/lib/credentials/token-service-accounts/errors'
10+
import { normalizeOracleEpmDestination } from '@/lib/internal/oracle-epm/destination'
11+
12+
const SYNTHETIC_TOKEN_TTL_SECONDS = 600
13+
const MAX_USERNAME_BYTES = 255
14+
const MAX_AUTH_VALUE_BYTES = 1_024
15+
const FORBIDDEN_CREDENTIAL_TEXT = /[\u0000-\u001f\u007f]/
16+
17+
function invalidCredentials(reason: string): TokenServiceAccountValidationError {
18+
return new TokenServiceAccountValidationError('invalid_credentials', 400, {
19+
step: 'oracle_epm_basic_auth',
20+
reason,
21+
})
22+
}
23+
24+
/**
25+
* Builds credential-bound Basic authentication locally. Oracle EPM does not
26+
* expose a token mint for this v1 flow, so connect performs no network probe.
27+
*/
28+
export async function mintOracleEpmServiceAccountToken(
29+
fields: ClientCredentialAccountFields,
30+
_options?: ClientCredentialAccountMintOptions
31+
): Promise<ClientCredentialAccountMintResult> {
32+
let instanceUrl: string
33+
try {
34+
instanceUrl = normalizeOracleEpmDestination(fields.orgId)
35+
} catch {
36+
throw new TokenServiceAccountValidationError('site_not_found', 400, {
37+
step: 'oracle_epm_destination_validation',
38+
reason: 'environment URL must be a valid HTTPS Oracle EPM destination',
39+
})
40+
}
41+
42+
const username = fields.clientId.trim()
43+
const password = requireClientSecret(
44+
fields.clientSecret,
45+
'oracle_epm_basic_auth',
46+
'Oracle EPM Cloud'
47+
)
48+
if (
49+
!username ||
50+
username.includes(':') ||
51+
FORBIDDEN_CREDENTIAL_TEXT.test(username) ||
52+
Buffer.byteLength(username, 'utf8') > MAX_USERNAME_BYTES
53+
) {
54+
throw invalidCredentials('integration username is invalid')
55+
}
56+
if (
57+
!password ||
58+
FORBIDDEN_CREDENTIAL_TEXT.test(password) ||
59+
Buffer.byteLength(password, 'utf8') > MAX_AUTH_VALUE_BYTES
60+
) {
61+
throw invalidCredentials('password is invalid')
62+
}
63+
64+
const hostname = new URL(instanceUrl).hostname
65+
return {
66+
accessToken: Buffer.from(`${username}:${password}`, 'utf8').toString('base64'),
67+
expiresInSeconds: SYNTHETIC_TOKEN_TTL_SECONDS,
68+
instanceUrl,
69+
identity: {
70+
displayName: `Oracle EPM ${hostname}`,
71+
principal: null,
72+
auditMetadata: { environmentUrl: instanceUrl },
73+
storedMetadata: { environmentUrl: instanceUrl },
74+
},
75+
}
76+
}

apps/sim/lib/credentials/client-credential-accounts/server.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import { CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE } from '@/lib/credentials/client-credential-accounts/descriptors'
6-
import { parseClientCredentialAccountSecretBlob } from '@/lib/credentials/client-credential-accounts/server'
6+
import {
7+
getClientCredentialAccountMinter,
8+
parseClientCredentialAccountSecretBlob,
9+
} from '@/lib/credentials/client-credential-accounts/server'
710

811
const MALFORMED = 'Stored client-credential service-account secret is malformed'
912

@@ -19,6 +22,10 @@ function blob(overrides: Record<string, unknown> = {}): string {
1922
}
2023

2124
describe('parseClientCredentialAccountSecretBlob', () => {
25+
it('registers the Oracle EPM minter in the generic client-credential pipeline', () => {
26+
expect(getClientCredentialAccountMinter('oracle-epm-service-account')).toBeTypeOf('function')
27+
})
28+
2229
it('returns the parsed blob when it matches the expected provider', () => {
2330
const parsed = parseClientCredentialAccountSecretBlob(blob(), 'zoom-service-account')
2431
expect(parsed.clientId).toBe('cid')
@@ -117,4 +124,22 @@ describe('parseClientCredentialAccountSecretBlob', () => {
117124
)
118125
).toThrow(MALFORMED)
119126
})
127+
128+
it('requires the complete Oracle EPM integration-user blob', () => {
129+
const oracleBlob = blob({
130+
providerId: 'oracle-epm-service-account',
131+
orgId: 'https://epm.example.com/gateway',
132+
clientId: 'integration.user@example.com',
133+
clientSecret: 'password',
134+
})
135+
expect(
136+
parseClientCredentialAccountSecretBlob(oracleBlob, 'oracle-epm-service-account')
137+
).toMatchObject({ orgId: 'https://epm.example.com/gateway' })
138+
expect(() =>
139+
parseClientCredentialAccountSecretBlob(
140+
blob({ providerId: 'oracle-epm-service-account', clientSecret: '' }),
141+
'oracle-epm-service-account'
142+
)
143+
).toThrow(MALFORMED)
144+
})
120145
})

apps/sim/lib/credentials/client-credential-accounts/server.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import {
55
getClientCredentialAccountDescriptor,
66
isClientCredentialAccountProviderId,
77
NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID,
8+
ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID,
89
partitionClientCredentialFields,
910
SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID,
1011
ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID,
1112
ZOOM_SERVICE_ACCOUNT_PROVIDER_ID,
1213
} from '@/lib/credentials/client-credential-accounts/descriptors'
1314
import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box'
1415
import { mintNetSuiteServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/netsuite'
16+
import { mintOracleEpmServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-epm'
1517
import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce'
1618
import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk'
1719
import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom'
@@ -29,8 +31,8 @@ export interface ClientCredentialAccountFields {
2931
clientSecret?: string
3032
/**
3133
* Provider-specific org identifier (Zoom Account ID, Box Enterprise ID,
32-
* Salesforce My Domain host, Zoho Desk organization ID, or NetSuite
33-
* SuiteTalk origin).
34+
* Salesforce My Domain host, Zoho Desk organization ID, NetSuite SuiteTalk
35+
* origin, or an Oracle EPM environment URL).
3436
*/
3537
orgId: string
3638
/**
@@ -84,8 +86,8 @@ export interface ClientCredentialAccountMintResult {
8486
accessToken: string
8587
expiresInSeconds: number
8688
/**
87-
* Provider API origin the minted token must be used against (Salesforce or
88-
* NetSuite), forwarded to tools alongside the token.
89+
* Provider API destination the minted token must be used against (Salesforce,
90+
* NetSuite, or Oracle EPM), forwarded to tools alongside the token.
8991
*/
9092
instanceUrl?: string
9193
/**
@@ -130,6 +132,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record<
130132
[SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID]: mintSalesforceServiceAccountToken,
131133
[ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID]: mintZohoDeskServiceAccountToken,
132134
[NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID]: mintNetSuiteServiceAccountToken,
135+
[ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID]: mintOracleEpmServiceAccountToken,
133136
}
134137

135138
export function getClientCredentialAccountMinter(

apps/sim/lib/credentials/orchestration/index.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,38 @@ describe('performUpdateCredential — service-account secret rotation', () => {
414414
)
415415
})
416416

417+
it('threads Oracle EPM integration-user fields through reconnect without reading old secrets', async () => {
418+
mockCredential({
419+
providerId: 'oracle-epm-service-account',
420+
displayName: 'Production EPM',
421+
})
422+
mockIsClientCredentialAccountProviderId.mockReturnValue(true)
423+
mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({
424+
providerId: 'oracle-epm-service-account',
425+
encryptedServiceAccountKey: 'new-cipher',
426+
displayName: 'Production EPM',
427+
auditMetadata: {},
428+
})
429+
430+
await performUpdateCredential({
431+
credentialId: 'cred-1',
432+
userId: 'user-1',
433+
orgId: 'https://epm.example.com/gateway',
434+
clientId: 'integration.user@example.com',
435+
clientSecret: 'rotated-password',
436+
})
437+
438+
expect(mockDecryptSecret).not.toHaveBeenCalled()
439+
expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith(
440+
'oracle-epm-service-account',
441+
expect.objectContaining({
442+
orgId: 'https://epm.example.com/gateway',
443+
clientId: 'integration.user@example.com',
444+
clientSecret: 'rotated-password',
445+
})
446+
)
447+
})
448+
417449
it('surfaces a rebuild failure as a validation error and writes nothing', async () => {
418450
mockCredential()
419451
mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL })

apps/sim/lib/credentials/service-account-provider-ids.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe('isServiceAccountProviderId', () => {
1616
expect(isServiceAccountProviderId('notion-service-account')).toBe(true)
1717
expect(isServiceAccountProviderId('salesforce-service-account')).toBe(true)
1818
expect(isServiceAccountProviderId('netsuite-service-account')).toBe(true)
19+
expect(isServiceAccountProviderId('oracle-epm-service-account')).toBe(true)
1920
})
2021

2122
it('is case- and whitespace-insensitive', () => {
@@ -52,6 +53,7 @@ describe('getServiceAccountConnectNoun', () => {
5253
it('names the client-credential secret', () => {
5354
expect(getServiceAccountConnectNoun('zoom-service-account')).toBe('server-to-server app')
5455
expect(getServiceAccountConnectNoun('netsuite-service-account')).toBe('OAuth certificate')
56+
expect(getServiceAccountConnectNoun('oracle-epm-service-account')).toBe('integration user')
5557
})
5658

5759
it('calls a custom Slack bot a custom bot', () => {

0 commit comments

Comments
 (0)