Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 5 additions & 6 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5895,12 +5895,11 @@ export function PipedriveIcon(props: SVGProps<SVGSVGElement>) {

export function SailPointIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'>
<path
fill='currentColor'
d='M7.25 2.4c5.66 1.7 9.2 6.86 9.9 15.35a.75.75 0 0 1-.75.81H7.25a.75.75 0 0 1-.75-.75V3.12a.75.75 0 0 1 .95-.72Z'
/>
<rect fill='currentColor' x='3' y='19.9' width='18' height='1.8' rx='.9' />
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 109.5 107'>
<path fill='#0033A1' d='M63,0l13.2,78.6H0L63,0z' />
<path fill='#CC27B0' d='M62.9,0l46.7,78.6H76L62.9,0z' />
<path fill='#0071CE' d='M0,78.6h76.2l4.8,28.4L0,78.6z' />
<path fill='#E17FD2' d='M76,78.6h33.5L80.8,107L76,78.6z' />
</svg>
)
}
Expand Down
1,030 changes: 959 additions & 71 deletions apps/docs/content/docs/integrations/sailpoint.mdx

Large diffs are not rendered by default.

125 changes: 96 additions & 29 deletions apps/sim/blocks/blocks/sailpoint.ts

Large diffs are not rendered by default.

11 changes: 5 additions & 6 deletions apps/sim/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5895,12 +5895,11 @@ export function PipedriveIcon(props: SVGProps<SVGSVGElement>) {

export function SailPointIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'>
<path
fill='currentColor'
d='M7.25 2.4c5.66 1.7 9.2 6.86 9.9 15.35a.75.75 0 0 1-.75.81H7.25a.75.75 0 0 1-.75-.75V3.12a.75.75 0 0 1 .95-.72Z'
/>
<rect fill='currentColor' x='3' y='19.9' width='18' height='1.8' rx='.9' />
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 109.5 107'>
<path fill='#0033A1' d='M63,0l13.2,78.6H0L63,0z' />
<path fill='#CC27B0' d='M62.9,0l46.7,78.6H76L62.9,0z' />
<path fill='#0071CE' d='M0,78.6h76.2l4.8,28.4L0,78.6z' />
<path fill='#E17FD2' d='M76,78.6h33.5L80.8,107L76,78.6z' />
</svg>
)
}
Expand Down
64 changes: 64 additions & 0 deletions apps/sim/lib/internal/sailpoint/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
clearSailPointTokenStateForTests,
getSailPointAccessToken,
getSailPointTokenStateForTests,
readTotalCount,
resolveSailPointHosts,
sailpointFetch,
} from '@/lib/internal/sailpoint/client'
Expand Down Expand Up @@ -65,6 +66,27 @@ describe('SailPoint client', () => {
await expect(Promise.all([first, second])).resolves.toEqual(['shared', 'shared'])
})

it('lets one token waiter abort without cancelling the shared exchange', async () => {
let release: ((response: Response) => void) | undefined
mockFetch.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
release = resolve
})
)
const credentials = { tenant: 'acme', clientId: 'client', clientSecret: 'secret' }
const controller = new AbortController()
const first = getSailPointAccessToken(credentials, controller.signal)
const second = getSailPointAccessToken(credentials)

controller.abort(new Error('caller stopped'))
await expect(first).rejects.toThrow('caller stopped')
release?.(tokenResponse('shared'))
await expect(second).resolves.toBe('shared')
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(getSailPointTokenStateForTests().exchangeSize).toBe(0)
})

it('expires cached tokens before their provider expiry', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
Expand Down Expand Up @@ -106,4 +128,46 @@ describe('SailPoint client', () => {
}))
).rejects.toThrow(/maximum|limit|exceeds/i)
})

it('aborts during rate-limit backoff without another provider call', async () => {
mockFetch
.mockResolvedValueOnce(tokenResponse('token'))
.mockResolvedValueOnce(new Response(null, { status: 429, headers: { 'retry-after': '30' } }))
const controller = new AbortController()
const credentials = { tenant: 'acme', clientId: 'client', clientSecret: 'secret' }
const pending = sailpointFetch(
credentials,
(hosts) => ({
url: `${hosts.apiBaseUrl}/identities/v1`,
init: { method: 'GET' },
}),
{ signal: controller.signal }
)

await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
controller.abort(new Error('stop retrying'))
await expect(pending).rejects.toThrow('stop retrying')
expect(mockFetch).toHaveBeenCalledTimes(2)
})

