Skip to content

Commit 52d5372

Browse files
committed
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.
1 parent 6712f35 commit 52d5372

6 files changed

Lines changed: 63 additions & 12 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,7 +725,7 @@ Return the aggregation result (buckets under `aggregations`, plus `hits`) for a
725725
| --------- | ---- | -------- | ----------- |
726726
| `indices` | json | No | Indices to aggregate over \(defaults to \["identities"\]\) |
727727
| `query` | string | No | Elasticsearch query string to scope the documents before aggregating |
728-
| `aggregationsDsl` | json | No | Elasticsearch aggregations DSL object defining the buckets/metrics to compute, e.g. \{ "department": \{ "terms": \{ "field": "attributes.department" \} \} \} |
728+
| `aggregationsDsl` | json | Yes | Elasticsearch aggregations DSL object defining the buckets/metrics to compute, e.g. \{ "department": \{ "terms": \{ "field": "attributes.department" \} \} \} |
729729
| `limit` | number | No | Maximum number of aggregation results \(max 250\) |
730730
| `offset` | number | No | Pagination offset \(0-based\) |
731731

‎apps/sim/app/api/tools/sailpoint/query/route.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ describe('SailPoint query route', () => {
139139
operation: 'sailpoint_search_aggregate',
140140
indices: 'identities',
141141
query: 'attributes.department:*',
142+
aggregationsDsl: { department: { terms: { field: 'attributes.department' } } },
142143
})
143144

144145
const response = await POST(request)
@@ -148,10 +149,31 @@ describe('SailPoint query route', () => {
148149
expect(fetchMock.mock.calls[1]?.[0]).toBe(
149150
'https://acme-aggregate.api.identitynow.com/v2025/search/aggregate'
150151
)
152+
const aggInit = fetchMock.mock.calls[1]?.[1] as RequestInit
153+
expect(JSON.parse(aggInit.body as string)).toEqual({
154+
indices: ['identities'],
155+
query: { query: 'attributes.department:*' },
156+
aggregationType: 'DSL',
157+
aggregationsDsl: { department: { terms: { field: 'attributes.department' } } },
158+
})
151159
// The full AggregationResult object is preserved under `item`, not dropped as an empty list.
152160
expect(data.output).toEqual({ item: aggregationResult })
153161
})
154162

