feat(oracle-epm-planning): add Planning and FreeForm integration by BillLeoutsakosvl346 · Pull Request #7510 · simstudioai/sim · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 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
6aa23b1
feat(oracle-epm-planning): add Planning and FreeForm integration
Sep 5, 2026
e09af09
fix(oracle-epm-planning): address CI contracts and registration checks
Sep 5, 2026
fc6c6bd
feat(oracle-epm-planning): add data maps approvals and insights
Sep 5, 2026
552557b
fix(oracle-epm-planning): validate approval confirmation routes
Sep 5, 2026
eb596d3
fix(oracle-epm-planning): repair contract test and CI formatting
Sep 6, 2026
3168260
style(oracle-epm-planning): apply remaining CI line wrapping
Sep 6, 2026
9e95590
Merge staging and repair Oracle EPM foundation compatibility
Oct 3, 2026
698e417
Merge feat/oracle-epm-foundation and resolve PR #7510 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
Prev Previous commit
Next Next commit
fix(oracle-epm): harden guarded foundation
  • Loading branch information
Bill Leoutsakos Bill Leoutsakos
Bill Leoutsakos authored and Bill Leoutsakos committed Sep 4, 2026
commit 7ba3213c9fb042fdb4f9be2e26a845df73600d01
8 changes: 8 additions & 0 deletions apps/sim/lib/credentials/orchestration/index.test.ts
12 changes: 8 additions & 4 deletions apps/sim/lib/credentials/orchestration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,12 +292,16 @@ export async function updateCredentialRecord(
// credential back to the US accounts server. Carry the stored value forward
// when the caller did not supply one.
const isClientCredentialProvider = isClientCredentialAccountProviderId(providerId)
const needsStoredDataCenter = params.dataCenter === undefined && isClientCredentialProvider
const clientCredentialDescriptor = isClientCredentialProvider
? getClientCredentialAccountDescriptor(providerId)
: undefined
const storesDataCenter = Boolean(
clientCredentialDescriptor?.fields.some((field) => field.id === 'dataCenter')
)
const needsStoredDataCenter = params.dataCenter === undefined && storesDataCenter
// Only a multi-grant provider stores these, so single-grant ones must not
// pay for a row read + decrypt that can only ever return undefined.
const isMultiGrantProvider = Boolean(
getClientCredentialAccountDescriptor(providerId)?.defaultAuthMethod
)
const isMultiGrantProvider = Boolean(clientCredentialDescriptor?.defaultAuthMethod)
const needsStoredAuthMethod = params.authMethod === undefined && isMultiGrantProvider
const needsStoredUsername = params.username === undefined && isMultiGrantProvider

Expand Down
44 changes: 44 additions & 0 deletions apps/sim/lib/internal/oracle-epm/client.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,17 @@ describe('Oracle EPM guarded client', () => {
expect(mockSecureFetch.mock.calls[0][0]).toContain('%252e%252e%252fadmin')
})

it('rejects malformed UTF-16 path input before URL encoding', async () => {
const client = createOracleEpmClient({
instanceUrl: 'https://epm.example.com',
accessToken: Buffer.from('u:p').toString('base64'),
})
await expect(client.request(getJob, { pathParams: { jobId: '\uD800' } })).rejects.toMatchObject(
{ category: 'invalid_input' }
)
expect(mockValidateUrl).not.toHaveBeenCalled()
})

it('suppresses arbitrary provider bodies in failed requests', async () => {
mockSecureFetch.mockResolvedValue(
secureResponse({
Expand Down Expand Up @@ -200,6 +211,39 @@ describe('Oracle EPM guarded client', () => {
expect(mockSecureFetch).not.toHaveBeenCalled()
})

it('rejects accessors and inherited JSON serializers without invoking them', async () => {
const endpoint = routes.defineEndpoint({
method: 'POST',
version: 'v3',
path: [oracleEpmLiteral('jobs')],
body: 'json',
maxRequestBytes: 1_024,
response: 'json',
timeoutMs: 2_000,
maxResponseBytes: 1_024,
})
const inheritedSerializer = vi.fn(() => ({ changed: true }))
const inherited = Object.create({ toJSON: inheritedSerializer }) as Record<string, unknown>
inherited.value = 'safe'
const getter = vi.fn(() => 'secret')
const accessor = {}
Object.defineProperty(accessor, 'value', { enumerable: true, get: getter })
const client = createOracleEpmClient({
instanceUrl: 'https://epm.example.com',
accessToken: Buffer.from('u:p').toString('base64'),
})

await expect(client.request(endpoint, { json: inherited })).rejects.toMatchObject({
category: 'invalid_input',
})
await expect(client.request(endpoint, { json: accessor })).rejects.toMatchObject({
category: 'invalid_input',
})
expect(inheritedSerializer).not.toHaveBeenCalled()
expect(getter).not.toHaveBeenCalled()
expect(mockValidateUrl).not.toHaveBeenCalled()
})

it('propagates caller aborts before opening a pinned request', async () => {
const controller = new AbortController()
controller.abort(new DOMException('user', 'AbortError'))
Expand Down
80 changes: 78 additions & 2 deletions apps/sim/lib/internal/oracle-epm/client.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type {
} from '@/lib/internal/oracle-epm/types'

const SAFE_TOKEN = /^[A-Za-z0-9+/]+={0,2}$/
const LONE_SURROGATE = /[\uD800-\uDFFF]/
const validatedLinks = new WeakMap<
object,
{ owner: object; url: string; policy: OracleEpmReturnedLinkPolicyDefinition }
Expand Down Expand Up @@ -63,6 +64,7 @@ function validatePathValue(
value === '.' ||
value === '..' ||
/[/\\\u0000-\u001f\u007f]/.test(value) ||
LONE_SURROGATE.test(value) ||
Buffer.byteLength(value, 'utf8') > declaration.maxBytes ||
(declaration.pattern && !declaration.pattern.test(value))
) {
Expand Down Expand Up @@ -91,6 +93,7 @@ function serializeQueryValue(value: unknown, declaration: OracleEpmQueryParamete
if (declaration.kind === 'string') {
if (
typeof value !== 'string' ||
LONE_SURROGATE.test(value) ||
Buffer.byteLength(value, 'utf8') > declaration.maxBytes ||
(declaration.pattern && !declaration.pattern.test(value))
)
Expand Down Expand Up @@ -149,6 +152,7 @@ function buildHeaders(
}
if (
/\r|\n|\u0000/.test(value) ||
LONE_SURROGATE.test(value) ||
Buffer.byteLength(value, 'utf8') > declaration.maxBytes ||
(declaration.pattern && !declaration.pattern.test(value))
)
Expand All @@ -161,6 +165,77 @@ function buildHeaders(
return headers
}

type JsonData = null | boolean | number | string | JsonData[] | { [key: string]: JsonData }

function hasPrototypeToJson(value: object): boolean {
let prototype = Object.getPrototypeOf(value)
while (prototype) {
if (Object.getOwnPropertyDescriptor(prototype, 'toJSON')) return true
prototype = Object.getPrototypeOf(prototype)
}
return false
}

/** Copies only ordinary JSON data without invoking accessors or custom serializers. */
function cloneJsonData(value: unknown, ancestors = new WeakSet<object>()): JsonData {
if (
value === null ||
typeof value === 'string' ||
typeof value === 'boolean' ||
(typeof value === 'number' && Number.isFinite(value))
) {
return value
}
if (typeof value !== 'object') throw oracleEpmLocalError('invalid_input')
if (ancestors.has(value)) throw oracleEpmLocalError('invalid_input')

const prototype = Object.getPrototypeOf(value)
if (Array.isArray(value)) {
if (prototype !== Array.prototype || hasPrototypeToJson(value)) {
throw oracleEpmLocalError('invalid_input')
}
const ownKeys = Reflect.ownKeys(value)
if (
ownKeys.some(
(key) => typeof key !== 'string' || (key !== 'length' && !/^(0|[1-9][0-9]*)$/.test(key))
)
) {
throw oracleEpmLocalError('invalid_input')
}
ancestors.add(value)
try {
return Array.from({ length: value.length }, (_, index) => {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index))
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
throw oracleEpmLocalError('invalid_input')
}
return cloneJsonData(descriptor.value, ancestors)
})
} finally {
ancestors.delete(value)
}
}

if (prototype !== Object.prototype && prototype !== null) {
throw oracleEpmLocalError('invalid_input')
}
const clone: { [key: string]: JsonData } = Object.create(null)
ancestors.add(value)
try {
for (const key of Reflect.ownKeys(value)) {
if (typeof key !== 'string') throw oracleEpmLocalError('invalid_input')
const descriptor = Object.getOwnPropertyDescriptor(value, key)
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
throw oracleEpmLocalError('invalid_input')
}
clone[key] = cloneJsonData(descriptor.value, ancestors)
}
} finally {
ancestors.delete(value)
}
return clone
}

function buildBody(
endpoint: OracleEpmEndpointDefinition,
input: OracleEpmRequestInput
Expand All @@ -175,7 +250,7 @@ function buildBody(
throw oracleEpmLocalError('invalid_input')
let body: string
try {
body = JSON.stringify(input.json)
body = JSON.stringify(cloneJsonData(input.json))
} catch {
throw oracleEpmLocalError('invalid_input')
}
Expand Down Expand Up @@ -308,7 +383,8 @@ export function createOracleEpmClient(input: {
}
if (deadlineSignal.aborted) throw oracleEpmLocalError('timeout', true)
if (!validation.isValid) throw oracleEpmLocalError('invalid_configuration')
const maxAttempts = endpoint.retry?.maxAttempts ?? 1
const maxAttempts =
endpoint.method === 'GET' ? Math.min(endpoint.retry?.maxAttempts ?? 1, 2) : 1
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
let response: SecureFetchResponse
try {
Expand Down
10 changes: 10 additions & 0 deletions apps/sim/lib/internal/oracle-epm/destination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,23 @@ describe('Oracle EPM destination', () => {
})
})

it('round-trips canonical percent-encoding in a credential-owned base path', () => {
const normalized = normalizeOracleEpmDestination(
'https://epm.example.com/gateway/My Folder/日本'
)
expect(normalized).toBe('https://epm.example.com/gateway/My%20Folder/%E6%97%A5%E6%9C%AC')
expect(normalizeOracleEpmDestination(normalized)).toBe(normalized)
})

it.each([
'http://epm.example.com',
'https://user@epm.example.com',
'https://epm.example.com?token=secret',
'https://epm.example.com/#fragment',
'https://epm.example.com/%2e%2e/admin',
'https://epm.example.com/%252e%252e/admin',
'https://epm.example.com/a%2Fb',
'https:////epm.example.com/gateway',
'https://epm.example.com/a\\b',
])('rejects unsafe destination %j', (value) => {
expect(() => defineOracleEpmDestination(value)).toThrow()
Expand Down
50 changes: 29 additions & 21 deletions apps/sim/lib/internal/oracle-epm/destination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,36 @@ function decodeSegment(segment: string): string {
}
}

function rawPathSegments(value: string): string[] {
const match = /^https:\/\/[^/?#]+(\/[^?#]*)?(?:[?#].*)?$/i.exec(value)
if (!match) throw new Error('Oracle EPM environment URL is invalid')
return (match[1] ?? '').split('/').filter(Boolean)
}

function validateAndDecodeSegment(encoded: string): string {
const decoded = decodeSegment(encoded)
let safetyValue = decoded
for (let depth = 0; depth < 4 && /%[0-9A-Fa-f]{2}/.test(safetyValue); depth += 1) {
safetyValue = decodeSegment(safetyValue)
}
if (
!decoded ||
/%[0-9A-Fa-f]{2}/.test(safetyValue) ||
safetyValue === '.' ||
safetyValue === '..' ||
safetyValue.includes('/') ||
FORBIDDEN_TEXT.test(safetyValue) ||
Buffer.byteLength(decoded, 'utf8') > MAX_PATH_SEGMENT_BYTES
) {
throw new Error('Oracle EPM environment URL base path is invalid')
}
return decoded
}

/** Validates and freezes the credential-bound Oracle EPM environment URL. */
export function defineOracleEpmDestination(rawUrl: string): OracleEpmDestination {
const value = rawUrl.trim()
if (
!value ||
value.length > MAX_DESTINATION_LENGTH ||
value.includes('%') ||
FORBIDDEN_TEXT.test(value)
) {
if (!value || value.length > MAX_DESTINATION_LENGTH || FORBIDDEN_TEXT.test(value)) {
throw new Error('Oracle EPM environment URL is invalid')
}

Expand All @@ -44,24 +65,11 @@ export function defineOracleEpmDestination(rawUrl: string): OracleEpmDestination
)
}

const encodedSegments = parsed.pathname.split('/').filter(Boolean)
const encodedSegments = rawPathSegments(value)
if (encodedSegments.length > MAX_PATH_SEGMENTS) {
throw new Error('Oracle EPM environment URL base path has too many segments')
}
const baseSegments = encodedSegments.map((encoded) => {
const decoded = decodeSegment(encoded)
if (
!decoded ||
decoded === '.' ||
decoded === '..' ||
decoded.includes('/') ||
FORBIDDEN_TEXT.test(decoded) ||
Buffer.byteLength(decoded, 'utf8') > MAX_PATH_SEGMENT_BYTES
) {
throw new Error('Oracle EPM environment URL base path is invalid')
}
return decoded
})
const baseSegments = encodedSegments.map(validateAndDecodeSegment)

const destination = Object.freeze({}) as OracleEpmDestination
destinations.set(destination, {
Expand Down
40 changes: 40 additions & 0 deletions apps/sim/lib/internal/oracle-epm/endpoint.test.ts
Loading
Loading