Skip to content

Commit dcaaf8e

Browse files
feat(sailpoint): add SailPoint (IGA) integration (#6092)
* feat(sailpoint): add SailPoint (IGA) integration Add a SailPoint Identity Security Cloud integration with 27 tools spanning identity-governance reads (search + count + aggregate, identities, accounts, entitlements, roles, access profiles, sources, account activities, campaigns, certifications, and entitlement expansion), the access-request write path (request, cancel, status), and CSV account/entitlement aggregation. Auth uses a service-identity Personal Access Token via the OAuth2 client-credentials grant against the per-tenant host (https://{tenant}.api.identitynow.com), resolved server-side in two internal routes (/api/tools/sailpoint/query and /load) with in-process token caching and Retry-After 429 backoff. The block enumerates the exact PAT scopes and the service-identity caveat. Access-request constraints (revoke one identity + one item, grant 25-entitlement / 10-identity caps, comment-on-revoke) are enforced pre-submission, and empty userAuth reads surface a permission-gap diagnostic. * fix(sailpoint): address review - SSRF host allowlist, working aggregate, retry/count fixes - Restrict tenant host resolution to *.api.identitynow.com / *.api.identitynowgov.com (or a bare tenant subdomain) so the client-credentials request can never post the PAT secret to an attacker-controlled or internal host. Throws on any other host. - Make search_aggregate functional: add an aggregationsDsl input (tool/contract/route/block) and preserve the AggregationResult object instead of dropping it through the list handler. - Retry the token exchange on 429 with Retry-After backoff, matching sailpointFetch. - Rebuild the multipart FormData per attempt so 401/429 retries never reuse a consumed body. - Expose an "Include Total Count" toggle so totalCount is reachable from the block UI. - Regenerate docs. Add route tests for the SSRF guard and aggregate object preservation. * fix(sailpoint): require aggregations for aggregate, expose searchAfter, fix review-item filters - Require aggregationsDsl on search_aggregate (contract + tool) and send aggregationType: DSL so /search/aggregate always receives a valid aggregations definition. - Coerce searchAfter cursor elements to strings instead of dropping non-string values, and expose a searchAfter input on the block so deep pagination past 10k is reachable from the UI. - Correct the certification review-item filter fields (entitlements / access profiles / roles) to comma-separated ID filters instead of true/false placeholders. * fix(sailpoint): bind the token cache to the client secret Include a hash of client_secret in the token cache key so a caller with a matching tenant/clientId but the wrong secret cannot reuse another principal's cached bearer token - a mismatched secret now misses the cache and fails the token exchange. Regression test added. * fix(sailpoint): adaptive token TTL buffer and honest search-count on missing header - Cap the token cache expiry buffer at the smaller of 60s and 10% of the lifetime so a short-lived token still caches instead of expiring immediately. - Return an error for search_count when SailPoint provides no X-Total-Count (and no numeric body) instead of reporting a misleading total of 0. Tests added for both count paths. * fix(sailpoint): align integration with current api * chore(docs): refresh generated manifest * fix(sailpoint): reject empty aggregation definitions * fix(docs): render SailPoint tenant placeholder * fix(docs): source safe SailPoint tenant URL --------- Co-authored-by: Waleed Latif <walif6@gmail.com>
1 parent 507663f commit dcaaf8e

29 files changed

Lines changed: 7967 additions & 4 deletions

File tree

apps/docs/components/icons.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5893,6 +5893,18 @@ export function PipedriveIcon(props: SVGProps<SVGSVGElement>) {
58935893
)
58945894
}
58955895

5896+
export function SailPointIcon(props: SVGProps<SVGSVGElement>) {
5897+
return (
5898+
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'>
5899+
<path
5900+
fill='currentColor'
5901+
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'
5902+
/>
5903+
<rect fill='currentColor' x='3' y='19.9' width='18' height='1.8' rx='.9' />
5904+
</svg>
5905+
)
5906+
}
5907+
58965908
export function SalesforceIcon(props: SVGProps<SVGSVGElement>) {
58975909
return (
58985910
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 273 191'>

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ import {
207207
RootlyIcon,
208208
RssIcon,
209209
S3Icon,
210+
SailPointIcon,
210211
SalesforceIcon,
211212
SapConcurIcon,
212213
SapS4HanaIcon,
@@ -510,6 +511,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
510511
rootly: RootlyIcon,
511512
rss: RssIcon,
512513
s3: S3Icon,
514+
sailpoint: SailPointIcon,
513515
salesforce: SalesforceIcon,
514516
sap_concur: SapConcurIcon,
515517
sap_s4hana: SapS4HanaIcon,

apps/docs/content/docs/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@
219219
"rocketlane",
220220
"rootly",
221221
"s3",
222+
"sailpoint",
222223
"salesforce",
223224
"salesforce-service-account",
224225
"sap_concur",

apps/docs/content/docs/integrations/sailpoint.mdx

Lines changed: 897 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/blocks/sailpoint.ts

Lines changed: 1826 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/registry-maps.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ import { RootlyBlock, RootlyBlockMeta } from '@/blocks/blocks/rootly'
286286
import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router'
287287
import { RssBlock, RssBlockMeta } from '@/blocks/blocks/rss'
288288
import { S3Block, S3BlockMeta } from '@/blocks/blocks/s3'
289+
import { SailPointBlock, SailPointBlockMeta } from '@/blocks/blocks/sailpoint'
289290
import { SalesforceBlock, SalesforceBlockMeta } from '@/blocks/blocks/salesforce'
290291
import { SapConcurBlock, SapConcurBlockMeta } from '@/blocks/blocks/sap_concur'
291292
import { SapS4HanaBlock, SapS4HanaBlockMeta } from '@/blocks/blocks/sap_s4hana'
@@ -629,6 +630,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
629630
router_v2: RouterV2Block,
630631
rss: RssBlock,
631632
s3: S3Block,
633+
sailpoint: SailPointBlock,
632634
salesforce: SalesforceBlock,
633635
sap_concur: SapConcurBlock,
634636
sap_s4hana: SapS4HanaBlock,
@@ -943,6 +945,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
943945
rootly: RootlyBlockMeta,
944946
rss: RssBlockMeta,
945947
s3: S3BlockMeta,
948+
sailpoint: SailPointBlockMeta,
946949
salesforce: SalesforceBlockMeta,
947950
sap_concur: SapConcurBlockMeta,
948951
sap_s4hana: SapS4HanaBlockMeta,

apps/sim/components/icons.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5893,6 +5893,18 @@ export function PipedriveIcon(props: SVGProps<SVGSVGElement>) {
58935893
)
58945894
}
58955895

5896+
export function SailPointIcon(props: SVGProps<SVGSVGElement>) {
5897+
return (
5898+
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'>
5899+
<path
5900+
fill='currentColor'
5901+
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'
5902+
/>
5903+
<rect fill='currentColor' x='3' y='19.9' width='18' height='1.8' rx='.9' />
5904+
</svg>
5905+
)
5906+
}
5907+
58965908
export function SalesforceIcon(props: SVGProps<SVGSVGElement>) {
58975909
return (
58985910
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 273 191'>

apps/sim/lib/copilot/generated/docs-manifest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ export const DOCS_MANIFEST: readonly string[] = [
275275
'integrations/rocketlane.mdx',
276276
'integrations/rootly.mdx',
277277
'integrations/s3.mdx',
278+
'integrations/sailpoint.mdx',
278279
'integrations/salesforce-service-account.mdx',
279280
'integrations/salesforce.mdx',
280281
'integrations/sap_concur.mdx',

apps/sim/lib/integrations/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ import {
205205
RootlyIcon,
206206
RssIcon,
207207
S3Icon,
208+
SailPointIcon,
208209
SalesforceIcon,
209210
SapConcurIcon,
210211
SapS4HanaIcon,
@@ -491,6 +492,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
491492
rootly: RootlyIcon,
492493
rss: RssIcon,
493494
s3: S3Icon,
495+
sailpoint: SailPointIcon,
494496
salesforce: SalesforceIcon,
495497
sap_concur: SapConcurIcon,
496498
sap_s4hana: SapS4HanaIcon,
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import {
6+
clearSailPointTokenStateForTests,
7+
getSailPointAccessToken,
8+
getSailPointTokenStateForTests,
9+
resolveSailPointHosts,
10+
sailpointFetch,
11+
} from '@/lib/internal/sailpoint/client'
12+
13+
const mockFetch = vi.fn<typeof fetch>()
14+
15+
function tokenResponse(token: string, expiresIn = 3600): Response {
16+
return Response.json({ access_token: token, expires_in: expiresIn })
17+
}
18+
19+
describe('SailPoint client', () => {
20+
beforeEach(() => {
21+
clearSailPointTokenStateForTests()
22+
mockFetch.mockReset()
23+
vi.stubGlobal('fetch', mockFetch)
24+
})
25+
26+
afterEach(() => {
27+
vi.useRealTimers()
28+
vi.unstubAllGlobals()
29+
})
30+
31+
it('accepts only commercial and government tenant hosts', () => {
32+
expect(resolveSailPointHosts('acme').host).toBe('acme.api.identitynow.com')
33+
expect(resolveSailPointHosts('https://agency.api.identitynowgov.com').host).toBe(
34+
'agency.api.identitynowgov.com'
35+
)
36+
expect(() => resolveSailPointHosts('acme.api.identitynow.com.evil.test')).toThrow(
37+
'not an allowed'
38+
)
39+
})
40+
41+
it('isolates cache entries by the exact credential secret', async () => {
42+
mockFetch
43+
.mockResolvedValueOnce(tokenResponse('first'))
44+
.mockResolvedValueOnce(tokenResponse('second'))
45+
46+
const common = { tenant: 'acme', clientId: 'client' }
47+
expect(await getSailPointAccessToken({ ...common, clientSecret: 'one' })).toBe('first')
48+
expect(await getSailPointAccessToken({ ...common, clientSecret: 'two' })).toBe('second')
49+
expect(mockFetch).toHaveBeenCalledTimes(2)
50+
})
51+
52+
it('single-flights concurrent exchanges for the same credentials', async () => {
53+
let release: ((response: Response) => void) | undefined
54+
mockFetch.mockImplementationOnce(
55+
() =>
56+
new Promise<Response>((resolve) => {
57+
release = resolve
58+
})
59+
)
60+
const credentials = { tenant: 'acme', clientId: 'client', clientSecret: 'secret' }
61+
const first = getSailPointAccessToken(credentials)
62+
const second = getSailPointAccessToken(credentials)
63+
expect(mockFetch).toHaveBeenCalledTimes(1)
64+
release?.(tokenResponse('shared'))
65+
await expect(Promise.all([first, second])).resolves.toEqual(['shared', 'shared'])
66+
})
67+
68+
it('expires cached tokens before their provider expiry', async () => {
69+
vi.useFakeTimers()
70+
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
71+
mockFetch
72+
.mockResolvedValueOnce(tokenResponse('old', 100))
73+
.mockResolvedValueOnce(tokenResponse('new', 100))
74+
const credentials = { tenant: 'acme', clientId: 'client', clientSecret: 'secret' }
75+
76+
expect(await getSailPointAccessToken(credentials)).toBe('old')
77+
vi.setSystemTime(new Date('2026-01-01T00:01:31.000Z'))
78+
expect(await getSailPointAccessToken(credentials)).toBe('new')
79+
})
80+
81+
it('evicts the oldest token when the bounded cache is full', async () => {
82+
mockFetch.mockImplementation(async () => tokenResponse('token'))
83+
for (let index = 0; index < 101; index += 1) {
84+
await getSailPointAccessToken({
85+
tenant: 'acme',
86+
clientId: `client-${index}`,
87+
clientSecret: 'secret',
88+
})
89+
}
90+
expect(getSailPointTokenStateForTests()).toEqual({ cacheSize: 100, exchangeSize: 0 })
91+
})
92+
93+
it('rejects provider responses larger than the shared JSON cap', async () => {
94+
mockFetch.mockResolvedValueOnce(tokenResponse('token')).mockResolvedValueOnce(
95+
new Response('{}', {
96+
status: 200,
97+
headers: { 'content-length': String(10 * 1024 * 1024 + 1) },
98+
})
99+
)
100+
const credentials = { tenant: 'acme', clientId: 'client', clientSecret: 'secret' }
101+
102+
await expect(
103+
sailpointFetch(credentials, (hosts) => ({
104+
url: `${hosts.apiBaseUrl}/identities/v1`,
105+
init: { method: 'GET' },
106+
}))
107+
).rejects.toThrow(/maximum|limit|exceeds/i)
108+
})
109+
})

0 commit comments

Comments
 (0)