163+
it('rejects a search aggregate without an aggregations definition', async () => {
164+
const request = createMockRequest('POST', {
165+
...baseCreds,
166+
tenant: 'acme-agg-missing',
167+
operation: 'sailpoint_search_aggregate',
168+
indices: 'identities',
169+
})
170+
171+
const response = await POST(request)
172+
173+
expect(response.status).toBe(400)
174+
expect(fetchMock).not.toHaveBeenCalled()
175+
})
176+
155177
it('caches the token across calls with the same credentials', async () => {
156178
fetchMock
157179
.mockResolvedValueOnce(tokenResponse())

‎apps/sim/app/api/tools/sailpoint/query/route.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,22 @@ function qs(params: Record<string, unknown>): string {
5757
return serialized ? `?${serialized}` : ''
5858
}
5959

60-
/** Normalizes an array/JSON-string/comma-list into a string[] (or undefined when empty). */
60+
/** Coerces a primitive (string/number/boolean) to a non-empty string token, else null. */
61+
function coerceToken(value: unknown): string | null {
62+
if (typeof value === 'string') return value.length ? value : null
63+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
64+
return null
65+
}
66+
67+
/**
68+
* Normalizes an array/JSON-string/comma-list into a string[] (or undefined when empty). Numeric or
69+
* boolean array elements are coerced to strings rather than dropped - SailPoint's `searchAfter`
70+
* cursor mirrors sort values, which may include numbers, and must be sent as strings.
71+
*/
6172
function toStringList(value: unknown): string[] | undefined {
6273
if (value == null) return undefined
6374
if (Array.isArray(value)) {
64-
const arr = value.filter((v): v is string => typeof v === 'string' && v.length > 0)
75+
const arr = value.map(coerceToken).filter((v): v is string => v !== null)
6576
return arr.length ? arr : undefined
6677
}
6778
if (typeof value === 'string') {
@@ -71,7 +82,7 @@ function toStringList(value: unknown): string[] | undefined {
7182
try {
7283
const parsed: unknown = JSON.parse(trimmed)
7384
if (Array.isArray(parsed)) {
74-
const arr = parsed.filter((v): v is string => typeof v === 'string')
85+
const arr = parsed.map(coerceToken).filter((v): v is string => v !== null)
7586
return arr.length ? arr : undefined
7687
}
7788
} catch {
@@ -196,6 +207,9 @@ function dispatch(
196207
const searchBody = filterUndefined({
197208
indices: toStringList(body.indices) ?? ['identities'],
198209
query: body.query ? { query: body.query } : undefined,
210+
// Interpret aggregationsDsl as Elasticsearch DSL (also SailPoint's default) rather than
211+
// the typed SAILPOINT aggregation mode.
212+
aggregationType: 'DSL',
199213
aggregationsDsl: body.aggregationsDsl,
200214
})
201215
return execute(

‎apps/sim/blocks/blocks/sailpoint.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,14 @@ export const SailPointBlock: BlockConfig<SailPointListResponse> = {
199199
condition: { field: 'operation', value: 'sailpoint_search' },
200200
mode: 'advanced',
201201
},
202+
{
203+
id: 'searchAfter',
204+
title: 'Search After',
205+
type: 'short-input',
206+
placeholder: '["John Doe","2c9180...id"] (cursor from the last result to page past 10k)',
207+
condition: { field: 'operation', value: 'sailpoint_search' },
208+
mode: 'advanced',
209+
},
202210
{
203211
id: 'aggregationsDsl',
204212
title: 'Aggregations',
@@ -393,25 +401,25 @@ export const SailPointBlock: BlockConfig<SailPointListResponse> = {
393401
},
394402
{
395403
id: 'entitlements',
396-
title: 'Entitlements Filter',
404+
title: 'Entitlement IDs',
397405
type: 'short-input',
398-
placeholder: 'true / false',
406+
placeholder: 'Comma-separated entitlement IDs to filter by',
399407
condition: { field: 'operation', value: 'sailpoint_list_certification_review_items' },
400408
mode: 'advanced',
401409
},
402410
{
403411
id: 'accessProfiles',
404-
title: 'Access Profiles Filter',
412+
title: 'Access Profile IDs',
405413
type: 'short-input',
406-
placeholder: 'true / false',
414+
placeholder: 'Comma-separated access profile IDs to filter by',
407415
condition: { field: 'operation', value: 'sailpoint_list_certification_review_items' },
408416
mode: 'advanced',
409417
},
410418
{
411419
id: 'roles',
412-
title: 'Roles Filter',
420+
title: 'Role IDs',
413421
type: 'short-input',
414-
placeholder: 'true / false',
422+
placeholder: 'Comma-separated role IDs to filter by',
415423
condition: { field: 'operation', value: 'sailpoint_list_certification_review_items' },
416424
mode: 'advanced',
417425
},
@@ -617,6 +625,7 @@ export const SailPointBlock: BlockConfig<SailPointListResponse> = {
617625
setStr('indices', params.indices)
618626
setStr('query', params.query)
619627
setStr('sort', params.sort)
628+
setStr('searchAfter', params.searchAfter)
620629
if (params.includeNested === 'false' || params.includeNested === false) {
621630
mapped.includeNested = false
622631
}
@@ -765,6 +774,7 @@ export const SailPointBlock: BlockConfig<SailPointListResponse> = {
765774
query: { type: 'string', description: 'Elasticsearch query string' },
766775
includeNested: { type: 'string', description: 'Include nested objects in search results' },
767776
sort: { type: 'string', description: 'Search sort fields' },
777+
searchAfter: { type: 'string', description: 'searchAfter cursor for deep search pagination' },
768778
aggregationsDsl: {
769779
type: 'json',
770780
description: 'Elasticsearch aggregations DSL for search aggregate',

‎apps/sim/lib/api/contracts/tools/sailpoint.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,12 @@ const searchAggregateSchema = z.object({
9595
operation: z.literal('sailpoint_search_aggregate'),
9696
indices: stringListField,
9797
query: z.string().optional(),
98-
aggregationsDsl: z.preprocess(parseJson, z.record(z.string(), z.unknown())).optional(),
98+
aggregationsDsl: z.preprocess(
99+
parseJson,
100+
z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, {
101+
message: 'aggregationsDsl is required and must define at least one aggregation',
102+
})
103+
),
99104
limit: limitField(LIMIT_STANDARD),
100105
offset: offsetField,
101106
})

‎apps/sim/tools/sailpoint/search_aggregate.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const sailpointSearchAggregateTool: ToolConfig<
3333
},
3434
aggregationsDsl: {
3535
type: 'json',
36-
required: false,
36+
required: true,
3737
visibility: 'user-or-llm',
3838
description:
3939
'Elasticsearch aggregations DSL object defining the buckets/metrics to compute, e.g. { "department": { "terms": { "field": "attributes.department" } } }',

0 commit comments

Comments
 (0)