Skip to content

Commit 3f2d37b

Browse files
Bill Leoutsakoswaleedlatif1
authored andcommitted
feat(oracle-epm): add validated integration foundation
1 parent 684b228 commit 3f2d37b

29 files changed

Lines changed: 4543 additions & 15 deletions

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ function ClientCredentialAccountModalForm({
189189
// stored on a credential whose grant never reads it.
190190
const secretFields: FieldValues = {}
191191
for (const field of visibleFields) {
192-
const value = values[field.id]?.trim()
192+
const value = field.preserveWhitespace ? values[field.id] : values[field.id]?.trim()
193193
if (value) secretFields[field.id] = value
194194
}
195195
if (credentialId) {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export const createCredentialFieldsSchema = z.object({
156156
signingSecret: z.string().trim().min(1).optional(),
157157
botToken: z.string().trim().min(1).optional(),
158158
clientId: z.string().trim().min(1).max(512).optional(),
159-
clientSecret: z.string().trim().min(1).max(1024).optional(),
159+
clientSecret: z.string().min(1).max(1024).optional(),
160160
certificateId: z.string().trim().min(1).max(512).optional(),
161161
orgId: z.string().trim().min(1).max(255).optional(),
162162
/** Optional provider region selector (Zoho Desk data center). */
@@ -276,7 +276,7 @@ export const updateCredentialByIdBodySchema = z
276276
atlassianProduct: atlassianProductSchema.optional(),
277277
/** Client-credential service-account secret rotation (reconnect). */
278278
clientId: z.string().trim().min(1).max(512).optional(),
279-
clientSecret: z.string().trim().min(1).max(1024).optional(),
279+
clientSecret: z.string().min(1).max(1024).optional(),
280280
certificateId: z.string().trim().min(1).max(512).optional(),
281281
orgId: z.string().trim().min(1).max(255).optional(),
282282
dataCenter: z.string().trim().min(1).max(32).optional(),

‎apps/sim/lib/api/contracts/v2/credentials.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,6 @@ const v2ServiceAccountCredentialFieldsSchema = z
444444
clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'),
445445
clientSecret: z
446446
.string()
447-
.trim()
448447
.min(1)
449448
.max(1024)
450449
.optional()
@@ -693,7 +692,6 @@ const v2ServiceAccountSecretFieldsShape = {
693692
clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'),
694693
clientSecret: z
695694
.string()
696-
.trim()
697695
.min(1)
698696
.max(1024)
699697
.optional()

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export interface ClientCredentialAccountField {
4242
placeholder: string
4343
/** Rendered with SecretInput and never echoed back. */
4444
secret: boolean
45+
/** Preserve exact password bytes instead of normalizing surrounding whitespace. */
46+
preserveWhitespace?: boolean
4547
/**
4648
* Renders a multi-line control instead of a single-line one. Required for
4749
* PEM-encoded material (a private key spans ~28 newline-separated lines and
@@ -111,13 +113,15 @@ export const BOX_SERVICE_ACCOUNT_PROVIDER_ID = 'box-service-account' as const
111113
export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-account' as const
112114
export const ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zoho-desk-service-account' as const
113115
export const NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID = 'netsuite-service-account' as const
116+
export const ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-epm-service-account' as const
114117

115118
export type ClientCredentialAccountProviderId =
116119
| typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID
117120
| typeof BOX_SERVICE_ACCOUNT_PROVIDER_ID
118121
| typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID
119122
| typeof ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID
120123
| typeof NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID
124+
| typeof ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID
121125

122126
/**
123127
* Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and
@@ -531,6 +535,40 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
531535
helpText:
532536
'Use the account-specific SuiteTalk URL and the client ID, certificate ID, and private key from one OAuth 2.0 client-credentials mapping.',
533537
},
538+
[ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID]: {
539+
providerId: ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID,
540+
serviceLabel: 'Oracle EPM Cloud',
541+
connectNoun: 'integration user',
542+
fields: [
543+
{
544+
id: 'orgId',
545+
label: 'REST Base URL',
546+
placeholder: 'https://example.oraclecloud.com',
547+
secret: false,
548+
hintPattern: /^https:\/\//,
549+
hintMessage: 'Expected the HTTPS REST base URL for one Oracle EPM environment.',
550+
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.',
551+
},
552+
{
553+
id: 'clientId',
554+
label: 'Integration username',
555+
placeholder: 'integration.user@example.com',
556+
secret: false,
557+
hint: 'Basic authentication requires a user without MFA. Credentials are checked on the first product request.',
558+
},
559+
{
560+
id: 'clientSecret',
561+
label: 'Password',
562+
placeholder: 'Paste the integration user password',
563+
secret: true,
564+
preserveWhitespace: true,
565+
},
566+
],
567+
docsUrl:
568+
'https://docs.oracle.com/en/cloud/saas/enterprise-performance-management-common/prest/authentication.html',
569+
helpText:
570+
'The credential is bound to one EPM environment. Use a dedicated integration user with only the permissions its workflows require.',
571+
},
534572
}
535573

536574
/**
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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: 'user\uD800', clientSecret: 'password' },
37+
{ clientId: 'user', clientSecret: 'password\uDC00' },
38+
{ clientId: '', clientSecret: 'password' },
39+
])('rejects unsafe Basic credential text', async (credentials) => {
40+
await expect(
41+
mintOracleEpmServiceAccountToken({
42+
orgId: 'https://epm.example.com',
43+
...credentials,
44+
})
45+
).rejects.toBeInstanceOf(TokenServiceAccountValidationError)
46+
})
47+
48+
it('preserves valid surrogate pairs in Basic credential values', async () => {
49+
const result = await mintOracleEpmServiceAccountToken({
50+
orgId: 'https://epm.example.com',
51+
clientId: 'integration-😀',
52+
clientSecret: 'password-🔒',
53+
})
54+
expect(Buffer.from(result.accessToken, 'base64').toString()).toBe('integration-😀:password-🔒')
55+
})
56+
57+
it('does not reflect secrets in validation errors', async () => {
58+
const secret = 'password-with-newline\n'
59+
const error = await mintOracleEpmServiceAccountToken({
60+
orgId: 'https://epm.example.com',
61+
clientId: 'user',
62+
clientSecret: secret,
63+
}).catch((value: unknown) => value)
64+
expect(JSON.stringify(error)).not.toContain(secret)
65+
})
66+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
const MALFORMED_UTF16 = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
17+
18+
function invalidCredentials(reason: string): TokenServiceAccountValidationError {
19+
return new TokenServiceAccountValidationError('invalid_credentials', 400, {
20+
step: 'oracle_epm_basic_auth',
21+
reason,
22+
})
23+
}
24+
25+
/**
26+
* Builds credential-bound Basic authentication locally. Oracle EPM does not
27+
* expose a token mint for this v1 flow, so connect performs no network probe.
28+
*/
29+
export async function mintOracleEpmServiceAccountToken(
30+
fields: ClientCredentialAccountFields,
31+
_options?: ClientCredentialAccountMintOptions
32+
): Promise<ClientCredentialAccountMintResult> {
33+
let instanceUrl: string
34+
try {
35+
instanceUrl = normalizeOracleEpmDestination(fields.orgId)
36+
} catch {
37+
throw new TokenServiceAccountValidationError('site_not_found', 400, {
38+
step: 'oracle_epm_destination_validation',
39+
reason: 'environment URL must be a valid HTTPS Oracle EPM destination',
40+
})
41+
}
42+
43+
const username = fields.clientId.trim()
44+
const password = requireClientSecret(
45+
fields.clientSecret,
46+
'oracle_epm_basic_auth',
47+
'Oracle EPM Cloud'
48+
)
49+
if (
50+
!username ||
51+
username.includes(':') ||
52+
FORBIDDEN_CREDENTIAL_TEXT.test(username) ||
53+
MALFORMED_UTF16.test(username) ||
54+
Buffer.byteLength(username, 'utf8') > MAX_USERNAME_BYTES
55+
) {
56+
throw invalidCredentials('integration username is invalid')
57+
}
58+
if (
59+
!password ||
60+
FORBIDDEN_CREDENTIAL_TEXT.test(password) ||
61+
MALFORMED_UTF16.test(password) ||
62+
Buffer.byteLength(password, 'utf8') > MAX_AUTH_VALUE_BYTES
63+
) {
64+
throw invalidCredentials('password is invalid')
65+
}
66+
67+
const hostname = new URL(instanceUrl).hostname
68+
return {
69+
accessToken: Buffer.from(`${username}:${password}`, 'utf8').toString('base64'),
70+
expiresInSeconds: SYNTHETIC_TOKEN_TTL_SECONDS,
71+
instanceUrl,
72+
identity: {
73+
displayName: `Oracle EPM ${hostname}`,
74+
principal: null,
75+
auditMetadata: { environmentUrl: instanceUrl },
76+
storedMetadata: { environmentUrl: instanceUrl },
77+
},
78+
}
79+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock'
2+
import { beforeEach, expect, it, vi } from 'vitest'
3+
import {
4+
createCredentialBodySchema,
5+
updateCredentialByIdBodySchema,
6+
} from '@/lib/api/contracts/credentials'
7+
import {
8+
v2CreateServiceAccountCredentialBodySchema,
9+
v2UpdateCredentialBodySchema,
10+
} from '@/lib/api/contracts/v2/credentials'
11+
import { mintOracleEpmServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-epm'
12+
import { parseClientCredentialAccountSecretBlob } from '@/lib/credentials/client-credential-accounts/server'
13+
import { verifyAndBuildServiceAccountSecret } from '@/lib/credentials/service-account-secret'
14+
15+
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
16+
17+
const input = {
18+
workspaceId: '11111111-1111-4111-8111-111111111111',
19+
type: 'service_account' as const,
20+
providerId: 'oracle-epm-service-account',
21+
displayName: 'Oracle test',
22+
orgId: 'https://epm.example.com/gateway',
23+
clientId: 'integration-user',
24+
clientSecret: ' password with spaces ',
25+
}
26+
27+
beforeEach(() => {
28+
encryptionMockFns.mockEncryptSecret.mockImplementation(async (value: string) => ({
29+
encrypted: value,
30+
}))
31+
})
32+
33+
it.each([
34+
['internal create', () => createCredentialBodySchema.parse(input)],
35+
[
36+
'internal reconnect',
37+
() => updateCredentialByIdBodySchema.parse({ clientSecret: input.clientSecret }),
38+
],
39+
[
40+
'v2 create',
41+
() =>
42+
v2CreateServiceAccountCredentialBodySchema.parse({
43+
workspaceId: input.workspaceId,
44+
type: input.type,
45+
providerId: input.providerId,
46+
credentials: JSON.stringify({
47+
orgId: input.orgId,
48+
clientId: input.clientId,
49+
clientSecret: input.clientSecret,
50+
}),
51+
}).credentials,
52+
],
53+
['v2 reconnect', () => v2UpdateCredentialBodySchema.parse({ clientSecret: input.clientSecret })],
54+
['secret builder', () => input],
55+
] as const)(
56+
'preserves the password through %s, storage, and Basic authentication',
57+
async (_surface, parse) => {
58+
const fields = { ...input, ...parse() }
59+
const result = await verifyAndBuildServiceAccountSecret(input.providerId, fields)
60+
const stored = parseClientCredentialAccountSecretBlob(
61+
result.encryptedServiceAccountKey,
62+
input.providerId
63+
)
64+
const credential = await mintOracleEpmServiceAccountToken(stored)
65+
expect(Buffer.from(credential.accessToken, 'base64').toString('utf8')).toBe(
66+
`integration-user:${input.clientSecret}`
67+
)
68+
}
69+
)

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,21 @@ 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'
1820
import type { ServiceAccountPrincipal } from '@/lib/credentials/principal'
1921

20-
/** Raw fields a client-credential minter receives (already trimmed). */
22+
/** Credential fields normalized according to the provider descriptor; passwords retain exact bytes. */
2123
export interface ClientCredentialAccountFields {
2224
clientId: string
2325
/** Certificate mapping identifier used as the JWT `kid` by NetSuite. */
@@ -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(

0 commit comments

Comments
 (0)