it('rejects redirects for token and authenticated provider requests', async () => {
mockFetch
.mockResolvedValueOnce(tokenResponse('token'))
.mockResolvedValueOnce(Response.json({ id: 'identity' }))
const credentials = { tenant: 'acme', clientId: 'client', clientSecret: 'secret' }

await sailpointFetch(credentials, (hosts) => ({
url: `${hosts.apiBaseUrl}/identities/v1/id`,
init: { method: 'GET' },
}))

expect(mockFetch.mock.calls[0][1]?.redirect).toBe('error')
expect(mockFetch.mock.calls[1][1]?.redirect).toBe('error')
})

it('accepts only non-negative integer total counts', () => {
expect(readTotalCount(new Headers({ 'x-total-count': '7' }))).toBe(7)
expect(readTotalCount(new Headers({ 'x-total-count': '1.5' }))).toBeNull()
expect(readTotalCount(new Headers({ 'x-total-count': '-1' }))).toBeNull()
})
})
34 changes: 27 additions & 7 deletions apps/sim/lib/internal/sailpoint/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import { sleep } from '@sim/utils/helpers'
import { interruptibleSleep } from '@sim/utils/helpers'
import { isRecordLike } from '@sim/utils/object'
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
import { MAX_JSON_API_RESPONSE_BYTES } from '@/lib/core/security/input-validation.server'
Expand Down Expand Up @@ -37,10 +37,23 @@ const MAX_FETCH_RETRIES = 4
const MAX_TOKEN_CACHE_ENTRIES = 100
const MAX_TOKEN_EXCHANGES = 100
const MAX_TOKEN_RESPONSE_BYTES = 1024 * 1024
const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000
const TOKEN_EXPIRY_BUFFER_MS = 60_000
const tokenCache = new Map<string, CachedToken>()
const tokenExchanges = new Map<string, Promise<string>>()

async function waitForPromiseWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise
signal.throwIfAborted()

return new Promise<T>((resolve, reject) => {
const onAbort = () =>
reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'))
signal.addEventListener('abort', onAbort, { once: true })
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort))
})
}

export function resolveSailPointHosts(tenant: string): SailPointHosts {
let host = tenant.trim().replace(/^https?:\/\//i, '')
host = host
Expand Down Expand Up @@ -155,14 +168,16 @@ async function exchangeAccessToken(
client_secret: credentials.clientSecret,
}).toString(),
cache: 'no-store',
redirect: 'error',
signal,
})

if (response.status === 429 && attempt < MAX_FETCH_RETRIES) {
const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
await consumeOrCancelBody(response, DEFAULT_MAX_ERROR_BODY_BYTES)
attempt += 1
await sleep(backoffWithJitter(attempt, retryAfterMs))
await interruptibleSleep(backoffWithJitter(attempt, retryAfterMs), signal)
signal?.throwIfAborted()
continue
}

Expand Down Expand Up @@ -210,16 +225,19 @@ export async function getSailPointAccessToken(
if (cached) tokenCache.delete(key)

const existing = tokenExchanges.get(key)
if (existing) return existing
if (existing) return waitForPromiseWithSignal(existing, signal)
if (tokenExchanges.size >= MAX_TOKEN_EXCHANGES) {
throw new Error('Too many concurrent SailPoint token exchanges')
}

const exchange = exchangeAccessToken(credentials, signal).finally(() => {
const exchange = exchangeAccessToken(
credentials,
AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS)
).finally(() => {
tokenExchanges.delete(key)
})
tokenExchanges.set(key, exchange)
return exchange
return waitForPromiseWithSignal(exchange, signal)
}

export async function sailpointFetch(
Expand All @@ -244,6 +262,7 @@ export async function sailpointFetch(
...init,
cache: 'no-store',
headers,
redirect: 'error',
signal: options.signal,
})

Expand All @@ -257,7 +276,8 @@ export async function sailpointFetch(
const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
await consumeOrCancelBody(response, DEFAULT_MAX_ERROR_BODY_BYTES)
attempt += 1
await sleep(backoffWithJitter(attempt, retryAfterMs))
await interruptibleSleep(backoffWithJitter(attempt, retryAfterMs), options.signal)
options.signal?.throwIfAborted()
continue
}

Expand All @@ -279,7 +299,7 @@ export function readTotalCount(headers: Headers): number | null {
const raw = headers.get('x-total-count')
if (!raw) return null
const parsed = Number(raw)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null
}

/** Clears process-local authentication state for deterministic tests. */
Expand Down
Loading
Loading