From 63e9e55dd7060b2d5d7559d3ae6240cf6e9e0946 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 29 Jul 2026 23:36:00 -0700 Subject: [PATCH 1/5] 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. --- apps/docs/components/icons.tsx | 12 + apps/docs/components/ui/icon-mapping.ts | 2 + .../content/docs/en/integrations/meta.json | 1 + .../docs/en/integrations/sailpoint.mdx | 774 +++++++++++++++ apps/sim/app/api/tools/sailpoint/client.ts | 204 ++++ .../api/tools/sailpoint/load/route.test.ts | 75 ++ .../sim/app/api/tools/sailpoint/load/route.ts | 127 +++ .../api/tools/sailpoint/query/route.test.ts | 231 +++++ .../app/api/tools/sailpoint/query/route.ts | 476 ++++++++++ apps/sim/blocks/blocks/sailpoint.ts | 885 ++++++++++++++++++ apps/sim/blocks/registry-maps.ts | 3 + apps/sim/components/icons.tsx | 12 + apps/sim/lib/api/contracts/tools/sailpoint.ts | 471 ++++++++++ apps/sim/lib/integrations/icon-mapping.ts | 2 + apps/sim/lib/integrations/integrations.json | 127 +++ apps/sim/tools/registry.ts | 56 ++ .../tools/sailpoint/cancel_access_request.ts | 57 ++ apps/sim/tools/sailpoint/common.ts | 142 +++ .../get_access_profile_entitlements.ts | 69 ++ .../sailpoint/get_access_request_status.ts | 98 ++ apps/sim/tools/sailpoint/get_account.ts | 43 + .../tools/sailpoint/get_account_activity.ts | 46 + .../sailpoint/get_account_entitlements.ts | 55 ++ apps/sim/tools/sailpoint/get_campaign.ts | 53 ++ apps/sim/tools/sailpoint/get_entitlement.ts | 46 + apps/sim/tools/sailpoint/get_identity.ts | 43 + .../tools/sailpoint/get_role_entitlements.ts | 69 ++ apps/sim/tools/sailpoint/get_source.ts | 43 + apps/sim/tools/sailpoint/index.ts | 28 + .../tools/sailpoint/list_access_profiles.ts | 62 ++ .../sailpoint/list_account_activities.ts | 84 ++ apps/sim/tools/sailpoint/list_accounts.ts | 69 ++ apps/sim/tools/sailpoint/list_campaigns.ts | 70 ++ .../list_certification_review_items.ts | 90 ++ .../tools/sailpoint/list_certifications.ts | 70 ++ apps/sim/tools/sailpoint/list_entitlements.ts | 76 ++ apps/sim/tools/sailpoint/list_identities.ts | 70 ++ apps/sim/tools/sailpoint/list_roles.ts | 59 ++ apps/sim/tools/sailpoint/list_sources.ts | 76 ++ apps/sim/tools/sailpoint/load_accounts.ts | 61 ++ apps/sim/tools/sailpoint/load_entitlements.ts | 57 ++ apps/sim/tools/sailpoint/request_access.ts | 75 ++ apps/sim/tools/sailpoint/search.ts | 82 ++ apps/sim/tools/sailpoint/search_aggregate.ts | 72 ++ apps/sim/tools/sailpoint/search_count.ts | 54 ++ apps/sim/tools/sailpoint/types.ts | 185 ++++ scripts/check-api-validation-contracts.ts | 4 +- 47 files changed, 5564 insertions(+), 2 deletions(-) create mode 100644 apps/docs/content/docs/en/integrations/sailpoint.mdx create mode 100644 apps/sim/app/api/tools/sailpoint/client.ts create mode 100644 apps/sim/app/api/tools/sailpoint/load/route.test.ts create mode 100644 apps/sim/app/api/tools/sailpoint/load/route.ts create mode 100644 apps/sim/app/api/tools/sailpoint/query/route.test.ts create mode 100644 apps/sim/app/api/tools/sailpoint/query/route.ts create mode 100644 apps/sim/blocks/blocks/sailpoint.ts create mode 100644 apps/sim/lib/api/contracts/tools/sailpoint.ts create mode 100644 apps/sim/tools/sailpoint/cancel_access_request.ts create mode 100644 apps/sim/tools/sailpoint/common.ts create mode 100644 apps/sim/tools/sailpoint/get_access_profile_entitlements.ts create mode 100644 apps/sim/tools/sailpoint/get_access_request_status.ts create mode 100644 apps/sim/tools/sailpoint/get_account.ts create mode 100644 apps/sim/tools/sailpoint/get_account_activity.ts create mode 100644 apps/sim/tools/sailpoint/get_account_entitlements.ts create mode 100644 apps/sim/tools/sailpoint/get_campaign.ts create mode 100644 apps/sim/tools/sailpoint/get_entitlement.ts create mode 100644 apps/sim/tools/sailpoint/get_identity.ts create mode 100644 apps/sim/tools/sailpoint/get_role_entitlements.ts create mode 100644 apps/sim/tools/sailpoint/get_source.ts create mode 100644 apps/sim/tools/sailpoint/index.ts create mode 100644 apps/sim/tools/sailpoint/list_access_profiles.ts create mode 100644 apps/sim/tools/sailpoint/list_account_activities.ts create mode 100644 apps/sim/tools/sailpoint/list_accounts.ts create mode 100644 apps/sim/tools/sailpoint/list_campaigns.ts create mode 100644 apps/sim/tools/sailpoint/list_certification_review_items.ts create mode 100644 apps/sim/tools/sailpoint/list_certifications.ts create mode 100644 apps/sim/tools/sailpoint/list_entitlements.ts create mode 100644 apps/sim/tools/sailpoint/list_identities.ts create mode 100644 apps/sim/tools/sailpoint/list_roles.ts create mode 100644 apps/sim/tools/sailpoint/list_sources.ts create mode 100644 apps/sim/tools/sailpoint/load_accounts.ts create mode 100644 apps/sim/tools/sailpoint/load_entitlements.ts create mode 100644 apps/sim/tools/sailpoint/request_access.ts create mode 100644 apps/sim/tools/sailpoint/search.ts create mode 100644 apps/sim/tools/sailpoint/search_aggregate.ts create mode 100644 apps/sim/tools/sailpoint/search_count.ts create mode 100644 apps/sim/tools/sailpoint/types.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index efedba2000d..b541e3328d9 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -5166,6 +5166,18 @@ export function PipedriveIcon(props: SVGProps) { ) } +export function SailPointIcon(props: SVGProps) { + return ( + + + + + ) +} + export function SalesforceIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 8070694868b..5786dafaf20 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -190,6 +190,7 @@ import { RootlyIcon, RssIcon, S3Icon, + SailPointIcon, SalesforceIcon, SapConcurIcon, SapS4HanaIcon, @@ -470,6 +471,7 @@ export const blockTypeToIconMap: Record = { rootly: RootlyIcon, rss: RssIcon, s3: S3Icon, + sailpoint: SailPointIcon, salesforce: SalesforceIcon, sap_concur: SapConcurIcon, sap_s4hana: SapS4HanaIcon, diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 56235d7d9c2..1b48b092fcc 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -200,6 +200,7 @@ "rocketlane", "rootly", "s3", + "sailpoint", "salesforce", "salesforce-service-account", "sap_concur", diff --git a/apps/docs/content/docs/en/integrations/sailpoint.mdx b/apps/docs/content/docs/en/integrations/sailpoint.mdx new file mode 100644 index 00000000000..cbe1c6ebfff --- /dev/null +++ b/apps/docs/content/docs/en/integrations/sailpoint.mdx @@ -0,0 +1,774 @@ +--- +title: SailPoint +description: Govern identities and access in SailPoint Identity Security Cloud +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Read and act on identity governance data in SailPoint Identity Security Cloud (ISC): search identities, accounts, entitlements, roles, and access profiles; review account activities, campaigns, and certifications; and request, revoke, or cancel access. Authenticates with a Personal Access Token (PAT) using the OAuth2 client-credentials grant against your per-tenant host (https://{tenant}.api.identitynow.com). + + + +## Actions + +### `sailpoint_cancel_access_request` + +Cancel a pending SailPoint access request by its identity request ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accountActivityId` | string | Yes | The identityRequestId of the access request to cancel | +| `comment` | string | Yes | Reason for cancellation | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_get_access_profile_entitlements` + +List the entitlements granted by a specific SailPoint access profile. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | string | Yes | Access Profile ID | +| `filters` | string | No | SailPoint filter expression to narrow results | +| `sorters` | string | No | SailPoint sorters expression | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_get_access_request_status` + +List the status of SailPoint access requests with optional identity and state filters. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `requestedFor` | string | No | Identity ID the request was made for | +| `requestedBy` | string | No | Identity ID that submitted the request | +| `regardingIdentity` | string | No | Identity ID the request is about \(requester or target\) | +| `assignedTo` | string | No | Identity ID a pending approval is assigned to | +| `requestState` | string | No | EXECUTING | +| `filters` | string | No | SailPoint filter expression to narrow results | +| `sorters` | string | No | SailPoint sorters expression | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_get_account` + +Get a single SailPoint account by ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | string | Yes | Account ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_get_account_activity` + +Get a single SailPoint account activity by ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | string | Yes | Account Activity ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_get_account_entitlements` + +List the entitlements granted on a specific SailPoint account. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | string | Yes | Account ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_get_campaign` + +Get a single SailPoint certification campaign by ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | string | Yes | Campaign ID | +| `detail` | string | No | SLIM or FULL | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_get_entitlement` + +Get a single SailPoint entitlement by ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | string | Yes | Entitlement ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_get_identity` + +Get a single SailPoint identity by ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | string | Yes | Identity ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_get_role_entitlements` + +List the entitlements granted by a specific SailPoint role. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | string | Yes | Role ID | +| `filters` | string | No | SailPoint filter expression to narrow results | +| `sorters` | string | No | SailPoint sorters expression | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_get_source` + +Get a single SailPoint identity source by ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | string | Yes | Source ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_list_access_profiles` + +List access profiles in SailPoint with optional filters, sorters, and pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `filters` | string | No | SailPoint filter expression to narrow results | +| `sorters` | string | No | SailPoint sorters expression | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_list_account_activities` + +List account activities (provisioning events) in SailPoint with optional filters and pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `requestedFor` | string | No | Identity ID the activity was requested for | +| `requestedBy` | string | No | Identity ID that requested the activity | +| `regardingIdentity` | string | No | Identity ID the activity is about \(requester or target\) | +| `filters` | string | No | SailPoint filter expression to narrow results | +| `sorters` | string | No | SailPoint sorters expression | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_list_accounts` + +List accounts in SailPoint with optional filters, sorters, and pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `filters` | string | No | SailPoint filter expression to narrow results | +| `sorters` | string | No | SailPoint sorters expression | +| `detailLevel` | string | No | SLIM or FULL \(default\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_list_campaigns` + +List certification campaigns in SailPoint with optional filters, sorters, and pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `detail` | string | No | SLIM \(default\) or FULL | +| `filters` | string | No | SailPoint filter expression to narrow results | +| `sorters` | string | No | SailPoint sorters expression | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_list_certification_review_items` + +List the access review items within a specific SailPoint certification. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | string | Yes | Certification ID | +| `filters` | string | No | SailPoint filter expression to narrow results | +| `sorters` | string | No | SailPoint sorters expression | +| `entitlements` | string | No | Filter review items to specific entitlement IDs | +| `accessProfiles` | string | No | Filter review items to specific access profile IDs | +| `roles` | string | No | Filter review items to specific role IDs | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_list_certifications` + +List certifications in SailPoint with optional reviewer filter, filters, sorters, and pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `reviewerIdentity` | string | No | Reviewer identity ID or 'me' | +| `filters` | string | No | SailPoint filter expression to narrow results | +| `sorters` | string | No | SailPoint sorters expression | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_list_entitlements` + +List entitlements in SailPoint with optional filters, sorters, and pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `filters` | string | No | SailPoint filter expression to narrow results | +| `sorters` | string | No | SailPoint sorters expression | +| `accountId` | string | No | Filter to entitlements on a specific account ID | +| `segmentedForIdentity` | string | No | Return only entitlements visible to the given identity via segmentation | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_list_identities` + +List identities in SailPoint with optional Sailpoint filters, sorters, and pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `filters` | string | No | SailPoint filter expression to narrow results | +| `sorters` | string | No | SailPoint sorters expression | +| `defaultFilter` | string | No | CORRELATED_ONLY \(default\) or NONE | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_list_roles` + +List roles in SailPoint with optional filters, sorters, and pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `filters` | string | No | SailPoint filter expression to narrow results | +| `sorters` | string | No | SailPoint sorters expression | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_list_sources` + +List identity sources in SailPoint with optional filters, sorters, and pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `filters` | string | No | SailPoint filter expression to narrow results | +| `sorters` | string | No | SailPoint sorters expression | +| `forSubadmin` | string | No | Return only sources the given source sub-admin identity can administer | +| `includeIDNSource` | boolean | No | Include the built-in IdentityNow source in results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_load_accounts` + +Trigger an account aggregation for a SailPoint source, optionally uploading a CSV of accounts. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `sourceId` | string | Yes | Source ID to aggregate | +| `file` | file | No | CSV file of accounts to aggregate \(delimited-file sources only\) | +| `disableOptimization` | boolean | No | Reprocess every account regardless of change | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_load_entitlements` + +Trigger an entitlement aggregation for a SailPoint source, optionally uploading a CSV of entitlements. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `sourceId` | string | Yes | Source ID to aggregate | +| `file` | file | No | CSV file of entitlements to aggregate \(delimited-file sources only\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_request_access` + +Submit a SailPoint access request to grant, revoke, or modify access for one or more identities. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `requestedFor` | json | Yes | Array of identity IDs. For REVOKE_ACCESS exactly one identity. | +| `requestedItems` | json | Yes | Array of \{ type: ACCESS_PROFILE\|ROLE\|ENTITLEMENT, id, comment?, removeDate?, startDate?, assignmentId?, nativeIdentity?, clientMetadata? \}. REVOKE requires exactly one item with a comment. | +| `requestType` | string | No | GRANT_ACCESS \(default\), REVOKE_ACCESS, or MODIFY_ACCESS | +| `clientMetadata` | json | No | Optional key/value map, e.g. to record the human requester for correlation | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_search` + +Run a global search across SailPoint indices (identities, entitlements, roles, access profiles, account activities, events). Set includeNested to return nested access[] on identities. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `indices` | json | No | Indices to search: identities, accessprofiles, accountactivities, entitlements, events, roles, or * \(defaults to \["identities"\]\) | +| `query` | string | No | Elasticsearch query string \(e.g. "attributes.department:Engineering"\) | +| `sort` | json | No | Sort fields, e.g. \["displayName","+id"\] | +| `searchAfter` | json | No | searchAfter cursor for deep pagination beyond 10,000 records | +| `includeNested` | boolean | No | Include nested objects \(e.g. identity access\[\]\) in results. Defaults to true. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_search_aggregate` + +Return aggregation buckets for a SailPoint search query (e.g. counts grouped by a field). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `indices` | json | No | Indices to aggregate over \(defaults to \["identities"\]\) | +| `query` | string | No | Elasticsearch query string | +| `limit` | number | No | Maximum number of aggregation results \(max 250\) | +| `offset` | number | No | Pagination offset \(0-based\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + +### `sailpoint_search_count` + +Return the total number of documents matching a SailPoint search query, without the documents themselves. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `indices` | json | No | Indices to search \(defaults to \["identities"\]\) | +| `query` | string | No | Elasticsearch query string | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | json | Raw SailPoint documents for list operations | +| `results` | json | Raw SailPoint documents for search operations | +| `item` | json | Raw SailPoint document for get operations | +| `total` | number | Total matching documents \(search count\) | +| `task` | json | Aggregation task for load operations | +| `accepted` | boolean | Whether an access-request write was accepted | +| `status` | number | HTTP status returned by SailPoint for writes | +| `count` | number | Number of records returned in the page | +| `totalCount` | number | Total matching records when count is requested | +| `complete` | boolean | False when an empty result may indicate a permission gap | +| `warnings` | json | Diagnostic warnings \(e.g. empty-result guidance\) | + + diff --git a/apps/sim/app/api/tools/sailpoint/client.ts b/apps/sim/app/api/tools/sailpoint/client.ts new file mode 100644 index 00000000000..4ce9663fc3d --- /dev/null +++ b/apps/sim/app/api/tools/sailpoint/client.ts @@ -0,0 +1,204 @@ +import { sleep } from '@sim/utils/helpers' +import { isRecordLike } from '@sim/utils/object' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' + +/** + * Shared server-side SailPoint client used by the SailPoint tool routes. Handles per-tenant host + * resolution, the client-credentials token exchange (cached in-process), and a fetch wrapper that + * refreshes the token on a 401 and backs off on a 429 honoring `Retry-After`. + * + * SailPoint enforces 100 requests per client_id per API version per 10 seconds, so a stateless + * per-call token exchange would double every operation against that budget - the cache avoids it. + */ + +export type SailPointApiVersion = 'v2025' | 'v2024' | 'v3' + +const SUPPORTED_VERSIONS: readonly SailPointApiVersion[] = ['v2025', 'v2024', 'v3'] + +export interface SailPointServerCredentials { + clientId: string + clientSecret: string + tenant: string + apiVersion: SailPointApiVersion +} + +export interface SailPointHosts { + /** `https://{host}/{apiVersion}` */ + apiBaseUrl: string + /** `https://{host}/oauth/token` */ + tokenUrl: string + host: string +} + +export interface SailPointFetchResult { + ok: boolean + status: number + data: unknown + headers: Headers +} + +/** Normalizes an incoming version string to a supported value, defaulting to v2025. */ +export function normalizeApiVersion(value: string | undefined | null): SailPointApiVersion { + if (value && SUPPORTED_VERSIONS.includes(value as SailPointApiVersion)) { + return value as SailPointApiVersion + } + return 'v2025' +} + +/** + * Resolves the API + token hosts for a tenant. Accepts either a bare tenant subdomain (`acme`) or a + * full host/URL (`https://acme.api.identitynow.com`, `acme.api.identitynow.com`), stripping any + * protocol, path, or version segment the caller may have included. + */ +export function resolveSailPointHosts( + tenant: string, + apiVersion: SailPointApiVersion +): SailPointHosts { + let host = tenant.trim().replace(/^https?:\/\//i, '') + host = host.replace(/[/?#].*$/, '').replace(/\.+$/, '') + if (!host.includes('.')) { + host = `${host}.api.identitynow.com` + } + return { + host, + apiBaseUrl: `https://${host}/${apiVersion}`, + tokenUrl: `https://${host}/oauth/token`, + } +} + +/** Extracts a human-readable message from a SailPoint error body (ISC `messages[]` or OAuth `error`). */ +export function getSailPointErrorMessage(data: unknown, fallback: string): string { + if (typeof data === 'string') return data || fallback + if (!isRecordLike(data)) return fallback + + if (Array.isArray(data.messages) && data.messages.length > 0) { + const first = data.messages[0] + if (isRecordLike(first) && typeof first.text === 'string' && first.text) { + const trackingId = typeof data.trackingId === 'string' ? data.trackingId : null + return trackingId ? `${first.text} (trackingId: ${trackingId})` : first.text + } + } + + if (typeof data.error_description === 'string' && data.error_description) + return data.error_description + if (typeof data.message === 'string' && data.message) return data.message + if (typeof data.error === 'string' && data.error) return data.error + return fallback +} + +interface CachedToken { + token: string + expiresAt: number +} + +const TOKEN_EXPIRY_BUFFER_MS = 60_000 +const tokenCache = new Map() + +function cacheKey(creds: SailPointServerCredentials): string { + return `${creds.tenant}:${creds.clientId}:${creds.apiVersion}` +} + +/** Drops any cached token for these credentials so the next call re-exchanges. */ +export function invalidateSailPointToken(creds: SailPointServerCredentials): void { + tokenCache.delete(cacheKey(creds)) +} + +/** Returns a cached bearer token or performs a client-credentials exchange and caches it. */ +export async function getSailPointAccessToken(creds: SailPointServerCredentials): Promise { + const key = cacheKey(creds) + const cached = tokenCache.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.token + } + + const { tokenUrl } = resolveSailPointHosts(creds.tenant, creds.apiVersion) + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: creds.clientId, + client_secret: creds.clientSecret, + }).toString(), + cache: 'no-store', + }) + + const data: unknown = await response.json().catch(() => null) + if (!response.ok) { + throw new Error(getSailPointErrorMessage(data, 'Failed to authenticate with SailPoint')) + } + if (!isRecordLike(data) || typeof data.access_token !== 'string') { + throw new Error('SailPoint authentication did not return an access token') + } + + const expiresInSec = typeof data.expires_in === 'number' ? data.expires_in : 3600 + tokenCache.set(key, { + token: data.access_token, + expiresAt: Date.now() + Math.max(expiresInSec * 1000 - TOKEN_EXPIRY_BUFFER_MS, 0), + }) + return data.access_token +} + +async function parseResponseBody(response: Response): Promise { + if (response.status === 204) return null + const text = await response.text() + if (!text) return null + try { + return JSON.parse(text) + } catch { + return text + } +} + +/** + * Performs an authenticated SailPoint request, refreshing the token once on a 401 and backing off on + * a 429 (honoring `Retry-After`). `buildRequest` receives the current token + resolved hosts so it can + * compose the URL/body; the bearer header is applied automatically. + */ +export async function sailpointFetch( + creds: SailPointServerCredentials, + buildRequest: (token: string, hosts: SailPointHosts) => { url: string; init: RequestInit }, + options: { maxRetries?: number } = {} +): Promise { + const maxRetries = options.maxRetries ?? 4 + const hosts = resolveSailPointHosts(creds.tenant, creds.apiVersion) + let attempt = 0 + let refreshedOn401 = false + + while (true) { + const token = await getSailPointAccessToken(creds) + const { url, init } = buildRequest(token, hosts) + const headers = new Headers(init.headers) + headers.set('Authorization', `Bearer ${token}`) + if (!headers.has('Accept')) headers.set('Accept', 'application/json') + + const response = await fetch(url, { ...init, headers, cache: 'no-store' }) + + if (response.status === 401 && !refreshedOn401) { + invalidateSailPointToken(creds) + refreshedOn401 = true + continue + } + + if (response.status === 429 && attempt < maxRetries) { + const retryAfterMs = parseRetryAfter(response.headers.get('retry-after')) + attempt += 1 + await sleep(backoffWithJitter(attempt, retryAfterMs)) + continue + } + + const data = await parseResponseBody(response) + return { ok: response.ok, status: response.status, data, headers: response.headers } + } +} + +/** Reads the `X-Total-Count` header as a number, or null when absent/unparseable. */ +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 : null +} diff --git a/apps/sim/app/api/tools/sailpoint/load/route.test.ts b/apps/sim/app/api/tools/sailpoint/load/route.test.ts new file mode 100644 index 00000000000..4cce2bce8bd --- /dev/null +++ b/apps/sim/app/api/tools/sailpoint/load/route.test.ts @@ -0,0 +1,75 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { fetchMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), +})) + +import { POST } from '@/app/api/tools/sailpoint/load/route' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function tokenResponse(): Response { + return jsonResponse({ access_token: 'token-123', token_type: 'Bearer', expires_in: 3600 }) +} + +describe('SailPoint load route', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'internal_jwt', + }) + }) + + it('triggers a source aggregation without a file and returns the task', async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse({ id: 'task-1', type: 'ACCOUNT_AGGREGATION' }, 202)) + + const request = createMockRequest('POST', { + clientId: 'client-id', + clientSecret: 'client-secret', + tenant: 'acme-load', + operation: 'sailpoint_load_accounts', + sourceId: 'src-1', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://acme-load.api.identitynow.com/v2025/sources/src-1/load-accounts' + ) + const init = fetchMock.mock.calls[1]?.[1] as RequestInit + expect(init.method).toBe('POST') + expect(init.body).toBeInstanceOf(FormData) + expect(data.output).toEqual({ task: { id: 'task-1', type: 'ACCOUNT_AGGREGATION' } }) + }) + + it('rejects a load request that is missing a source ID', async () => { + const request = createMockRequest('POST', { + clientId: 'client-id', + clientSecret: 'client-secret', + tenant: 'acme-load', + operation: 'sailpoint_load_entitlements', + }) + + const response = await POST(request) + + expect(response.status).toBe(400) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/tools/sailpoint/load/route.ts b/apps/sim/app/api/tools/sailpoint/load/route.ts new file mode 100644 index 00000000000..f8b8807f600 --- /dev/null +++ b/apps/sim/app/api/tools/sailpoint/load/route.ts @@ -0,0 +1,127 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { sailpointLoadContract } from '@/lib/api/contracts/tools/sailpoint' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' +import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { + getSailPointErrorMessage, + normalizeApiVersion, + type SailPointServerCredentials, + sailpointFetch, +} from '@/app/api/tools/sailpoint/client' + +const logger = createLogger('SailPointLoadAPI') + +const LOAD_PATHS: Record = { + sailpoint_load_accounts: 'load-accounts', + sailpoint_load_entitlements: 'load-entitlements', +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + sailpointLoadContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid SailPoint load request'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + + const body = parsed.data.body + const creds: SailPointServerCredentials = { + clientId: body.clientId, + clientSecret: body.clientSecret, + tenant: body.tenant, + apiVersion: normalizeApiVersion(body.apiVersion), + } + + const formData = new FormData() + + if (body.file && typeof body.file === 'object') { + const userFiles = processFilesToUserFiles([body.file as RawFileInput], requestId, logger) + if (userFiles.length === 0) { + return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 }) + } + const userFile = userFiles[0] + + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + + try { + const { buffer } = await downloadServableFileFromStorage(userFile, requestId, logger) + formData.append( + 'file', + new Blob([new Uint8Array(buffer)], { type: userFile.type || 'text/csv' }), + userFile.name || 'aggregation.csv' + ) + } catch (error) { + const notReady = docNotReadyResponse(error) + if (notReady) return notReady + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Failed to download file') }, + { status: 500 } + ) + } + } + + if (body.operation === 'sailpoint_load_accounts' && body.disableOptimization) { + formData.append('disableOptimization', 'true') + } + + const loadPath = LOAD_PATHS[body.operation] + + try { + logger.info(`[${requestId}] SailPoint aggregation`, { + operation: body.operation, + apiVersion: creds.apiVersion, + hasFile: formData.has('file'), + }) + + const result = await sailpointFetch(creds, (_token, hosts) => ({ + url: `${hosts.apiBaseUrl}/sources/${encodeURIComponent(body.sourceId)}/${loadPath}`, + init: { method: 'POST', body: formData }, + })) + + if (!result.ok) { + return NextResponse.json( + { + success: false, + error: getSailPointErrorMessage(result.data, 'SailPoint aggregation failed'), + }, + { status: result.status || 502 } + ) + } + + return NextResponse.json({ success: true, output: { task: result.data ?? null } }) + } catch (error) { + const message = getErrorMessage(error, 'SailPoint aggregation failed') + logger.error(`[${requestId}] SailPoint aggregation failed`, { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/sailpoint/query/route.test.ts b/apps/sim/app/api/tools/sailpoint/query/route.test.ts new file mode 100644 index 00000000000..f1c549f479e --- /dev/null +++ b/apps/sim/app/api/tools/sailpoint/query/route.test.ts @@ -0,0 +1,231 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { fetchMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), +})) + +import { POST } from '@/app/api/tools/sailpoint/query/route' + +function jsonResponse(body: unknown, status = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }) +} + +function emptyResponse(status: number): Response { + return new Response(null, { status }) +} + +function tokenResponse(): Response { + return jsonResponse({ access_token: 'token-123', token_type: 'Bearer', expires_in: 3600 }) +} + +const baseCreds = { + clientId: 'client-id', + clientSecret: 'client-secret', +} + +describe('SailPoint query route', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'internal_jwt', + }) + }) + + it('lists identities, exchanging a token then calling the v2025 endpoint', async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce( + jsonResponse([{ id: 'i1', name: 'Alice' }], 200, { 'X-Total-Count': '1' }) + ) + + const request = createMockRequest('POST', { + ...baseCreds, + tenant: 'acme-identities', + operation: 'sailpoint_list_identities', + filters: 'name sw "A"', + limit: 50, + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://acme-identities.api.identitynow.com/oauth/token' + ) + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://acme-identities.api.identitynow.com/v2025/identities?filters=name+sw+%22A%22&limit=50' + ) + expect(data.output).toEqual({ + items: [{ id: 'i1', name: 'Alice' }], + count: 1, + totalCount: 1, + complete: true, + warnings: [], + }) + }) + + it('flags an empty identity result with a diagnostic warning', async () => { + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(jsonResponse([])) + + const request = createMockRequest('POST', { + ...baseCreds, + tenant: 'acme-empty', + operation: 'sailpoint_list_identities', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.output.count).toBe(0) + expect(data.output.complete).toBe(false) + expect(data.output.warnings).toHaveLength(1) + expect(data.output.warnings[0]).toContain('user level') + }) + + it('posts a search body with the query object and returns results', async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse([{ _type: 'identity', id: 'i1' }])) + + const request = createMockRequest('POST', { + ...baseCreds, + tenant: 'acme-search', + operation: 'sailpoint_search', + indices: 'identities', + query: 'name:A*', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://acme-search.api.identitynow.com/v2025/search' + ) + const searchInit = fetchMock.mock.calls[1]?.[1] as RequestInit + expect(searchInit.method).toBe('POST') + expect(JSON.parse(searchInit.body as string)).toEqual({ + indices: ['identities'], + query: { query: 'name:A*' }, + }) + expect(data.output.results).toEqual([{ _type: 'identity', id: 'i1' }]) + }) + + it('caches the token across calls with the same credentials', async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse([{ id: 'a1' }])) + .mockResolvedValueOnce(jsonResponse([{ id: 'a2' }])) + + const makeRequest = () => + createMockRequest('POST', { + ...baseCreds, + tenant: 'acme-cache', + operation: 'sailpoint_list_accounts', + }) + + await POST(makeRequest()) + await POST(makeRequest()) + + // 1 token exchange + 2 API calls (not 4) - the token is reused + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://acme-cache.api.identitynow.com/oauth/token') + expect(fetchMock.mock.calls[1]?.[0]).toContain('/v2025/accounts') + expect(fetchMock.mock.calls[2]?.[0]).toContain('/v2025/accounts') + }) + + it('backs off and retries on a 429 response', async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(emptyResponse(429)) + .mockResolvedValueOnce(jsonResponse([{ id: 'r1' }])) + + const request = createMockRequest('POST', { + ...baseCreds, + tenant: 'acme-429', + operation: 'sailpoint_list_roles', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(data.output.items).toEqual([{ id: 'r1' }]) + }) + + it('accepts an access-request write (202) as accepted', async () => { + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(emptyResponse(202)) + + const request = createMockRequest('POST', { + ...baseCreds, + tenant: 'acme-grant', + operation: 'sailpoint_request_access', + requestedFor: ['identity-1'], + requestedItems: [{ type: 'ENTITLEMENT', id: 'ent-1' }], + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://acme-grant.api.identitynow.com/v2025/access-requests' + ) + expect(data.output).toEqual({ accepted: true, status: 202 }) + }) + + it('rejects a revoke that targets more than one identity before calling SailPoint', async () => { + const request = createMockRequest('POST', { + ...baseCreds, + tenant: 'acme-revoke', + operation: 'sailpoint_request_access', + requestType: 'REVOKE_ACCESS', + requestedFor: ['identity-1', 'identity-2'], + requestedItems: [{ type: 'ENTITLEMENT', id: 'ent-1', comment: 'offboarding' }], + }) + + const response = await POST(request) + + expect(response.status).toBe(400) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('propagates a SailPoint error body', async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce( + jsonResponse( + { messages: [{ locale: 'en', text: 'Insufficient access' }], trackingId: 'trk-1' }, + 403 + ) + ) + + const request = createMockRequest('POST', { + ...baseCreds, + tenant: 'acme-error', + operation: 'sailpoint_get_identity', + id: 'identity-1', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.success).toBe(false) + expect(data.error).toContain('Insufficient access') + }) +}) diff --git a/apps/sim/app/api/tools/sailpoint/query/route.ts b/apps/sim/app/api/tools/sailpoint/query/route.ts new file mode 100644 index 00000000000..87a02a0edc7 --- /dev/null +++ b/apps/sim/app/api/tools/sailpoint/query/route.ts @@ -0,0 +1,476 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { filterUndefined } from '@sim/utils/object' +import { type NextRequest, NextResponse } from 'next/server' +import { + type SailpointQueryBody, + sailpointQueryContract, +} from '@/lib/api/contracts/tools/sailpoint' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + getSailPointErrorMessage, + normalizeApiVersion, + readTotalCount, + type SailPointFetchResult, + type SailPointHosts, + type SailPointServerCredentials, + sailpointFetch, +} from '@/app/api/tools/sailpoint/client' + +const logger = createLogger('SailPointQueryAPI') + +/** + * Operations for which an empty result set warrants a diagnostic. These read endpoints are userAuth + * gated, so an empty 200 commonly means the PAT lacks the required user level (e.g. an API-Management + * client with no user context) or that segmentation restricts visibility. + */ +const EMPTY_DIAGNOSTIC_OPERATIONS = new Set([ + 'sailpoint_search', + 'sailpoint_list_identities', + 'sailpoint_list_entitlements', + 'sailpoint_list_roles', +]) + +const EMPTY_RESULT_WARNING = + 'Zero rows returned - this can indicate the PAT lacks sufficient ISC user level (e.g. an API-Management client with no user context), or that Data Segmentation / Access Request Segments restrict visibility. Confirm the PAT is owned by a service identity with the required user level and scopes.' + +type ResultKind = 'list' | 'search' | 'item' | 'count' | 'write' + +function diagnose(operation: string, count: number): { complete: boolean; warnings: string[] } { + if (count === 0 && EMPTY_DIAGNOSTIC_OPERATIONS.has(operation)) { + return { complete: false, warnings: [EMPTY_RESULT_WARNING] } + } + return { complete: true, warnings: [] } +} + +/** Builds a `?a=b&c=d` query string, dropping undefined/null/empty values. */ +function qs(params: Record): string { + const usp = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue + usp.set(key, String(value)) + } + const serialized = usp.toString() + return serialized ? `?${serialized}` : '' +} + +/** Normalizes an array/JSON-string/comma-list into a string[] (or undefined when empty). */ +function toStringList(value: unknown): string[] | undefined { + if (value == null) return undefined + if (Array.isArray(value)) { + const arr = value.filter((v): v is string => typeof v === 'string' && v.length > 0) + return arr.length ? arr : undefined + } + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return undefined + if (trimmed.startsWith('[')) { + try { + const parsed: unknown = JSON.parse(trimmed) + if (Array.isArray(parsed)) { + const arr = parsed.filter((v): v is string => typeof v === 'string') + return arr.length ? arr : undefined + } + } catch { + // fall through to comma splitting + } + } + const parts = trimmed + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + return parts.length ? parts : undefined + } + return undefined +} + +function id(value: string): string { + return encodeURIComponent(value) +} + +function errorResponse(result: SailPointFetchResult): NextResponse { + return NextResponse.json( + { success: false, error: getSailPointErrorMessage(result.data, 'SailPoint request failed') }, + { status: result.status || 502 } + ) +} + +function buildListOutput( + result: SailPointFetchResult, + operation: string, + key: 'items' | 'results' +) { + const items = Array.isArray(result.data) ? result.data : [] + const { complete, warnings } = diagnose(operation, items.length) + const base = { + count: items.length, + totalCount: readTotalCount(result.headers), + complete, + warnings, + } + return key === 'results' ? { results: items, ...base } : { items, ...base } +} + +async function execute( + creds: SailPointServerCredentials, + operation: string, + buildRequest: (token: string, hosts: SailPointHosts) => { url: string; init: RequestInit }, + kind: ResultKind +): Promise { + const result = await sailpointFetch(creds, buildRequest) + if (!result.ok) return errorResponse(result) + + let output: Record + switch (kind) { + case 'list': + output = buildListOutput(result, operation, 'items') + break + case 'search': + output = buildListOutput(result, operation, 'results') + break + case 'item': + output = { item: result.data ?? null } + break + case 'count': + output = { + total: + readTotalCount(result.headers) ?? (typeof result.data === 'number' ? result.data : 0), + } + break + case 'write': + output = { accepted: result.ok, status: result.status } + break + } + + return NextResponse.json({ success: true, output }) +} + +function jsonInit(body: unknown): RequestInit { + return { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } +} + +function dispatch( + creds: SailPointServerCredentials, + body: SailpointQueryBody +): Promise { + switch (body.operation) { + case 'sailpoint_search': { + const searchBody = filterUndefined({ + indices: toStringList(body.indices) ?? ['identities'], + query: body.query ? { query: body.query } : undefined, + sort: toStringList(body.sort), + searchAfter: toStringList(body.searchAfter), + includeNested: body.includeNested, + }) + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/search${qs({ limit: body.limit, offset: body.offset, count: body.count })}`, + init: jsonInit(searchBody), + }), + 'search' + ) + } + case 'sailpoint_search_count': { + const searchBody = filterUndefined({ + indices: toStringList(body.indices) ?? ['identities'], + query: body.query ? { query: body.query } : undefined, + }) + return execute( + creds, + body.operation, + (_t, h) => ({ url: `${h.apiBaseUrl}/search/count`, init: jsonInit(searchBody) }), + 'count' + ) + } + case 'sailpoint_search_aggregate': { + const searchBody = filterUndefined({ + indices: toStringList(body.indices) ?? ['identities'], + query: body.query ? { query: body.query } : undefined, + }) + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/search/aggregate${qs({ limit: body.limit, offset: body.offset })}`, + init: jsonInit(searchBody), + }), + 'search' + ) + } + case 'sailpoint_list_identities': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/identities${qs({ filters: body.filters, sorters: body.sorters, defaultFilter: body.defaultFilter, limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + case 'sailpoint_get_identity': + return execute( + creds, + body.operation, + (_t, h) => ({ url: `${h.apiBaseUrl}/identities/${id(body.id)}`, init: { method: 'GET' } }), + 'item' + ) + case 'sailpoint_list_accounts': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/accounts${qs({ filters: body.filters, sorters: body.sorters, detailLevel: body.detailLevel, limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + case 'sailpoint_get_account': + return execute( + creds, + body.operation, + (_t, h) => ({ url: `${h.apiBaseUrl}/accounts/${id(body.id)}`, init: { method: 'GET' } }), + 'item' + ) + case 'sailpoint_get_account_entitlements': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/accounts/${id(body.id)}/entitlements${qs({ limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + case 'sailpoint_list_entitlements': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/entitlements${qs({ filters: body.filters, sorters: body.sorters, 'account-id': body.accountId, 'segmented-for-identity': body.segmentedForIdentity, limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + case 'sailpoint_get_entitlement': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/entitlements/${id(body.id)}`, + init: { method: 'GET' }, + }), + 'item' + ) + case 'sailpoint_list_roles': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/roles${qs({ filters: body.filters, sorters: body.sorters, limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + case 'sailpoint_get_role_entitlements': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/roles/${id(body.id)}/entitlements${qs({ filters: body.filters, sorters: body.sorters, limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + case 'sailpoint_list_access_profiles': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/access-profiles${qs({ filters: body.filters, sorters: body.sorters, limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + case 'sailpoint_get_access_profile_entitlements': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/access-profiles/${id(body.id)}/entitlements${qs({ filters: body.filters, sorters: body.sorters, limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + case 'sailpoint_list_sources': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/sources${qs({ filters: body.filters, sorters: body.sorters, 'for-subadmin': body.forSubadmin, includeIDNSource: body.includeIDNSource, limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + case 'sailpoint_get_source': + return execute( + creds, + body.operation, + (_t, h) => ({ url: `${h.apiBaseUrl}/sources/${id(body.id)}`, init: { method: 'GET' } }), + 'item' + ) + case 'sailpoint_list_account_activities': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/account-activities${qs({ 'requested-for': body.requestedFor, 'requested-by': body.requestedBy, 'regarding-identity': body.regardingIdentity, filters: body.filters, sorters: body.sorters, limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + case 'sailpoint_get_account_activity': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/account-activities/${id(body.id)}`, + init: { method: 'GET' }, + }), + 'item' + ) + case 'sailpoint_list_campaigns': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/campaigns${qs({ detail: body.detail, filters: body.filters, sorters: body.sorters, limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + case 'sailpoint_get_campaign': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/campaigns/${id(body.id)}${qs({ detail: body.detail })}`, + init: { method: 'GET' }, + }), + 'item' + ) + case 'sailpoint_list_certifications': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/certifications${qs({ 'reviewer-identity': body.reviewerIdentity, filters: body.filters, sorters: body.sorters, limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + case 'sailpoint_list_certification_review_items': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/certifications/${id(body.id)}/access-review-items${qs({ filters: body.filters, sorters: body.sorters, entitlements: body.entitlements, 'access-profiles': body.accessProfiles, roles: body.roles, limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + case 'sailpoint_request_access': { + const requestBody = filterUndefined({ + requestedFor: body.requestedFor, + requestedItems: body.requestedItems, + requestType: body.requestType, + clientMetadata: body.clientMetadata, + }) + return execute( + creds, + body.operation, + (_t, h) => ({ url: `${h.apiBaseUrl}/access-requests`, init: jsonInit(requestBody) }), + 'write' + ) + } + case 'sailpoint_cancel_access_request': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/access-requests/cancel`, + init: jsonInit({ accountActivityId: body.accountActivityId, comment: body.comment }), + }), + 'write' + ) + case 'sailpoint_get_access_request_status': + return execute( + creds, + body.operation, + (_t, h) => ({ + url: `${h.apiBaseUrl}/access-request-status${qs({ 'requested-for': body.requestedFor, 'requested-by': body.requestedBy, 'regarding-identity': body.regardingIdentity, 'assigned-to': body.assignedTo, 'request-state': body.requestState, filters: body.filters, sorters: body.sorters, limit: body.limit, offset: body.offset, count: body.count })}`, + init: { method: 'GET' }, + }), + 'list' + ) + } +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + + try { + const parsed = await parseRequest( + sailpointQueryContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid SailPoint request'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + + const body = parsed.data.body + const creds: SailPointServerCredentials = { + clientId: body.clientId, + clientSecret: body.clientSecret, + tenant: body.tenant, + apiVersion: normalizeApiVersion(body.apiVersion), + } + + logger.info(`[${requestId}] SailPoint request`, { + operation: body.operation, + apiVersion: creds.apiVersion, + }) + + return await dispatch(creds, body) + } catch (error) { + const message = toError(error).message + logger.error(`[${requestId}] SailPoint request failed`, { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +}) diff --git a/apps/sim/blocks/blocks/sailpoint.ts b/apps/sim/blocks/blocks/sailpoint.ts new file mode 100644 index 00000000000..abe25f40e04 --- /dev/null +++ b/apps/sim/blocks/blocks/sailpoint.ts @@ -0,0 +1,885 @@ +import { SailPointIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { + normalizeFileInput, + parseOptionalJsonInput, + parseOptionalNumberInput, +} from '@/blocks/utils' +import type { SailPointListResponse } from '@/tools/sailpoint/types' + +/** Single-entity operations that take a resource `id`. */ +const ID_OPERATIONS = [ + 'sailpoint_get_identity', + 'sailpoint_get_account', + 'sailpoint_get_account_entitlements', + 'sailpoint_get_entitlement', + 'sailpoint_get_role_entitlements', + 'sailpoint_get_access_profile_entitlements', + 'sailpoint_get_source', + 'sailpoint_get_account_activity', + 'sailpoint_get_campaign', + 'sailpoint_list_certification_review_items', +] + +const SEARCH_OPERATIONS = [ + 'sailpoint_search', + 'sailpoint_search_count', + 'sailpoint_search_aggregate', +] + +/** List operations that accept `filters` and `sorters`. */ +const FILTER_OPERATIONS = [ + 'sailpoint_list_identities', + 'sailpoint_list_accounts', + 'sailpoint_list_entitlements', + 'sailpoint_list_roles', + 'sailpoint_list_access_profiles', + 'sailpoint_get_role_entitlements', + 'sailpoint_get_access_profile_entitlements', + 'sailpoint_list_sources', + 'sailpoint_list_account_activities', + 'sailpoint_list_campaigns', + 'sailpoint_list_certifications', + 'sailpoint_list_certification_review_items', + 'sailpoint_get_access_request_status', +] + +/** Operations that accept `limit`/`offset` pagination. */ +const LIMIT_OPERATIONS = [ + ...FILTER_OPERATIONS, + 'sailpoint_search', + 'sailpoint_search_aggregate', + 'sailpoint_get_account_entitlements', +] + +/** Operations that scope by an identity (`requested-for` / `requested-by` / `regarding-identity`). */ +const IDENTITY_SCOPE_OPERATIONS = [ + 'sailpoint_list_account_activities', + 'sailpoint_get_access_request_status', +] + +export const SailPointBlock: BlockConfig = { + type: 'sailpoint', + name: 'SailPoint', + description: 'Govern identities and access in SailPoint Identity Security Cloud', + longDescription: + 'Read and act on identity governance data in SailPoint Identity Security Cloud (ISC): search identities, accounts, entitlements, roles, and access profiles; review account activities, campaigns, and certifications; and request, revoke, or cancel access. Authenticates with a Personal Access Token (PAT) using the OAuth2 client-credentials grant against your per-tenant host (https://{tenant}.api.identitynow.com). ' + + "IMPORTANT: generate the PAT from a dedicated ISC service *identity* (a real user with the required user level), NOT an API-Management client - identity, role, access-profile, and access-request endpoints are user-context only, and a client without user context returns empty result sets instead of an error. A PAT's effective rights are the intersection of its selected scopes AND the owner's ISC user level. Select these scopes when generating the PAT: sp:search:read (search), idn:identity:read (identities), idn:accounts:read (accounts), idn:entitlement:read (entitlements), idn:role-unchecked:read (roles), idn:access-profile:read (access profiles), idn:sources:read (sources), idn:access-request:manage or idn:access-request-self:manage (request/cancel access), idn:access-request-status:read (request status), idn:campaign:read (campaigns and certifications). Account activities are user-context gated and need no dedicated scope; sp:scopes:all covers everything for a pilot. Revoking access for anyone who is not a direct report requires ORG_ADMIN, and self-revoke is not permitted.", + docsLink: 'https://docs.sim.ai/integrations/sailpoint', + category: 'tools', + integrationType: IntegrationType.Security, + bgColor: '#0033A1', + icon: SailPointIcon, + authMode: AuthMode.ApiKey, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Search', id: 'sailpoint_search' }, + { label: 'Search Count', id: 'sailpoint_search_count' }, + { label: 'Search Aggregate', id: 'sailpoint_search_aggregate' }, + { label: 'List Identities', id: 'sailpoint_list_identities' }, + { label: 'Get Identity', id: 'sailpoint_get_identity' }, + { label: 'List Accounts', id: 'sailpoint_list_accounts' }, + { label: 'Get Account', id: 'sailpoint_get_account' }, + { label: 'Get Account Entitlements', id: 'sailpoint_get_account_entitlements' }, + { label: 'List Entitlements', id: 'sailpoint_list_entitlements' }, + { label: 'Get Entitlement', id: 'sailpoint_get_entitlement' }, + { label: 'List Roles', id: 'sailpoint_list_roles' }, + { label: 'Get Role Entitlements', id: 'sailpoint_get_role_entitlements' }, + { label: 'List Access Profiles', id: 'sailpoint_list_access_profiles' }, + { + label: 'Get Access Profile Entitlements', + id: 'sailpoint_get_access_profile_entitlements', + }, + { label: 'List Sources', id: 'sailpoint_list_sources' }, + { label: 'Get Source', id: 'sailpoint_get_source' }, + { label: 'List Account Activities', id: 'sailpoint_list_account_activities' }, + { label: 'Get Account Activity', id: 'sailpoint_get_account_activity' }, + { label: 'List Campaigns', id: 'sailpoint_list_campaigns' }, + { label: 'Get Campaign', id: 'sailpoint_get_campaign' }, + { label: 'List Certifications', id: 'sailpoint_list_certifications' }, + { + label: 'List Certification Review Items', + id: 'sailpoint_list_certification_review_items', + }, + { label: 'Request Access', id: 'sailpoint_request_access' }, + { label: 'Cancel Access Request', id: 'sailpoint_cancel_access_request' }, + { label: 'Get Access Request Status', id: 'sailpoint_get_access_request_status' }, + { label: 'Load Accounts (CSV)', id: 'sailpoint_load_accounts' }, + { label: 'Load Entitlements (CSV)', id: 'sailpoint_load_entitlements' }, + ], + value: () => 'sailpoint_search', + required: true, + }, + { + id: 'tenant', + title: 'Tenant', + type: 'short-input', + placeholder: 'acme (subdomain of api.identitynow.com)', + required: true, + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'PAT client ID', + required: true, + }, + { + id: 'clientSecret', + title: 'Client Secret', + type: 'short-input', + password: true, + placeholder: 'PAT client secret', + required: true, + }, + { + id: 'apiVersion', + title: 'API Version', + type: 'dropdown', + options: [ + { label: 'v2025 (default)', id: 'v2025' }, + { label: 'v2024', id: 'v2024' }, + { label: 'v3', id: 'v3' }, + ], + value: () => 'v2025', + mode: 'advanced', + }, + { + id: 'id', + title: 'ID', + type: 'short-input', + placeholder: 'Resource ID', + condition: { field: 'operation', value: ID_OPERATIONS }, + required: { field: 'operation', value: ID_OPERATIONS }, + }, + { + id: 'indices', + title: 'Indices', + type: 'short-input', + placeholder: 'identities (comma-separated or JSON array)', + condition: { field: 'operation', value: SEARCH_OPERATIONS }, + }, + { + id: 'query', + title: 'Query', + type: 'short-input', + placeholder: 'attributes.department:Engineering', + condition: { field: 'operation', value: SEARCH_OPERATIONS }, + wandConfig: { + enabled: true, + prompt: + 'Generate a SailPoint Identity Security Cloud search query string using Elasticsearch query-string syntax (field:value, AND/OR, wildcards). Return ONLY the query string - no explanations.', + placeholder: + 'Describe what to search for, e.g. "active identities in the Finance department"...', + }, + }, + { + id: 'includeNested', + title: 'Include Nested Objects', + type: 'dropdown', + options: [ + { label: 'Yes (default)', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + condition: { field: 'operation', value: 'sailpoint_search' }, + mode: 'advanced', + }, + { + id: 'sort', + title: 'Sort', + type: 'short-input', + placeholder: 'displayName,+id', + condition: { field: 'operation', value: 'sailpoint_search' }, + mode: 'advanced', + }, + { + id: 'filters', + title: 'Filters', + type: 'short-input', + placeholder: 'name sw "A" and cloudStatus eq "ACTIVE"', + condition: { field: 'operation', value: FILTER_OPERATIONS }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a SailPoint ISC V3 filter expression (e.g. name sw "A", cloudStatus eq "ACTIVE", and/or). Use documented filterable fields and operators. Return ONLY the filter string.', + placeholder: + 'Describe the records to filter, e.g. "identities whose email ends with @acme.com"...', + }, + }, + { + id: 'sorters', + title: 'Sorters', + type: 'short-input', + placeholder: 'name,-created', + condition: { field: 'operation', value: FILTER_OPERATIONS }, + mode: 'advanced', + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '250', + condition: { field: 'operation', value: LIMIT_OPERATIONS }, + mode: 'advanced', + }, + { + id: 'offset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: LIMIT_OPERATIONS }, + mode: 'advanced', + }, + { + id: 'defaultFilter', + title: 'Default Filter', + type: 'dropdown', + options: [ + { label: 'Correlated only (default)', id: '' }, + { label: 'All identities', id: 'NONE' }, + ], + value: () => '', + condition: { field: 'operation', value: 'sailpoint_list_identities' }, + mode: 'advanced', + }, + { + id: 'detailLevel', + title: 'Detail Level', + type: 'dropdown', + options: [ + { label: 'Full (default)', id: '' }, + { label: 'Slim', id: 'SLIM' }, + ], + value: () => '', + condition: { field: 'operation', value: 'sailpoint_list_accounts' }, + mode: 'advanced', + }, + { + id: 'detail', + title: 'Detail', + type: 'dropdown', + options: [ + { label: 'Slim (default)', id: '' }, + { label: 'Full', id: 'FULL' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['sailpoint_list_campaigns', 'sailpoint_get_campaign'], + }, + mode: 'advanced', + }, + { + id: 'accountId', + title: 'Account ID', + type: 'short-input', + placeholder: 'Filter entitlements to a specific account', + condition: { field: 'operation', value: 'sailpoint_list_entitlements' }, + mode: 'advanced', + }, + { + id: 'segmentedForIdentity', + title: 'Segmented For Identity', + type: 'short-input', + placeholder: 'Identity ID to apply entitlement segmentation for', + condition: { field: 'operation', value: 'sailpoint_list_entitlements' }, + mode: 'advanced', + }, + { + id: 'forSubadmin', + title: 'For Subadmin', + type: 'short-input', + placeholder: 'Subadmin identity ID', + condition: { field: 'operation', value: 'sailpoint_list_sources' }, + mode: 'advanced', + }, + { + id: 'includeIDNSource', + title: 'Include IDN Source', + type: 'dropdown', + options: [ + { label: 'No (default)', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'sailpoint_list_sources' }, + mode: 'advanced', + }, + { + id: 'requestedForFilter', + title: 'Requested For', + type: 'short-input', + placeholder: 'Identity ID or "me"', + condition: { field: 'operation', value: IDENTITY_SCOPE_OPERATIONS }, + }, + { + id: 'requestedBy', + title: 'Requested By', + type: 'short-input', + placeholder: 'Identity ID or "me"', + condition: { field: 'operation', value: IDENTITY_SCOPE_OPERATIONS }, + mode: 'advanced', + }, + { + id: 'regardingIdentity', + title: 'Regarding Identity', + type: 'short-input', + placeholder: 'Identity ID (requester or target)', + condition: { field: 'operation', value: IDENTITY_SCOPE_OPERATIONS }, + mode: 'advanced', + }, + { + id: 'assignedTo', + title: 'Assigned To', + type: 'short-input', + placeholder: 'Work item owner identity ID', + condition: { field: 'operation', value: 'sailpoint_get_access_request_status' }, + mode: 'advanced', + }, + { + id: 'requestState', + title: 'Request State', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + { label: 'Executing', id: 'EXECUTING' }, + ], + value: () => '', + condition: { field: 'operation', value: 'sailpoint_get_access_request_status' }, + mode: 'advanced', + }, + { + id: 'reviewerIdentity', + title: 'Reviewer Identity', + type: 'short-input', + placeholder: 'Reviewer identity ID or "me"', + condition: { field: 'operation', value: 'sailpoint_list_certifications' }, + }, + { + id: 'entitlements', + title: 'Entitlements Filter', + type: 'short-input', + placeholder: 'true / false', + condition: { field: 'operation', value: 'sailpoint_list_certification_review_items' }, + mode: 'advanced', + }, + { + id: 'accessProfiles', + title: 'Access Profiles Filter', + type: 'short-input', + placeholder: 'true / false', + condition: { field: 'operation', value: 'sailpoint_list_certification_review_items' }, + mode: 'advanced', + }, + { + id: 'roles', + title: 'Roles Filter', + type: 'short-input', + placeholder: 'true / false', + condition: { field: 'operation', value: 'sailpoint_list_certification_review_items' }, + mode: 'advanced', + }, + { + id: 'requestedIdentities', + title: 'Requested For (Identities)', + type: 'code', + language: 'json', + placeholder: '["2c9180857c1a...","2c9180857c1b..."]', + condition: { field: 'operation', value: 'sailpoint_request_access' }, + required: { field: 'operation', value: 'sailpoint_request_access' }, + }, + { + id: 'requestedItems', + title: 'Requested Items', + type: 'code', + language: 'json', + placeholder: '[{ "type": "ENTITLEMENT", "id": "2c918...", "comment": "New hire" }]', + condition: { field: 'operation', value: 'sailpoint_request_access' }, + required: { field: 'operation', value: 'sailpoint_request_access' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a SailPoint access-request requestedItems JSON array. Each item is { type: ACCESS_PROFILE|ROLE|ENTITLEMENT, id, comment?, removeDate?, startDate?, assignmentId?, nativeIdentity?, clientMetadata? }. For REVOKE_ACCESS exactly one item with a comment is allowed. Return ONLY valid JSON.', + placeholder: 'Describe the access to request or revoke...', + generationType: 'json-object', + }, + }, + { + id: 'requestType', + title: 'Request Type', + type: 'dropdown', + options: [ + { label: 'Grant Access (default)', id: 'GRANT_ACCESS' }, + { label: 'Revoke Access', id: 'REVOKE_ACCESS' }, + { label: 'Modify Access', id: 'MODIFY_ACCESS' }, + ], + value: () => 'GRANT_ACCESS', + condition: { field: 'operation', value: 'sailpoint_request_access' }, + }, + { + id: 'clientMetadata', + title: 'Client Metadata', + type: 'code', + language: 'json', + placeholder: '{ "requestedByEmail": "manager@acme.com" }', + condition: { field: 'operation', value: 'sailpoint_request_access' }, + mode: 'advanced', + }, + { + id: 'accountActivityId', + title: 'Account Activity ID', + type: 'short-input', + placeholder: 'identityRequestId of the request to cancel', + condition: { field: 'operation', value: 'sailpoint_cancel_access_request' }, + required: { field: 'operation', value: 'sailpoint_cancel_access_request' }, + }, + { + id: 'comment', + title: 'Comment', + type: 'long-input', + placeholder: 'Reason for cancellation', + condition: { field: 'operation', value: 'sailpoint_cancel_access_request' }, + required: { field: 'operation', value: 'sailpoint_cancel_access_request' }, + }, + { + id: 'sourceId', + title: 'Source ID', + type: 'short-input', + placeholder: 'Source ID to aggregate', + condition: { + field: 'operation', + value: ['sailpoint_load_accounts', 'sailpoint_load_entitlements'], + }, + required: { + field: 'operation', + value: ['sailpoint_load_accounts', 'sailpoint_load_entitlements'], + }, + }, + { + id: 'accountsFileUpload', + title: 'Accounts CSV', + type: 'file-upload', + canonicalParamId: 'accountsCsv', + placeholder: 'Upload the accounts CSV to aggregate', + condition: { field: 'operation', value: 'sailpoint_load_accounts' }, + mode: 'basic', + multiple: false, + required: false, + }, + { + id: 'accountsFileRef', + title: 'Accounts CSV', + type: 'short-input', + canonicalParamId: 'accountsCsv', + placeholder: 'Reference a file from a previous block', + condition: { field: 'operation', value: 'sailpoint_load_accounts' }, + mode: 'advanced', + required: false, + }, + { + id: 'disableOptimization', + title: 'Disable Optimization', + type: 'dropdown', + options: [ + { label: 'No (default)', id: 'false' }, + { label: 'Yes - reprocess every account', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'sailpoint_load_accounts' }, + mode: 'advanced', + }, + { + id: 'entitlementsFileUpload', + title: 'Entitlements CSV', + type: 'file-upload', + canonicalParamId: 'entitlementsCsv', + placeholder: 'Upload the entitlements CSV to aggregate', + condition: { field: 'operation', value: 'sailpoint_load_entitlements' }, + mode: 'basic', + multiple: false, + required: false, + }, + { + id: 'entitlementsFileRef', + title: 'Entitlements CSV', + type: 'short-input', + canonicalParamId: 'entitlementsCsv', + placeholder: 'Reference a file from a previous block', + condition: { field: 'operation', value: 'sailpoint_load_entitlements' }, + mode: 'advanced', + required: false, + }, + ], + + tools: { + access: [ + 'sailpoint_cancel_access_request', + 'sailpoint_get_access_profile_entitlements', + 'sailpoint_get_access_request_status', + 'sailpoint_get_account', + 'sailpoint_get_account_activity', + 'sailpoint_get_account_entitlements', + 'sailpoint_get_campaign', + 'sailpoint_get_entitlement', + 'sailpoint_get_identity', + 'sailpoint_get_role_entitlements', + 'sailpoint_get_source', + 'sailpoint_list_access_profiles', + 'sailpoint_list_account_activities', + 'sailpoint_list_accounts', + 'sailpoint_list_campaigns', + 'sailpoint_list_certification_review_items', + 'sailpoint_list_certifications', + 'sailpoint_list_entitlements', + 'sailpoint_list_identities', + 'sailpoint_list_roles', + 'sailpoint_list_sources', + 'sailpoint_load_accounts', + 'sailpoint_load_entitlements', + 'sailpoint_request_access', + 'sailpoint_search', + 'sailpoint_search_aggregate', + 'sailpoint_search_count', + ], + config: { + tool: (params) => + typeof params.operation === 'string' ? params.operation : 'sailpoint_search', + params: (params) => { + const mapped: Record = { + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + } + if (params.apiVersion) mapped.apiVersion = params.apiVersion + + const setStr = (key: string, value: unknown) => { + if (typeof value === 'string') { + const trimmed = value.trim() + if (trimmed) mapped[key] = trimmed + } else if (value !== undefined && value !== null) { + mapped[key] = value + } + } + const setNum = (key: string, value: unknown) => { + const parsed = parseOptionalNumberInput(value, key, { integer: true, min: 0 }) + if (parsed != null) mapped[key] = parsed + } + const applyPagination = () => { + setNum('limit', params.limit) + setNum('offset', params.offset) + } + const applyFilters = () => { + setStr('filters', params.filters) + setStr('sorters', params.sorters) + } + + switch (params.operation) { + case 'sailpoint_search': + setStr('indices', params.indices) + setStr('query', params.query) + setStr('sort', params.sort) + if (params.includeNested === 'false' || params.includeNested === false) { + mapped.includeNested = false + } + applyPagination() + break + case 'sailpoint_search_count': + setStr('indices', params.indices) + setStr('query', params.query) + break + case 'sailpoint_search_aggregate': + setStr('indices', params.indices) + setStr('query', params.query) + applyPagination() + break + case 'sailpoint_list_identities': + applyFilters() + setStr('defaultFilter', params.defaultFilter) + applyPagination() + break + case 'sailpoint_list_accounts': + applyFilters() + setStr('detailLevel', params.detailLevel) + applyPagination() + break + case 'sailpoint_list_entitlements': + applyFilters() + setStr('accountId', params.accountId) + setStr('segmentedForIdentity', params.segmentedForIdentity) + applyPagination() + break + case 'sailpoint_list_roles': + case 'sailpoint_list_access_profiles': + applyFilters() + applyPagination() + break + case 'sailpoint_get_role_entitlements': + case 'sailpoint_get_access_profile_entitlements': + setStr('id', params.id) + applyFilters() + applyPagination() + break + case 'sailpoint_get_account_entitlements': + setStr('id', params.id) + applyPagination() + break + case 'sailpoint_list_sources': + applyFilters() + setStr('forSubadmin', params.forSubadmin) + if (params.includeIDNSource === 'true' || params.includeIDNSource === true) { + mapped.includeIDNSource = true + } + applyPagination() + break + case 'sailpoint_list_account_activities': + setStr('requestedFor', params.requestedForFilter) + setStr('requestedBy', params.requestedBy) + setStr('regardingIdentity', params.regardingIdentity) + applyFilters() + applyPagination() + break + case 'sailpoint_list_campaigns': + setStr('detail', params.detail) + applyFilters() + applyPagination() + break + case 'sailpoint_get_campaign': + setStr('id', params.id) + setStr('detail', params.detail) + break + case 'sailpoint_list_certifications': + setStr('reviewerIdentity', params.reviewerIdentity) + applyFilters() + applyPagination() + break + case 'sailpoint_list_certification_review_items': + setStr('id', params.id) + applyFilters() + setStr('entitlements', params.entitlements) + setStr('accessProfiles', params.accessProfiles) + setStr('roles', params.roles) + applyPagination() + break + case 'sailpoint_get_identity': + case 'sailpoint_get_account': + case 'sailpoint_get_entitlement': + case 'sailpoint_get_source': + case 'sailpoint_get_account_activity': + setStr('id', params.id) + break + case 'sailpoint_get_access_request_status': + setStr('requestedFor', params.requestedForFilter) + setStr('requestedBy', params.requestedBy) + setStr('regardingIdentity', params.regardingIdentity) + setStr('assignedTo', params.assignedTo) + setStr('requestState', params.requestState) + applyFilters() + applyPagination() + break + case 'sailpoint_request_access': { + const requestedFor = parseOptionalJsonInput(params.requestedIdentities, 'requestedFor') + if (requestedFor !== undefined) mapped.requestedFor = requestedFor + const requestedItems = parseOptionalJsonInput(params.requestedItems, 'requestedItems') + if (requestedItems !== undefined) mapped.requestedItems = requestedItems + setStr('requestType', params.requestType) + const clientMetadata = parseOptionalJsonInput(params.clientMetadata, 'clientMetadata') + if (clientMetadata !== undefined) mapped.clientMetadata = clientMetadata + break + } + case 'sailpoint_cancel_access_request': + setStr('accountActivityId', params.accountActivityId) + setStr('comment', params.comment) + break + case 'sailpoint_load_accounts': { + setStr('sourceId', params.sourceId) + const file = normalizeFileInput(params.accountsCsv, { single: true }) + if (file) mapped.file = file + if (params.disableOptimization === 'true' || params.disableOptimization === true) { + mapped.disableOptimization = true + } + break + } + case 'sailpoint_load_entitlements': { + setStr('sourceId', params.sourceId) + const file = normalizeFileInput(params.entitlementsCsv, { single: true }) + if (file) mapped.file = file + break + } + } + + return mapped + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Selected SailPoint operation' }, + tenant: { type: 'string', description: 'SailPoint tenant subdomain' }, + clientId: { type: 'string', description: 'PAT client ID' }, + clientSecret: { type: 'string', description: 'PAT client secret' }, + apiVersion: { type: 'string', description: 'API version path segment (v2025, v2024, v3)' }, + id: { type: 'string', description: 'Resource ID for single-entity operations' }, + indices: { type: 'string', description: 'Search indices (comma-separated or JSON array)' }, + query: { type: 'string', description: 'Elasticsearch query string' }, + includeNested: { type: 'string', description: 'Include nested objects in search results' }, + sort: { type: 'string', description: 'Search sort fields' }, + filters: { type: 'string', description: 'V3 filter expression' }, + sorters: { type: 'string', description: 'Sort expression' }, + limit: { type: 'number', description: 'Maximum records to return' }, + offset: { type: 'number', description: 'Pagination offset' }, + defaultFilter: { + type: 'string', + description: 'Identity default filter (CORRELATED_ONLY or NONE)', + }, + detailLevel: { type: 'string', description: 'Account detail level (SLIM or FULL)' }, + detail: { type: 'string', description: 'Campaign detail level (SLIM or FULL)' }, + accountId: { type: 'string', description: 'Account ID to filter entitlements' }, + segmentedForIdentity: { + type: 'string', + description: 'Identity ID for entitlement segmentation', + }, + forSubadmin: { type: 'string', description: 'Subadmin identity ID for source scoping' }, + includeIDNSource: { type: 'string', description: 'Include the IdentityNow source in results' }, + requestedForFilter: { type: 'string', description: 'Identity to scope activities/status by' }, + requestedBy: { type: 'string', description: 'Requester identity to scope by' }, + regardingIdentity: { type: 'string', description: 'Requester or target identity to scope by' }, + assignedTo: { type: 'string', description: 'Work item owner identity ID' }, + requestState: { type: 'string', description: 'Access request state filter (EXECUTING)' }, + reviewerIdentity: { type: 'string', description: 'Reviewer identity for certifications' }, + entitlements: { type: 'string', description: 'Certification review item entitlements filter' }, + accessProfiles: { + type: 'string', + description: 'Certification review item access-profiles filter', + }, + roles: { type: 'string', description: 'Certification review item roles filter' }, + requestedIdentities: { type: 'json', description: 'Identity IDs the access is requested for' }, + requestedItems: { type: 'json', description: 'Access items to request or revoke' }, + requestType: { type: 'string', description: 'GRANT_ACCESS, REVOKE_ACCESS, or MODIFY_ACCESS' }, + clientMetadata: { type: 'json', description: 'Arbitrary key/value metadata for correlation' }, + accountActivityId: { type: 'string', description: 'identityRequestId to cancel' }, + comment: { type: 'string', description: 'Reason for cancellation' }, + sourceId: { type: 'string', description: 'Source ID for aggregation' }, + accountsCsv: { type: 'json', description: 'Accounts CSV file to aggregate' }, + entitlementsCsv: { type: 'json', description: 'Entitlements CSV file to aggregate' }, + disableOptimization: { + type: 'string', + description: 'Reprocess every account during aggregation', + }, + }, + + outputs: { + items: { type: 'json', description: 'Raw SailPoint documents for list operations' }, + results: { type: 'json', description: 'Raw SailPoint documents for search operations' }, + item: { type: 'json', description: 'Raw SailPoint document for get operations' }, + total: { type: 'number', description: 'Total matching documents (search count)' }, + task: { type: 'json', description: 'Aggregation task for load operations' }, + accepted: { type: 'boolean', description: 'Whether an access-request write was accepted' }, + status: { type: 'number', description: 'HTTP status returned by SailPoint for writes' }, + count: { type: 'number', description: 'Number of records returned in the page' }, + totalCount: { type: 'number', description: 'Total matching records when count is requested' }, + complete: { + type: 'boolean', + description: 'False when an empty result may indicate a permission gap', + }, + warnings: { type: 'json', description: 'Diagnostic warnings (e.g. empty-result guidance)' }, + }, +} + +export const SailPointBlockMeta = { + tags: ['identity', 'operations'], + url: 'https://www.sailpoint.com', + templates: [ + { + icon: SailPointIcon, + title: 'SailPoint joiner access review', + prompt: + 'Create a scheduled workflow that lists recent SailPoint account activities for joiners, summarizes their granted access and time-to-access, and posts a digest to Slack for the identity team.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['enterprise', 'reporting'], + alsoIntegrations: ['slack'], + }, + { + icon: SailPointIcon, + title: 'SailPoint access request bot', + prompt: + 'Build a workflow where a user describes the access they need in Chat, the agent searches SailPoint entitlements and access profiles, and submits a SailPoint access request on their behalf with a correlation note in client metadata.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['automation', 'self-service'], + alsoIntegrations: ['slack'], + }, + { + icon: SailPointIcon, + title: 'SailPoint orphan account finder', + prompt: + 'Create a scheduled workflow that searches SailPoint accounts that are uncorrelated to any identity, writes the orphan list to a table, and opens a review task for the source owners.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['enterprise', 'analysis'], + }, + { + icon: SailPointIcon, + title: 'SailPoint certification progress digest', + prompt: + 'Build a scheduled workflow that lists active SailPoint campaigns and certifications, computes completion percentages, and emails a progress digest to certification owners.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['enterprise', 'reporting'], + alsoIntegrations: ['gmail'], + }, + { + icon: SailPointIcon, + title: 'SailPoint leaver access revocation', + prompt: + 'Create a workflow that, given a departing employee, searches their SailPoint identity access, and submits revoke access requests for each directly-assigned entitlement with a comment referencing the offboarding ticket.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['automation', 'security'], + alsoIntegrations: ['jira'], + }, + { + icon: SailPointIcon, + title: 'SailPoint entitlement catalog export', + prompt: + 'Build a scheduled workflow that lists SailPoint entitlements and their owning sources, and writes the catalog to a table for access-governance reporting.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['enterprise', 'reporting'], + }, + { + icon: SailPointIcon, + title: 'SailPoint privileged access watch', + prompt: + 'Create a scheduled workflow that searches SailPoint identities holding privileged roles, cross-references recent account activities, and flags any new privileged grants to a security review channel.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['enterprise', 'security'], + alsoIntegrations: ['slack'], + }, + ], + skills: [ + { + name: 'review-identity-access', + description: + 'Search a SailPoint identity and summarize its entitlements, roles, and access profiles for an access review.', + content: + '# Review SailPoint Identity Access\n\nBuild a complete picture of what an identity can access.\n\n## Steps\n1. Search identities (with nested objects) to find the target identity and its access array.\n2. Expand roles and access profiles into their underlying entitlements.\n3. Note directly-assigned versus role- or birthright-granted access (only directly-assigned access can be revoked via access request).\n\n## Output\nA per-identity access summary highlighting privileged or unusual grants for reviewer attention.', + }, + { + name: 'request-and-track-access', + description: + 'Submit a SailPoint access request and track it to completion via account activities and request status.', + content: + '# Request and Track SailPoint Access\n\nDrive an access request from submission to fulfillment.\n\n## Steps\n1. Search entitlements, roles, or access profiles to resolve the exact item IDs.\n2. Submit an access request (GRANT_ACCESS) for the identities, adding a correlation note in client metadata.\n3. Poll access request status and account activities until the request completes, cancelling if needed.\n\n## Output\nA confirmation of the submitted request plus its current fulfillment status.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 70f443bec70..edc68bc94c8 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -261,6 +261,7 @@ import { RootlyBlock, RootlyBlockMeta } from '@/blocks/blocks/rootly' import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router' import { RssBlock, RssBlockMeta } from '@/blocks/blocks/rss' import { S3Block, S3BlockMeta } from '@/blocks/blocks/s3' +import { SailPointBlock, SailPointBlockMeta } from '@/blocks/blocks/sailpoint' import { SalesforceBlock, SalesforceBlockMeta } from '@/blocks/blocks/salesforce' import { SapConcurBlock, SapConcurBlockMeta } from '@/blocks/blocks/sap_concur' import { SapS4HanaBlock, SapS4HanaBlockMeta } from '@/blocks/blocks/sap_s4hana' @@ -577,6 +578,7 @@ export const BLOCK_REGISTRY: Record = { router_v2: RouterV2Block, rss: RssBlock, s3: S3Block, + sailpoint: SailPointBlock, salesforce: SalesforceBlock, sap_concur: SapConcurBlock, sap_s4hana: SapS4HanaBlock, @@ -865,6 +867,7 @@ export const BLOCK_META_REGISTRY: Record = { rootly: RootlyBlockMeta, rss: RssBlockMeta, s3: S3BlockMeta, + sailpoint: SailPointBlockMeta, salesforce: SalesforceBlockMeta, sap_concur: SapConcurBlockMeta, sap_s4hana: SapS4HanaBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index efedba2000d..b541e3328d9 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -5166,6 +5166,18 @@ export function PipedriveIcon(props: SVGProps) { ) } +export function SailPointIcon(props: SVGProps) { + return ( + + + + + ) +} + export function SalesforceIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/api/contracts/tools/sailpoint.ts b/apps/sim/lib/api/contracts/tools/sailpoint.ts new file mode 100644 index 00000000000..0501c5ab84b --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/sailpoint.ts @@ -0,0 +1,471 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' + +/** Credential + version fields shared by every SailPoint operation. */ +const sailpointBaseFields = { + clientId: z.string().min(1, 'Client ID is required'), + clientSecret: z.string().min(1, 'Client Secret is required'), + tenant: z.string().min(1, 'Tenant is required'), + apiVersion: z.enum(['v2025', 'v2024', 'v3']).optional(), +} + +const filtersField = z.string().optional() +const sortersField = z.string().optional() +const offsetField = z.coerce.number().int().min(0, 'Offset must be 0 or greater').optional() +const countField = z.boolean().optional() + +const limitField = (max: number) => + z.coerce + .number() + .int() + .min(0, 'Limit must be 0 or greater') + .max(max, `Limit must be at most ${max}`) + .optional() + +/** Standard limit/offset/count trio with a per-endpoint limit cap. */ +const pagination = (limitMax: number) => ({ + limit: limitField(limitMax), + offset: offsetField, + count: countField, +}) + +const idField = (label: string) => z.string().min(1, `${label} is required`) + +/** Accepts an array of strings or a single string (route normalizes). */ +const stringListField = z.union([z.array(z.string()), z.string()]).optional() + +/** Parses a JSON string into a value before applying the inner schema. */ +function parseJson(value: unknown): unknown { + if (typeof value === 'string') { + try { + return JSON.parse(value) + } catch { + return value + } + } + return value +} + +const requestedForSchema = z.preprocess(parseJson, z.array(z.string())) + +const requestedItemSchema = z.object({ + type: z.enum(['ACCESS_PROFILE', 'ROLE', 'ENTITLEMENT']), + id: z.string().min(1, 'requestedItems[].id is required'), + comment: z.string().optional(), + removeDate: z.string().optional(), + startDate: z.string().optional(), + assignmentId: z.string().optional(), + nativeIdentity: z.string().optional(), + clientMetadata: z.record(z.string(), z.string()).optional(), +}) + +const requestedItemsSchema = z.preprocess(parseJson, z.array(requestedItemSchema)) +const clientMetadataSchema = z.preprocess(parseJson, z.record(z.string(), z.string())).optional() + +const LIMIT_STANDARD = 250 +const LIMIT_SEARCH = 10000 +const LIMIT_ROLES = 50 + +const searchSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_search'), + indices: stringListField, + query: z.string().optional(), + sort: stringListField, + searchAfter: stringListField, + includeNested: z.boolean().optional(), + ...pagination(LIMIT_SEARCH), +}) + +const searchCountSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_search_count'), + indices: stringListField, + query: z.string().optional(), +}) + +const searchAggregateSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_search_aggregate'), + indices: stringListField, + query: z.string().optional(), + limit: limitField(LIMIT_STANDARD), + offset: offsetField, +}) + +const listIdentitiesSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_list_identities'), + filters: filtersField, + sorters: sortersField, + defaultFilter: z.enum(['CORRELATED_ONLY', 'NONE']).optional(), + ...pagination(LIMIT_STANDARD), +}) + +const getIdentitySchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_get_identity'), + id: idField('Identity ID'), +}) + +const listAccountsSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_list_accounts'), + filters: filtersField, + sorters: sortersField, + detailLevel: z.enum(['SLIM', 'FULL']).optional(), + ...pagination(LIMIT_STANDARD), +}) + +const getAccountSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_get_account'), + id: idField('Account ID'), +}) + +const getAccountEntitlementsSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_get_account_entitlements'), + id: idField('Account ID'), + ...pagination(LIMIT_STANDARD), +}) + +const listEntitlementsSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_list_entitlements'), + filters: filtersField, + sorters: sortersField, + accountId: z.string().optional(), + segmentedForIdentity: z.string().optional(), + ...pagination(LIMIT_STANDARD), +}) + +const getEntitlementSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_get_entitlement'), + id: idField('Entitlement ID'), +}) + +const listRolesSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_list_roles'), + filters: filtersField, + sorters: sortersField, + ...pagination(LIMIT_ROLES), +}) + +const getRoleEntitlementsSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_get_role_entitlements'), + id: idField('Role ID'), + filters: filtersField, + sorters: sortersField, + ...pagination(LIMIT_ROLES), +}) + +const listAccessProfilesSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_list_access_profiles'), + filters: filtersField, + sorters: sortersField, + ...pagination(LIMIT_STANDARD), +}) + +const getAccessProfileEntitlementsSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_get_access_profile_entitlements'), + id: idField('Access Profile ID'), + filters: filtersField, + sorters: sortersField, + ...pagination(LIMIT_STANDARD), +}) + +const listSourcesSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_list_sources'), + filters: filtersField, + sorters: sortersField, + forSubadmin: z.string().optional(), + includeIDNSource: z.boolean().optional(), + ...pagination(LIMIT_STANDARD), +}) + +const getSourceSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_get_source'), + id: idField('Source ID'), +}) + +const listAccountActivitiesSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_list_account_activities'), + requestedFor: z.string().optional(), + requestedBy: z.string().optional(), + regardingIdentity: z.string().optional(), + filters: filtersField, + sorters: sortersField, + ...pagination(LIMIT_STANDARD), +}) + +const getAccountActivitySchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_get_account_activity'), + id: idField('Account activity ID'), +}) + +const listCampaignsSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_list_campaigns'), + detail: z.enum(['SLIM', 'FULL']).optional(), + filters: filtersField, + sorters: sortersField, + ...pagination(LIMIT_STANDARD), +}) + +const getCampaignSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_get_campaign'), + id: idField('Campaign ID'), + detail: z.enum(['SLIM', 'FULL']).optional(), +}) + +const listCertificationsSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_list_certifications'), + reviewerIdentity: z.string().optional(), + filters: filtersField, + sorters: sortersField, + ...pagination(LIMIT_STANDARD), +}) + +const listCertificationReviewItemsSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_list_certification_review_items'), + id: idField('Certification ID'), + filters: filtersField, + sorters: sortersField, + entitlements: z.string().optional(), + accessProfiles: z.string().optional(), + roles: z.string().optional(), + ...pagination(LIMIT_STANDARD), +}) + +const requestAccessSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_request_access'), + requestedFor: requestedForSchema, + requestedItems: requestedItemsSchema, + requestType: z.enum(['GRANT_ACCESS', 'REVOKE_ACCESS', 'MODIFY_ACCESS']).optional(), + clientMetadata: clientMetadataSchema, +}) + +const cancelAccessRequestSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_cancel_access_request'), + accountActivityId: idField('accountActivityId'), + comment: z.string().min(1, 'comment is required to cancel an access request'), +}) + +const getAccessRequestStatusSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_get_access_request_status'), + requestedFor: z.string().optional(), + requestedBy: z.string().optional(), + regardingIdentity: z.string().optional(), + assignedTo: z.string().optional(), + requestState: z.enum(['EXECUTING']).optional(), + filters: filtersField, + sorters: sortersField, + ...pagination(LIMIT_STANDARD), +}) + +/** + * Discriminated union of every JSON operation handled by `/api/tools/sailpoint/query`. The + * `.superRefine` enforces SailPoint's documented access-request constraints (server-side 400s) + * before submission so callers get descriptive, field-anchored errors. + */ +export const sailpointQueryBodySchema = z + .discriminatedUnion('operation', [ + searchSchema, + searchCountSchema, + searchAggregateSchema, + listIdentitiesSchema, + getIdentitySchema, + listAccountsSchema, + getAccountSchema, + getAccountEntitlementsSchema, + listEntitlementsSchema, + getEntitlementSchema, + listRolesSchema, + getRoleEntitlementsSchema, + listAccessProfilesSchema, + getAccessProfileEntitlementsSchema, + listSourcesSchema, + getSourceSchema, + listAccountActivitiesSchema, + getAccountActivitySchema, + listCampaignsSchema, + getCampaignSchema, + listCertificationsSchema, + listCertificationReviewItemsSchema, + requestAccessSchema, + cancelAccessRequestSchema, + getAccessRequestStatusSchema, + ]) + .superRefine((val, ctx) => { + if (val.operation !== 'sailpoint_request_access') return + + const requestType = val.requestType ?? 'GRANT_ACCESS' + const requestedFor = val.requestedFor + const requestedItems = val.requestedItems + + if (requestedFor.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['requestedFor'], + message: 'requestedFor must contain at least one identity ID', + }) + } + if (requestedItems.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['requestedItems'], + message: 'requestedItems must contain at least one item', + }) + } + + if (requestType === 'REVOKE_ACCESS') { + if (requestedFor.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['requestedFor'], + message: 'REVOKE_ACCESS supports exactly one identity per request', + }) + } + if (requestedItems.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['requestedItems'], + message: + 'REVOKE_ACCESS supports exactly one item per request (there is no bulk-revoke endpoint)', + }) + } + requestedItems.forEach((item, index) => { + if (!item.comment) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['requestedItems', index, 'comment'], + message: 'comment is required for REVOKE_ACCESS requests', + }) + } + if (item.startDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['requestedItems', index, 'startDate'], + message: 'startDate is not allowed on REVOKE_ACCESS requests', + }) + } + }) + } + + if (requestType === 'GRANT_ACCESS') { + const hasEntitlement = requestedItems.some((item) => item.type === 'ENTITLEMENT') + if (hasEntitlement) { + if (requestedItems.length > 25) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['requestedItems'], + message: 'A grant that includes entitlements may request at most 25 items', + }) + } + if (requestedFor.length > 10) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['requestedFor'], + message: 'A grant that includes entitlements may request for at most 10 identities', + }) + } + } + } + }) + +const loadAccountsSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_load_accounts'), + sourceId: idField('Source ID'), + file: FileInputSchema.optional().nullable(), + disableOptimization: z.boolean().optional(), +}) + +const loadEntitlementsSchema = z.object({ + ...sailpointBaseFields, + operation: z.literal('sailpoint_load_entitlements'), + sourceId: idField('Source ID'), + file: FileInputSchema.optional().nullable(), +}) + +export const sailpointLoadBodySchema = z.discriminatedUnion('operation', [ + loadAccountsSchema, + loadEntitlementsSchema, +]) + +const listOutputSchema = z.object({ + items: z.array(z.unknown()), + count: z.number(), + totalCount: z.number().nullable(), + complete: z.boolean(), + warnings: z.array(z.string()), +}) + +const searchOutputSchema = z.object({ + results: z.array(z.unknown()), + count: z.number(), + totalCount: z.number().nullable(), + complete: z.boolean(), + warnings: z.array(z.string()), +}) + +const countOutputSchema = z.object({ total: z.number() }) +const itemOutputSchema = z.object({ item: z.unknown() }) +const writeOutputSchema = z.object({ accepted: z.boolean(), status: z.number() }) +const taskOutputSchema = z.object({ task: z.unknown() }) + +const okResponse = (output: T) => + z.object({ success: z.literal(true), output }) + +const sailpointQueryResponseSchema = z.union([ + okResponse(listOutputSchema), + okResponse(searchOutputSchema), + okResponse(countOutputSchema), + okResponse(itemOutputSchema), + okResponse(writeOutputSchema), +]) + +const sailpointLoadResponseSchema = okResponse(taskOutputSchema) + +export const sailpointQueryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sailpoint/query', + body: sailpointQueryBodySchema, + response: { mode: 'json', schema: sailpointQueryResponseSchema }, +}) + +export const sailpointLoadContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sailpoint/load', + body: sailpointLoadBodySchema, + response: { mode: 'json', schema: sailpointLoadResponseSchema }, +}) + +export type SailpointQueryBody = ContractBody +export type SailpointQueryBodyInput = ContractBodyInput +export type SailpointQueryResponse = ContractJsonResponse +export type SailpointLoadBody = ContractBody +export type SailpointLoadBodyInput = ContractBodyInput +export type SailpointLoadResponse = ContractJsonResponse diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index ff7c8965ac8..5614633b0a9 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -189,6 +189,7 @@ import { RootlyIcon, RssIcon, S3Icon, + SailPointIcon, SalesforceIcon, SapConcurIcon, SapS4HanaIcon, @@ -453,6 +454,7 @@ export const blockTypeToIconMap: Record = { rootly: RootlyIcon, rss: RssIcon, s3: S3Icon, + sailpoint: SailPointIcon, salesforce: SalesforceIcon, sap_concur: SapConcurIcon, sap_s4hana: SapS4HanaIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index deadb0012bc..7b2f1ca82e5 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -16136,6 +16136,133 @@ "integrationType": "documents", "tags": ["cloud", "automation"] }, + { + "type": "sailpoint", + "slug": "sailpoint", + "name": "SailPoint", + "description": "Govern identities and access in SailPoint Identity Security Cloud", + "longDescription": "Read and act on identity governance data in SailPoint Identity Security Cloud (ISC): search identities, accounts, entitlements, roles, and access profiles; review account activities, campaigns, and certifications; and request, revoke, or cancel access. Authenticates with a Personal Access Token (PAT) using the OAuth2 client-credentials grant against your per-tenant host (https://{tenant}.api.identitynow.com). ", + "bgColor": "#0033A1", + "iconName": "SailPointIcon", + "docsUrl": "https://docs.sim.ai/integrations/sailpoint", + "operations": [ + { + "name": "Search", + "description": "Run a global search across SailPoint indices (identities, entitlements, roles, access profiles, account activities, events). Set includeNested to return nested access[] on identities." + }, + { + "name": "Search Count", + "description": "Return the total number of documents matching a SailPoint search query, without the documents themselves." + }, + { + "name": "Search Aggregate", + "description": "Return aggregation buckets for a SailPoint search query (e.g. counts grouped by a field)." + }, + { + "name": "List Identities", + "description": "List identities in SailPoint with optional Sailpoint filters, sorters, and pagination." + }, + { + "name": "Get Identity", + "description": "Get a single SailPoint identity by ID." + }, + { + "name": "List Accounts", + "description": "List accounts in SailPoint with optional filters, sorters, and pagination." + }, + { + "name": "Get Account", + "description": "Get a single SailPoint account by ID." + }, + { + "name": "Get Account Entitlements", + "description": "List the entitlements granted on a specific SailPoint account." + }, + { + "name": "List Entitlements", + "description": "List entitlements in SailPoint with optional filters, sorters, and pagination." + }, + { + "name": "Get Entitlement", + "description": "Get a single SailPoint entitlement by ID." + }, + { + "name": "List Roles", + "description": "List roles in SailPoint with optional filters, sorters, and pagination." + }, + { + "name": "Get Role Entitlements", + "description": "List the entitlements granted by a specific SailPoint role." + }, + { + "name": "List Access Profiles", + "description": "List access profiles in SailPoint with optional filters, sorters, and pagination." + }, + { + "name": "Get Access Profile Entitlements", + "description": "List the entitlements granted by a specific SailPoint access profile." + }, + { + "name": "List Sources", + "description": "List identity sources in SailPoint with optional filters, sorters, and pagination." + }, + { + "name": "Get Source", + "description": "Get a single SailPoint identity source by ID." + }, + { + "name": "List Account Activities", + "description": "List account activities (provisioning events) in SailPoint with optional filters and pagination." + }, + { + "name": "Get Account Activity", + "description": "Get a single SailPoint account activity by ID." + }, + { + "name": "List Campaigns", + "description": "List certification campaigns in SailPoint with optional filters, sorters, and pagination." + }, + { + "name": "Get Campaign", + "description": "Get a single SailPoint certification campaign by ID." + }, + { + "name": "List Certifications", + "description": "List certifications in SailPoint with optional reviewer filter, filters, sorters, and pagination." + }, + { + "name": "List Certification Review Items", + "description": "List the access review items within a specific SailPoint certification." + }, + { + "name": "Request Access", + "description": "Submit a SailPoint access request to grant, revoke, or modify access for one or more identities." + }, + { + "name": "Cancel Access Request", + "description": "Cancel a pending SailPoint access request by its identity request ID." + }, + { + "name": "Get Access Request Status", + "description": "List the status of SailPoint access requests with optional identity and state filters." + }, + { + "name": "Load Accounts (CSV)", + "description": "Trigger an account aggregation for a SailPoint source, optionally uploading a CSV of accounts." + }, + { + "name": "Load Entitlements (CSV)", + "description": "Trigger an entitlement aggregation for a SailPoint source, optionally uploading a CSV of entitlements." + } + ], + "operationCount": 27, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "security", + "tags": ["identity", "operations"] + }, { "type": "salesforce", "slug": "salesforce", diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 1e2facaa404..ad0a5ceb6ea 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3334,6 +3334,35 @@ import { s3PresignedUrlTool, s3PutObjectTool, } from '@/tools/s3' +import { + sailpointCancelAccessRequestTool, + sailpointGetAccessProfileEntitlementsTool, + sailpointGetAccessRequestStatusTool, + sailpointGetAccountActivityTool, + sailpointGetAccountEntitlementsTool, + sailpointGetAccountTool, + sailpointGetCampaignTool, + sailpointGetEntitlementTool, + sailpointGetIdentityTool, + sailpointGetRoleEntitlementsTool, + sailpointGetSourceTool, + sailpointListAccessProfilesTool, + sailpointListAccountActivitiesTool, + sailpointListAccountsTool, + sailpointListCampaignsTool, + sailpointListCertificationReviewItemsTool, + sailpointListCertificationsTool, + sailpointListEntitlementsTool, + sailpointListIdentitiesTool, + sailpointListRolesTool, + sailpointListSourcesTool, + sailpointLoadAccountsTool, + sailpointLoadEntitlementsTool, + sailpointRequestAccessTool, + sailpointSearchAggregateTool, + sailpointSearchCountTool, + sailpointSearchTool, +} from '@/tools/sailpoint' import { salesforceCreateAccountTool, salesforceCreateCaseTool, @@ -8631,6 +8660,33 @@ export const tools: Record = { stripe_search_prices: stripeSearchPricesTool, stripe_retrieve_event: stripeRetrieveEventTool, stripe_list_events: stripeListEventsTool, + sailpoint_search: sailpointSearchTool, + sailpoint_search_count: sailpointSearchCountTool, + sailpoint_search_aggregate: sailpointSearchAggregateTool, + sailpoint_list_identities: sailpointListIdentitiesTool, + sailpoint_get_identity: sailpointGetIdentityTool, + sailpoint_list_accounts: sailpointListAccountsTool, + sailpoint_get_account: sailpointGetAccountTool, + sailpoint_get_account_entitlements: sailpointGetAccountEntitlementsTool, + sailpoint_list_entitlements: sailpointListEntitlementsTool, + sailpoint_get_entitlement: sailpointGetEntitlementTool, + sailpoint_list_roles: sailpointListRolesTool, + sailpoint_get_role_entitlements: sailpointGetRoleEntitlementsTool, + sailpoint_list_access_profiles: sailpointListAccessProfilesTool, + sailpoint_get_access_profile_entitlements: sailpointGetAccessProfileEntitlementsTool, + sailpoint_list_sources: sailpointListSourcesTool, + sailpoint_get_source: sailpointGetSourceTool, + sailpoint_list_account_activities: sailpointListAccountActivitiesTool, + sailpoint_get_account_activity: sailpointGetAccountActivityTool, + sailpoint_list_campaigns: sailpointListCampaignsTool, + sailpoint_get_campaign: sailpointGetCampaignTool, + sailpoint_list_certifications: sailpointListCertificationsTool, + sailpoint_list_certification_review_items: sailpointListCertificationReviewItemsTool, + sailpoint_request_access: sailpointRequestAccessTool, + sailpoint_cancel_access_request: sailpointCancelAccessRequestTool, + sailpoint_get_access_request_status: sailpointGetAccessRequestStatusTool, + sailpoint_load_accounts: sailpointLoadAccountsTool, + sailpoint_load_entitlements: sailpointLoadEntitlementsTool, salesforce_get_accounts: salesforceGetAccountsTool, salesforce_create_account: salesforceCreateAccountTool, salesforce_update_account: salesforceUpdateAccountTool, diff --git a/apps/sim/tools/sailpoint/cancel_access_request.ts b/apps/sim/tools/sailpoint/cancel_access_request.ts new file mode 100644 index 00000000000..cd30e244edf --- /dev/null +++ b/apps/sim/tools/sailpoint/cancel_access_request.ts @@ -0,0 +1,57 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointWriteOutputs, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointCancelAccessRequestParams, + SailPointWriteResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointCancelAccessRequestTool: ToolConfig< + SailPointCancelAccessRequestParams, + SailPointWriteResponse +> = { + id: 'sailpoint_cancel_access_request', + name: 'SailPoint Cancel Access Request', + description: 'Cancel a pending SailPoint access request by its identity request ID.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + accountActivityId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The identityRequestId of the access request to cancel', + }, + comment: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Reason for cancellation', + }, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_cancel_access_request', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + accountActivityId: params.accountActivityId, + comment: params.comment, + }), + }, + + transformResponse: (response) => + unwrapSailPointOutput<{ accepted: boolean; status: number }>(response), + + outputs: sailpointWriteOutputs, +} diff --git a/apps/sim/tools/sailpoint/common.ts b/apps/sim/tools/sailpoint/common.ts new file mode 100644 index 00000000000..505316c0be3 --- /dev/null +++ b/apps/sim/tools/sailpoint/common.ts @@ -0,0 +1,142 @@ +import type { ToolConfig } from '@/tools/types' + +/** + * Internal route that performs the SailPoint client-credentials token exchange (with + * caching + 429 backoff) and proxies all JSON read/write operations. Tools never call + * the SailPoint API directly - the per-tenant host, version prefix, and bearer token + * are all resolved server-side. + */ +export const SAILPOINT_QUERY_ROUTE = '/api/tools/sailpoint/query' + +/** Internal route for the multipart CSV aggregation writes (load-accounts / load-entitlements). */ +export const SAILPOINT_LOAD_ROUTE = '/api/tools/sailpoint/load' + +/** + * Credential params shared by every SailPoint tool. The credential is a Personal Access + * Token (PAT) owned by a dedicated ISC service identity - see the block longDescription + * for the required scopes and the service-identity caveat. + */ +export const sailpointCredentialParams = { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'SailPoint PAT client ID (from a service-identity Personal Access Token)', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'SailPoint PAT client secret', + }, + tenant: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'SailPoint tenant (subdomain of api.identitynow.com, e.g. "acme")', + }, + apiVersion: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'API version path segment: v2025 (default), v2024, or v3', + }, +} as const satisfies ToolConfig['params'] + +/** Standard pagination params reused across list operations. */ +export const sailpointPaginationParams = { + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of records to return', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Pagination offset (0-based)', + }, + count: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'When true, include the total record count (X-Total-Count) in the response', + }, +} as const satisfies ToolConfig['params'] + +/** Output shape for paginated list operations (raw documents + empty-result diagnostic). */ +export const sailpointListOutputs = { + items: { type: 'json', description: 'Array of raw SailPoint documents for this page' }, + count: { type: 'number', description: 'Number of records returned in this page' }, + totalCount: { + type: 'number', + description: 'Total matching records when count=true, otherwise null', + optional: true, + nullable: true, + }, + complete: { + type: 'boolean', + description: 'False when an empty result may indicate insufficient user level or segmentation', + }, + warnings: { type: 'json', description: 'Diagnostic warnings (e.g. empty-result guidance)' }, +} as const satisfies ToolConfig['outputs'] + +/** Output shape for POST /search (raw documents under `results`). */ +export const sailpointSearchOutputs = { + results: { type: 'json', description: 'Array of raw SailPoint search documents' }, + count: { type: 'number', description: 'Number of documents returned in this page' }, + totalCount: { + type: 'number', + description: 'Total matching documents when count=true, otherwise null', + optional: true, + nullable: true, + }, + complete: { + type: 'boolean', + description: 'False when an empty result may indicate insufficient user level or segmentation', + }, + warnings: { type: 'json', description: 'Diagnostic warnings (e.g. empty-result guidance)' }, +} as const satisfies ToolConfig['outputs'] + +/** Output shape for single-entity get operations. */ +export const sailpointItemOutputs = { + item: { type: 'json', description: 'Raw SailPoint document' }, +} as const satisfies ToolConfig['outputs'] + +/** Output shape for POST /search/count. */ +export const sailpointCountOutputs = { + total: { type: 'number', description: 'Total matching documents (X-Total-Count)' }, +} as const satisfies ToolConfig['outputs'] + +/** Output shape for access-request create/cancel (202, empty body). */ +export const sailpointWriteOutputs = { + accepted: { type: 'boolean', description: 'True when SailPoint accepted the request (HTTP 202)' }, + status: { type: 'number', description: 'HTTP status returned by SailPoint' }, +} as const satisfies ToolConfig['outputs'] + +/** Output shape for the CSV aggregation writes (task object). */ +export const sailpointTaskOutputs = { + task: { + type: 'json', + description: 'Aggregation task returned by SailPoint (LoadAccountsTask / LoadEntitlementTask)', + }, +} as const satisfies ToolConfig['outputs'] + +/** + * Unwraps the `{ success, output }` envelope returned by the internal SailPoint routes and + * throws a descriptive error when the route reports failure. Shared by every SailPoint tool's + * `transformResponse`. + */ +export async function unwrapSailPointOutput>( + response: Response, + fallbackError = 'SailPoint request failed' +): Promise<{ success: true; output: T }> { + const data = await response.json().catch(() => null) + + if (!response.ok || !data || data.success === false) { + throw new Error(data?.error || fallbackError) + } + + return { success: true, output: (data.output ?? {}) as T } +} diff --git a/apps/sim/tools/sailpoint/get_access_profile_entitlements.ts b/apps/sim/tools/sailpoint/get_access_profile_entitlements.ts new file mode 100644 index 00000000000..5ccf39012f1 --- /dev/null +++ b/apps/sim/tools/sailpoint/get_access_profile_entitlements.ts @@ -0,0 +1,69 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointGetChildEntitlementsParams, + SailPointListOutput, + SailPointListResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointGetAccessProfileEntitlementsTool: ToolConfig< + SailPointGetChildEntitlementsParams, + SailPointListResponse +> = { + id: 'sailpoint_get_access_profile_entitlements', + name: 'SailPoint Get Access Profile Entitlements', + description: 'List the entitlements granted by a specific SailPoint access profile.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + id: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Access Profile ID', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint filter expression to narrow results', + }, + sorters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint sorters expression', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_get_access_profile_entitlements', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + id: params.id, + filters: params.filters, + sorters: params.sorters, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/get_access_request_status.ts b/apps/sim/tools/sailpoint/get_access_request_status.ts new file mode 100644 index 00000000000..b2071c6dc12 --- /dev/null +++ b/apps/sim/tools/sailpoint/get_access_request_status.ts @@ -0,0 +1,98 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointAccessRequestStatusParams, + SailPointListOutput, + SailPointListResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointGetAccessRequestStatusTool: ToolConfig< + SailPointAccessRequestStatusParams, + SailPointListResponse +> = { + id: 'sailpoint_get_access_request_status', + name: 'SailPoint Get Access Request Status', + description: + 'List the status of SailPoint access requests with optional identity and state filters.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + requestedFor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Identity ID the request was made for', + }, + requestedBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Identity ID that submitted the request', + }, + regardingIdentity: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Identity ID the request is about (requester or target)', + }, + assignedTo: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Identity ID a pending approval is assigned to', + }, + requestState: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'EXECUTING', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint filter expression to narrow results', + }, + sorters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint sorters expression', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_get_access_request_status', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + requestedFor: params.requestedFor, + requestedBy: params.requestedBy, + regardingIdentity: params.regardingIdentity, + assignedTo: params.assignedTo, + requestState: params.requestState, + filters: params.filters, + sorters: params.sorters, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/get_account.ts b/apps/sim/tools/sailpoint/get_account.ts new file mode 100644 index 00000000000..623c8f538ab --- /dev/null +++ b/apps/sim/tools/sailpoint/get_account.ts @@ -0,0 +1,43 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointItemOutputs, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { SailPointGetByIdParams, SailPointItemResponse } from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointGetAccountTool: ToolConfig = { + id: 'sailpoint_get_account', + name: 'SailPoint Get Account', + description: 'Get a single SailPoint account by ID.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + id: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Account ID', + }, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_get_account', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + id: params.id, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput<{ item: unknown }>(response), + + outputs: sailpointItemOutputs, +} diff --git a/apps/sim/tools/sailpoint/get_account_activity.ts b/apps/sim/tools/sailpoint/get_account_activity.ts new file mode 100644 index 00000000000..d0f207fb54e --- /dev/null +++ b/apps/sim/tools/sailpoint/get_account_activity.ts @@ -0,0 +1,46 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointItemOutputs, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { SailPointGetByIdParams, SailPointItemResponse } from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointGetAccountActivityTool: ToolConfig< + SailPointGetByIdParams, + SailPointItemResponse +> = { + id: 'sailpoint_get_account_activity', + name: 'SailPoint Get Account Activity', + description: 'Get a single SailPoint account activity by ID.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + id: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Account Activity ID', + }, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_get_account_activity', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + id: params.id, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput<{ item: unknown }>(response), + + outputs: sailpointItemOutputs, +} diff --git a/apps/sim/tools/sailpoint/get_account_entitlements.ts b/apps/sim/tools/sailpoint/get_account_entitlements.ts new file mode 100644 index 00000000000..1a39d67cfd0 --- /dev/null +++ b/apps/sim/tools/sailpoint/get_account_entitlements.ts @@ -0,0 +1,55 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointGetChildEntitlementsParams, + SailPointListOutput, + SailPointListResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointGetAccountEntitlementsTool: ToolConfig< + SailPointGetChildEntitlementsParams, + SailPointListResponse +> = { + id: 'sailpoint_get_account_entitlements', + name: 'SailPoint Get Account Entitlements', + description: 'List the entitlements granted on a specific SailPoint account.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + id: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Account ID', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_get_account_entitlements', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + id: params.id, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/get_campaign.ts b/apps/sim/tools/sailpoint/get_campaign.ts new file mode 100644 index 00000000000..19efb6afd9e --- /dev/null +++ b/apps/sim/tools/sailpoint/get_campaign.ts @@ -0,0 +1,53 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointItemOutputs, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { SailPointGetCampaignParams, SailPointItemResponse } from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointGetCampaignTool: ToolConfig< + SailPointGetCampaignParams, + SailPointItemResponse +> = { + id: 'sailpoint_get_campaign', + name: 'SailPoint Get Campaign', + description: 'Get a single SailPoint certification campaign by ID.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + id: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Campaign ID', + }, + detail: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SLIM or FULL', + }, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_get_campaign', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + id: params.id, + detail: params.detail, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput<{ item: unknown }>(response), + + outputs: sailpointItemOutputs, +} diff --git a/apps/sim/tools/sailpoint/get_entitlement.ts b/apps/sim/tools/sailpoint/get_entitlement.ts new file mode 100644 index 00000000000..7f9fdf184d4 --- /dev/null +++ b/apps/sim/tools/sailpoint/get_entitlement.ts @@ -0,0 +1,46 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointItemOutputs, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { SailPointGetByIdParams, SailPointItemResponse } from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointGetEntitlementTool: ToolConfig< + SailPointGetByIdParams, + SailPointItemResponse +> = { + id: 'sailpoint_get_entitlement', + name: 'SailPoint Get Entitlement', + description: 'Get a single SailPoint entitlement by ID.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + id: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Entitlement ID', + }, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_get_entitlement', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + id: params.id, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput<{ item: unknown }>(response), + + outputs: sailpointItemOutputs, +} diff --git a/apps/sim/tools/sailpoint/get_identity.ts b/apps/sim/tools/sailpoint/get_identity.ts new file mode 100644 index 00000000000..50c388ebfbb --- /dev/null +++ b/apps/sim/tools/sailpoint/get_identity.ts @@ -0,0 +1,43 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointItemOutputs, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { SailPointGetByIdParams, SailPointItemResponse } from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointGetIdentityTool: ToolConfig = { + id: 'sailpoint_get_identity', + name: 'SailPoint Get Identity', + description: 'Get a single SailPoint identity by ID.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + id: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Identity ID', + }, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_get_identity', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + id: params.id, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput<{ item: unknown }>(response), + + outputs: sailpointItemOutputs, +} diff --git a/apps/sim/tools/sailpoint/get_role_entitlements.ts b/apps/sim/tools/sailpoint/get_role_entitlements.ts new file mode 100644 index 00000000000..be1a2b26bc7 --- /dev/null +++ b/apps/sim/tools/sailpoint/get_role_entitlements.ts @@ -0,0 +1,69 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointGetChildEntitlementsParams, + SailPointListOutput, + SailPointListResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointGetRoleEntitlementsTool: ToolConfig< + SailPointGetChildEntitlementsParams, + SailPointListResponse +> = { + id: 'sailpoint_get_role_entitlements', + name: 'SailPoint Get Role Entitlements', + description: 'List the entitlements granted by a specific SailPoint role.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + id: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Role ID', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint filter expression to narrow results', + }, + sorters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint sorters expression', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_get_role_entitlements', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + id: params.id, + filters: params.filters, + sorters: params.sorters, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/get_source.ts b/apps/sim/tools/sailpoint/get_source.ts new file mode 100644 index 00000000000..f4e0cac73ba --- /dev/null +++ b/apps/sim/tools/sailpoint/get_source.ts @@ -0,0 +1,43 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointItemOutputs, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { SailPointGetByIdParams, SailPointItemResponse } from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointGetSourceTool: ToolConfig = { + id: 'sailpoint_get_source', + name: 'SailPoint Get Source', + description: 'Get a single SailPoint identity source by ID.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + id: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Source ID', + }, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_get_source', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + id: params.id, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput<{ item: unknown }>(response), + + outputs: sailpointItemOutputs, +} diff --git a/apps/sim/tools/sailpoint/index.ts b/apps/sim/tools/sailpoint/index.ts new file mode 100644 index 00000000000..d7cecb14f28 --- /dev/null +++ b/apps/sim/tools/sailpoint/index.ts @@ -0,0 +1,28 @@ +export { sailpointCancelAccessRequestTool } from '@/tools/sailpoint/cancel_access_request' +export { sailpointGetAccessProfileEntitlementsTool } from '@/tools/sailpoint/get_access_profile_entitlements' +export { sailpointGetAccessRequestStatusTool } from '@/tools/sailpoint/get_access_request_status' +export { sailpointGetAccountTool } from '@/tools/sailpoint/get_account' +export { sailpointGetAccountActivityTool } from '@/tools/sailpoint/get_account_activity' +export { sailpointGetAccountEntitlementsTool } from '@/tools/sailpoint/get_account_entitlements' +export { sailpointGetCampaignTool } from '@/tools/sailpoint/get_campaign' +export { sailpointGetEntitlementTool } from '@/tools/sailpoint/get_entitlement' +export { sailpointGetIdentityTool } from '@/tools/sailpoint/get_identity' +export { sailpointGetRoleEntitlementsTool } from '@/tools/sailpoint/get_role_entitlements' +export { sailpointGetSourceTool } from '@/tools/sailpoint/get_source' +export { sailpointListAccessProfilesTool } from '@/tools/sailpoint/list_access_profiles' +export { sailpointListAccountActivitiesTool } from '@/tools/sailpoint/list_account_activities' +export { sailpointListAccountsTool } from '@/tools/sailpoint/list_accounts' +export { sailpointListCampaignsTool } from '@/tools/sailpoint/list_campaigns' +export { sailpointListCertificationReviewItemsTool } from '@/tools/sailpoint/list_certification_review_items' +export { sailpointListCertificationsTool } from '@/tools/sailpoint/list_certifications' +export { sailpointListEntitlementsTool } from '@/tools/sailpoint/list_entitlements' +export { sailpointListIdentitiesTool } from '@/tools/sailpoint/list_identities' +export { sailpointListRolesTool } from '@/tools/sailpoint/list_roles' +export { sailpointListSourcesTool } from '@/tools/sailpoint/list_sources' +export { sailpointLoadAccountsTool } from '@/tools/sailpoint/load_accounts' +export { sailpointLoadEntitlementsTool } from '@/tools/sailpoint/load_entitlements' +export { sailpointRequestAccessTool } from '@/tools/sailpoint/request_access' +export { sailpointSearchTool } from '@/tools/sailpoint/search' +export { sailpointSearchAggregateTool } from '@/tools/sailpoint/search_aggregate' +export { sailpointSearchCountTool } from '@/tools/sailpoint/search_count' +export * from '@/tools/sailpoint/types' diff --git a/apps/sim/tools/sailpoint/list_access_profiles.ts b/apps/sim/tools/sailpoint/list_access_profiles.ts new file mode 100644 index 00000000000..3927146e7a4 --- /dev/null +++ b/apps/sim/tools/sailpoint/list_access_profiles.ts @@ -0,0 +1,62 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointListOutput, + SailPointListParams, + SailPointListResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointListAccessProfilesTool: ToolConfig< + SailPointListParams, + SailPointListResponse +> = { + id: 'sailpoint_list_access_profiles', + name: 'SailPoint List Access Profiles', + description: 'List access profiles in SailPoint with optional filters, sorters, and pagination.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint filter expression to narrow results', + }, + sorters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint sorters expression', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_list_access_profiles', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + filters: params.filters, + sorters: params.sorters, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/list_account_activities.ts b/apps/sim/tools/sailpoint/list_account_activities.ts new file mode 100644 index 00000000000..c91d5ba381a --- /dev/null +++ b/apps/sim/tools/sailpoint/list_account_activities.ts @@ -0,0 +1,84 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointListAccountActivitiesParams, + SailPointListOutput, + SailPointListResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointListAccountActivitiesTool: ToolConfig< + SailPointListAccountActivitiesParams, + SailPointListResponse +> = { + id: 'sailpoint_list_account_activities', + name: 'SailPoint List Account Activities', + description: + 'List account activities (provisioning events) in SailPoint with optional filters and pagination.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + requestedFor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Identity ID the activity was requested for', + }, + requestedBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Identity ID that requested the activity', + }, + regardingIdentity: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Identity ID the activity is about (requester or target)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint filter expression to narrow results', + }, + sorters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint sorters expression', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_list_account_activities', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + requestedFor: params.requestedFor, + requestedBy: params.requestedBy, + regardingIdentity: params.regardingIdentity, + filters: params.filters, + sorters: params.sorters, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/list_accounts.ts b/apps/sim/tools/sailpoint/list_accounts.ts new file mode 100644 index 00000000000..7a1de07b297 --- /dev/null +++ b/apps/sim/tools/sailpoint/list_accounts.ts @@ -0,0 +1,69 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointListAccountsParams, + SailPointListOutput, + SailPointListResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointListAccountsTool: ToolConfig< + SailPointListAccountsParams, + SailPointListResponse +> = { + id: 'sailpoint_list_accounts', + name: 'SailPoint List Accounts', + description: 'List accounts in SailPoint with optional filters, sorters, and pagination.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint filter expression to narrow results', + }, + sorters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint sorters expression', + }, + detailLevel: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SLIM or FULL (default)', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_list_accounts', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + filters: params.filters, + sorters: params.sorters, + detailLevel: params.detailLevel, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/list_campaigns.ts b/apps/sim/tools/sailpoint/list_campaigns.ts new file mode 100644 index 00000000000..62215408802 --- /dev/null +++ b/apps/sim/tools/sailpoint/list_campaigns.ts @@ -0,0 +1,70 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointListCampaignsParams, + SailPointListOutput, + SailPointListResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointListCampaignsTool: ToolConfig< + SailPointListCampaignsParams, + SailPointListResponse +> = { + id: 'sailpoint_list_campaigns', + name: 'SailPoint List Campaigns', + description: + 'List certification campaigns in SailPoint with optional filters, sorters, and pagination.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + detail: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SLIM (default) or FULL', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint filter expression to narrow results', + }, + sorters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint sorters expression', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_list_campaigns', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + detail: params.detail, + filters: params.filters, + sorters: params.sorters, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/list_certification_review_items.ts b/apps/sim/tools/sailpoint/list_certification_review_items.ts new file mode 100644 index 00000000000..96fd75b3228 --- /dev/null +++ b/apps/sim/tools/sailpoint/list_certification_review_items.ts @@ -0,0 +1,90 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointListOutput, + SailPointListResponse, + SailPointListReviewItemsParams, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointListCertificationReviewItemsTool: ToolConfig< + SailPointListReviewItemsParams, + SailPointListResponse +> = { + id: 'sailpoint_list_certification_review_items', + name: 'SailPoint List Certification Review Items', + description: 'List the access review items within a specific SailPoint certification.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + id: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Certification ID', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint filter expression to narrow results', + }, + sorters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint sorters expression', + }, + entitlements: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter review items to specific entitlement IDs', + }, + accessProfiles: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter review items to specific access profile IDs', + }, + roles: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter review items to specific role IDs', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_list_certification_review_items', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + id: params.id, + filters: params.filters, + sorters: params.sorters, + entitlements: params.entitlements, + accessProfiles: params.accessProfiles, + roles: params.roles, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/list_certifications.ts b/apps/sim/tools/sailpoint/list_certifications.ts new file mode 100644 index 00000000000..5567d0aaa84 --- /dev/null +++ b/apps/sim/tools/sailpoint/list_certifications.ts @@ -0,0 +1,70 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointListCertificationsParams, + SailPointListOutput, + SailPointListResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointListCertificationsTool: ToolConfig< + SailPointListCertificationsParams, + SailPointListResponse +> = { + id: 'sailpoint_list_certifications', + name: 'SailPoint List Certifications', + description: + 'List certifications in SailPoint with optional reviewer filter, filters, sorters, and pagination.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + reviewerIdentity: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Reviewer identity ID or 'me'", + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint filter expression to narrow results', + }, + sorters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint sorters expression', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_list_certifications', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + reviewerIdentity: params.reviewerIdentity, + filters: params.filters, + sorters: params.sorters, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/list_entitlements.ts b/apps/sim/tools/sailpoint/list_entitlements.ts new file mode 100644 index 00000000000..0e36897b70e --- /dev/null +++ b/apps/sim/tools/sailpoint/list_entitlements.ts @@ -0,0 +1,76 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointListEntitlementsParams, + SailPointListOutput, + SailPointListResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointListEntitlementsTool: ToolConfig< + SailPointListEntitlementsParams, + SailPointListResponse +> = { + id: 'sailpoint_list_entitlements', + name: 'SailPoint List Entitlements', + description: 'List entitlements in SailPoint with optional filters, sorters, and pagination.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint filter expression to narrow results', + }, + sorters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint sorters expression', + }, + accountId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter to entitlements on a specific account ID', + }, + segmentedForIdentity: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return only entitlements visible to the given identity via segmentation', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_list_entitlements', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + filters: params.filters, + sorters: params.sorters, + accountId: params.accountId, + segmentedForIdentity: params.segmentedForIdentity, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/list_identities.ts b/apps/sim/tools/sailpoint/list_identities.ts new file mode 100644 index 00000000000..3cacb3f982f --- /dev/null +++ b/apps/sim/tools/sailpoint/list_identities.ts @@ -0,0 +1,70 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointListIdentitiesParams, + SailPointListOutput, + SailPointListResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointListIdentitiesTool: ToolConfig< + SailPointListIdentitiesParams, + SailPointListResponse +> = { + id: 'sailpoint_list_identities', + name: 'SailPoint List Identities', + description: + 'List identities in SailPoint with optional Sailpoint filters, sorters, and pagination.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint filter expression to narrow results', + }, + sorters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint sorters expression', + }, + defaultFilter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'CORRELATED_ONLY (default) or NONE', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_list_identities', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + filters: params.filters, + sorters: params.sorters, + defaultFilter: params.defaultFilter, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/list_roles.ts b/apps/sim/tools/sailpoint/list_roles.ts new file mode 100644 index 00000000000..bfd40794b1e --- /dev/null +++ b/apps/sim/tools/sailpoint/list_roles.ts @@ -0,0 +1,59 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointListOutput, + SailPointListParams, + SailPointListResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointListRolesTool: ToolConfig = { + id: 'sailpoint_list_roles', + name: 'SailPoint List Roles', + description: 'List roles in SailPoint with optional filters, sorters, and pagination.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint filter expression to narrow results', + }, + sorters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint sorters expression', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_list_roles', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + filters: params.filters, + sorters: params.sorters, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/list_sources.ts b/apps/sim/tools/sailpoint/list_sources.ts new file mode 100644 index 00000000000..5f385ed2433 --- /dev/null +++ b/apps/sim/tools/sailpoint/list_sources.ts @@ -0,0 +1,76 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointListOutputs, + sailpointPaginationParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointListOutput, + SailPointListResponse, + SailPointListSourcesParams, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointListSourcesTool: ToolConfig< + SailPointListSourcesParams, + SailPointListResponse +> = { + id: 'sailpoint_list_sources', + name: 'SailPoint List Sources', + description: 'List identity sources in SailPoint with optional filters, sorters, and pagination.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint filter expression to narrow results', + }, + sorters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'SailPoint sorters expression', + }, + forSubadmin: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return only sources the given source sub-admin identity can administer', + }, + includeIDNSource: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include the built-in IdentityNow source in results', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_list_sources', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + filters: params.filters, + sorters: params.sorters, + forSubadmin: params.forSubadmin, + includeIDNSource: params.includeIDNSource, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointListOutputs, +} diff --git a/apps/sim/tools/sailpoint/load_accounts.ts b/apps/sim/tools/sailpoint/load_accounts.ts new file mode 100644 index 00000000000..561ff85f66b --- /dev/null +++ b/apps/sim/tools/sailpoint/load_accounts.ts @@ -0,0 +1,61 @@ +import { + SAILPOINT_LOAD_ROUTE, + sailpointCredentialParams, + sailpointTaskOutputs, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { SailPointLoadAccountsParams, SailPointTaskResponse } from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointLoadAccountsTool: ToolConfig< + SailPointLoadAccountsParams, + SailPointTaskResponse +> = { + id: 'sailpoint_load_accounts', + name: 'SailPoint Load Accounts', + description: + 'Trigger an account aggregation for a SailPoint source, optionally uploading a CSV of accounts.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + sourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Source ID to aggregate', + }, + file: { + type: 'file', + required: false, + visibility: 'user-or-llm', + description: 'CSV file of accounts to aggregate (delimited-file sources only)', + }, + disableOptimization: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Reprocess every account regardless of change', + }, + }, + + request: { + url: SAILPOINT_LOAD_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_load_accounts', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + sourceId: params.sourceId, + file: params.file, + disableOptimization: params.disableOptimization, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput<{ task: unknown }>(response), + + outputs: sailpointTaskOutputs, +} diff --git a/apps/sim/tools/sailpoint/load_entitlements.ts b/apps/sim/tools/sailpoint/load_entitlements.ts new file mode 100644 index 00000000000..4402c491a8b --- /dev/null +++ b/apps/sim/tools/sailpoint/load_entitlements.ts @@ -0,0 +1,57 @@ +import { + SAILPOINT_LOAD_ROUTE, + sailpointCredentialParams, + sailpointTaskOutputs, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointLoadEntitlementsParams, + SailPointTaskResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointLoadEntitlementsTool: ToolConfig< + SailPointLoadEntitlementsParams, + SailPointTaskResponse +> = { + id: 'sailpoint_load_entitlements', + name: 'SailPoint Load Entitlements', + description: + 'Trigger an entitlement aggregation for a SailPoint source, optionally uploading a CSV of entitlements.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + sourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Source ID to aggregate', + }, + file: { + type: 'file', + required: false, + visibility: 'user-or-llm', + description: 'CSV file of entitlements to aggregate (delimited-file sources only)', + }, + }, + + request: { + url: SAILPOINT_LOAD_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_load_entitlements', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + sourceId: params.sourceId, + file: params.file, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput<{ task: unknown }>(response), + + outputs: sailpointTaskOutputs, +} diff --git a/apps/sim/tools/sailpoint/request_access.ts b/apps/sim/tools/sailpoint/request_access.ts new file mode 100644 index 00000000000..d2832404c04 --- /dev/null +++ b/apps/sim/tools/sailpoint/request_access.ts @@ -0,0 +1,75 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointWriteOutputs, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { SailPointRequestAccessParams, SailPointWriteResponse } from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Submits an access request in SailPoint. REVOKE_ACCESS is limited to exactly one identity and + * one entitlement per request (with a mandatory comment) and cannot revoke role-membership or + * birthright access - those must be removed at their source. + */ +export const sailpointRequestAccessTool: ToolConfig< + SailPointRequestAccessParams, + SailPointWriteResponse +> = { + id: 'sailpoint_request_access', + name: 'SailPoint Request Access', + description: + 'Submit a SailPoint access request to grant, revoke, or modify access for one or more identities.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + requestedFor: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Array of identity IDs. For REVOKE_ACCESS exactly one identity.', + }, + requestedItems: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of { type: ACCESS_PROFILE|ROLE|ENTITLEMENT, id, comment?, removeDate?, startDate?, assignmentId?, nativeIdentity?, clientMetadata? }. REVOKE requires exactly one item with a comment.', + }, + requestType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'GRANT_ACCESS (default), REVOKE_ACCESS, or MODIFY_ACCESS', + }, + clientMetadata: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Optional key/value map, e.g. to record the human requester for correlation', + }, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_request_access', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + requestedFor: params.requestedFor, + requestedItems: params.requestedItems, + requestType: params.requestType, + clientMetadata: params.clientMetadata, + }), + }, + + transformResponse: (response) => + unwrapSailPointOutput<{ accepted: boolean; status: number }>(response), + + outputs: sailpointWriteOutputs, +} diff --git a/apps/sim/tools/sailpoint/search.ts b/apps/sim/tools/sailpoint/search.ts new file mode 100644 index 00000000000..5f939e204b4 --- /dev/null +++ b/apps/sim/tools/sailpoint/search.ts @@ -0,0 +1,82 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointPaginationParams, + sailpointSearchOutputs, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointSearchOutput, + SailPointSearchParams, + SailPointSearchResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointSearchTool: ToolConfig = { + id: 'sailpoint_search', + name: 'SailPoint Search', + description: + 'Run a global search across SailPoint indices (identities, entitlements, roles, access profiles, account activities, events). Set includeNested to return nested access[] on identities.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + indices: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Indices to search: identities, accessprofiles, accountactivities, entitlements, events, roles, or * (defaults to ["identities"])', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Elasticsearch query string (e.g. "attributes.department:Engineering")', + }, + sort: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Sort fields, e.g. ["displayName","+id"]', + }, + searchAfter: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'searchAfter cursor for deep pagination beyond 10,000 records', + }, + includeNested: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include nested objects (e.g. identity access[]) in results. Defaults to true.', + }, + ...sailpointPaginationParams, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_search', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + indices: params.indices, + query: params.query, + sort: params.sort, + searchAfter: params.searchAfter, + includeNested: params.includeNested, + limit: params.limit, + offset: params.offset, + count: params.count, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointSearchOutputs, +} diff --git a/apps/sim/tools/sailpoint/search_aggregate.ts b/apps/sim/tools/sailpoint/search_aggregate.ts new file mode 100644 index 00000000000..0c241e20631 --- /dev/null +++ b/apps/sim/tools/sailpoint/search_aggregate.ts @@ -0,0 +1,72 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCredentialParams, + sailpointSearchOutputs, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { + SailPointSearchAggregateParams, + SailPointSearchOutput, + SailPointSearchResponse, +} from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointSearchAggregateTool: ToolConfig< + SailPointSearchAggregateParams, + SailPointSearchResponse +> = { + id: 'sailpoint_search_aggregate', + name: 'SailPoint Search Aggregate', + description: + 'Return aggregation buckets for a SailPoint search query (e.g. counts grouped by a field).', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + indices: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Indices to aggregate over (defaults to ["identities"])', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Elasticsearch query string', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of aggregation results (max 250)', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Pagination offset (0-based)', + }, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_search_aggregate', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + indices: params.indices, + query: params.query, + limit: params.limit, + offset: params.offset, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput(response), + + outputs: sailpointSearchOutputs, +} diff --git a/apps/sim/tools/sailpoint/search_count.ts b/apps/sim/tools/sailpoint/search_count.ts new file mode 100644 index 00000000000..8961bfc034f --- /dev/null +++ b/apps/sim/tools/sailpoint/search_count.ts @@ -0,0 +1,54 @@ +import { + SAILPOINT_QUERY_ROUTE, + sailpointCountOutputs, + sailpointCredentialParams, + unwrapSailPointOutput, +} from '@/tools/sailpoint/common' +import type { SailPointCountResponse, SailPointSearchCountParams } from '@/tools/sailpoint/types' +import type { ToolConfig } from '@/tools/types' + +export const sailpointSearchCountTool: ToolConfig< + SailPointSearchCountParams, + SailPointCountResponse +> = { + id: 'sailpoint_search_count', + name: 'SailPoint Search Count', + description: + 'Return the total number of documents matching a SailPoint search query, without the documents themselves.', + version: '1.0.0', + + params: { + ...sailpointCredentialParams, + indices: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Indices to search (defaults to ["identities"])', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Elasticsearch query string', + }, + }, + + request: { + url: SAILPOINT_QUERY_ROUTE, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'sailpoint_search_count', + clientId: params.clientId, + clientSecret: params.clientSecret, + tenant: params.tenant, + apiVersion: params.apiVersion, + indices: params.indices, + query: params.query, + }), + }, + + transformResponse: (response) => unwrapSailPointOutput<{ total: number }>(response), + + outputs: sailpointCountOutputs, +} diff --git a/apps/sim/tools/sailpoint/types.ts b/apps/sim/tools/sailpoint/types.ts new file mode 100644 index 00000000000..496ffd11c59 --- /dev/null +++ b/apps/sim/tools/sailpoint/types.ts @@ -0,0 +1,185 @@ +import type { ToolResponse } from '@/tools/types' + +export type SailPointApiVersion = 'v2025' | 'v2024' | 'v3' + +/** Credentials shared by every SailPoint tool (a service-identity PAT + tenant + version). */ +export interface SailPointCredentials { + clientId: string + clientSecret: string + tenant: string + apiVersion?: SailPointApiVersion +} + +/** Envelope wrapping a paginated list of raw SailPoint documents plus the empty-result diagnostic. */ +export interface SailPointListOutput { + items: unknown[] + count: number + totalCount: number | null + complete: boolean + warnings: string[] +} + +export interface SailPointListResponse extends ToolResponse { + output: SailPointListOutput +} + +/** Search returns raw documents under `results` (index-dependent shape). */ +export interface SailPointSearchOutput { + results: unknown[] + count: number + totalCount: number | null + complete: boolean + warnings: string[] +} + +export interface SailPointSearchResponse extends ToolResponse { + output: SailPointSearchOutput +} + +export interface SailPointCountResponse extends ToolResponse { + output: { total: number } +} + +export interface SailPointItemResponse extends ToolResponse { + output: { item: unknown } +} + +/** Access-request create/cancel return 202 with an empty body. */ +export interface SailPointWriteResponse extends ToolResponse { + output: { accepted: boolean; status: number } +} + +/** load-accounts / load-entitlements return a task object (LoadAccountsTask / LoadEntitlementTask). */ +export interface SailPointTaskResponse extends ToolResponse { + output: { task: unknown } +} + +export interface SailPointSearchParams extends SailPointCredentials { + indices?: string[] | string + query?: string + sort?: string[] | string + searchAfter?: string[] | string + includeNested?: boolean + limit?: number + offset?: number + count?: boolean +} + +export interface SailPointSearchCountParams extends SailPointCredentials { + indices?: string[] | string + query?: string +} + +export interface SailPointSearchAggregateParams extends SailPointCredentials { + indices?: string[] | string + query?: string + limit?: number + offset?: number +} + +export interface SailPointListParams extends SailPointCredentials { + filters?: string + sorters?: string + limit?: number + offset?: number + count?: boolean +} + +export interface SailPointGetByIdParams extends SailPointCredentials { + id: string +} + +export interface SailPointListIdentitiesParams extends SailPointListParams { + defaultFilter?: 'CORRELATED_ONLY' | 'NONE' +} + +export interface SailPointListAccountsParams extends SailPointListParams { + detailLevel?: 'SLIM' | 'FULL' +} + +export interface SailPointListEntitlementsParams extends SailPointListParams { + accountId?: string + segmentedForIdentity?: string +} + +export interface SailPointGetChildEntitlementsParams extends SailPointListParams { + id: string +} + +export interface SailPointListSourcesParams extends SailPointListParams { + forSubadmin?: string + includeIDNSource?: boolean +} + +export interface SailPointListAccountActivitiesParams extends SailPointListParams { + requestedFor?: string + requestedBy?: string + regardingIdentity?: string +} + +export interface SailPointListCampaignsParams extends SailPointListParams { + detail?: 'SLIM' | 'FULL' +} + +export interface SailPointGetCampaignParams extends SailPointCredentials { + id: string + detail?: 'SLIM' | 'FULL' +} + +export interface SailPointListCertificationsParams extends SailPointListParams { + reviewerIdentity?: string +} + +export interface SailPointListReviewItemsParams extends SailPointListParams { + id: string + entitlements?: string + accessProfiles?: string + roles?: string +} + +export interface SailPointRequestedItem { + type: 'ACCESS_PROFILE' | 'ROLE' | 'ENTITLEMENT' + id: string + comment?: string + removeDate?: string + startDate?: string + assignmentId?: string + nativeIdentity?: string + clientMetadata?: Record +} + +export interface SailPointRequestAccessParams extends SailPointCredentials { + requestedFor: string[] | string + requestedItems: SailPointRequestedItem[] | string + requestType?: 'GRANT_ACCESS' | 'REVOKE_ACCESS' | 'MODIFY_ACCESS' + clientMetadata?: Record | string +} + +export interface SailPointCancelAccessRequestParams extends SailPointCredentials { + accountActivityId: string + comment: string +} + +export interface SailPointAccessRequestStatusParams extends SailPointCredentials { + requestedFor?: string + requestedBy?: string + regardingIdentity?: string + assignedTo?: string + requestState?: 'EXECUTING' + filters?: string + sorters?: string + limit?: number + offset?: number + count?: boolean +} + +export interface SailPointLoadAccountsParams extends SailPointCredentials { + sourceId: string + file?: unknown + disableOptimization?: boolean +} + +export interface SailPointLoadEntitlementsParams extends SailPointCredentials { + sourceId: string + file?: unknown +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 4e7f5f6ca23..b508c48c80f 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 994, - zodRoutes: 994, + totalRoutes: 996, + zodRoutes: 996, nonZodRoutes: 0, } as const From 6712f356bc71961e8862dc493e64b62ce6eba286 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 30 Jul 2026 00:11:31 -0700 Subject: [PATCH 2/5] 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. --- .../docs/en/integrations/sailpoint.mdx | 5 +- apps/sim/app/api/tools/sailpoint/client.ts | 87 ++++++++++++++----- .../sim/app/api/tools/sailpoint/load/route.ts | 38 +++++--- .../api/tools/sailpoint/query/route.test.ts | 60 +++++++++++++ .../app/api/tools/sailpoint/query/route.ts | 8 +- apps/sim/blocks/blocks/sailpoint.ts | 47 +++++++++- apps/sim/lib/api/contracts/tools/sailpoint.ts | 1 + apps/sim/lib/integrations/integrations.json | 4 +- apps/sim/tools/sailpoint/search_aggregate.ts | 26 +++--- apps/sim/tools/sailpoint/types.ts | 1 + 10 files changed, 223 insertions(+), 54 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/sailpoint.mdx b/apps/docs/content/docs/en/integrations/sailpoint.mdx index cbe1c6ebfff..25652050a8f 100644 --- a/apps/docs/content/docs/en/integrations/sailpoint.mdx +++ b/apps/docs/content/docs/en/integrations/sailpoint.mdx @@ -717,14 +717,15 @@ Run a global search across SailPoint indices (identities, entitlements, roles, a ### `sailpoint_search_aggregate` -Return aggregation buckets for a SailPoint search query (e.g. counts grouped by a field). +Return the aggregation result (buckets under `aggregations`, plus `hits`) for a SailPoint search query. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `indices` | json | No | Indices to aggregate over \(defaults to \["identities"\]\) | -| `query` | string | No | Elasticsearch query string | +| `query` | string | No | Elasticsearch query string to scope the documents before aggregating | +| `aggregationsDsl` | json | No | Elasticsearch aggregations DSL object defining the buckets/metrics to compute, e.g. \{ "department": \{ "terms": \{ "field": "attributes.department" \} \} \} | | `limit` | number | No | Maximum number of aggregation results \(max 250\) | | `offset` | number | No | Pagination offset \(0-based\) | diff --git a/apps/sim/app/api/tools/sailpoint/client.ts b/apps/sim/app/api/tools/sailpoint/client.ts index 4ce9663fc3d..c92617f5450 100644 --- a/apps/sim/app/api/tools/sailpoint/client.ts +++ b/apps/sim/app/api/tools/sailpoint/client.ts @@ -46,19 +46,48 @@ export function normalizeApiVersion(value: string | undefined | null): SailPoint } /** - * Resolves the API + token hosts for a tenant. Accepts either a bare tenant subdomain (`acme`) or a - * full host/URL (`https://acme.api.identitynow.com`, `acme.api.identitynow.com`), stripping any - * protocol, path, or version segment the caller may have included. + * Allowed SailPoint API host suffixes. A user-supplied full host must end with one of these; this + * prevents SSRF and PAT-secret disclosure by ensuring the client-credentials request (which carries + * `client_secret`) can only ever be sent to a SailPoint tenant host, never an arbitrary destination. + */ +const ALLOWED_HOST_SUFFIXES = ['.api.identitynow.com', '.api.identitynowgov.com'] as const + +/** + * Resolves the API + token hosts for a tenant. Accepts either a bare tenant subdomain (`acme` → + * `acme.api.identitynow.com`) or a full SailPoint host/URL (`https://acme.api.identitynow.com`), + * stripping any protocol, path, or version segment. Throws when the resolved host is not a SailPoint + * identitynow.com host - the credentials must never be posted to an attacker-controlled or internal + * host. */ export function resolveSailPointHosts( tenant: string, apiVersion: SailPointApiVersion ): SailPointHosts { let host = tenant.trim().replace(/^https?:\/\//i, '') - host = host.replace(/[/?#].*$/, '').replace(/\.+$/, '') - if (!host.includes('.')) { + host = host + .replace(/[/?#].*$/, '') + .replace(/\.+$/, '') + .toLowerCase() + + if (!host) { + throw new Error('SailPoint tenant is required') + } + + if (host.includes('.')) { + const isAllowed = + /^[a-z0-9.-]+$/.test(host) && ALLOWED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix)) + if (!isAllowed) { + throw new Error( + `SailPoint host "${host}" is not an allowed identitynow.com host. Enter your tenant name (e.g. "acme") or a full *.api.identitynow.com host.` + ) + } + } else { + if (!/^[a-z0-9][a-z0-9-]*$/.test(host)) { + throw new Error(`Invalid SailPoint tenant "${tenant}"`) + } host = `${host}.api.identitynow.com` } + return { host, apiBaseUrl: `https://${host}/${apiVersion}`, @@ -92,10 +121,12 @@ interface CachedToken { } const TOKEN_EXPIRY_BUFFER_MS = 60_000 +const MAX_FETCH_RETRIES = 4 const tokenCache = new Map() function cacheKey(creds: SailPointServerCredentials): string { - return `${creds.tenant}:${creds.clientId}:${creds.apiVersion}` + const { host } = resolveSailPointHosts(creds.tenant, creds.apiVersion) + return `${host}:${creds.clientId}:${creds.apiVersion}` } /** Drops any cached token for these credentials so the next call re-exchanges. */ @@ -112,19 +143,32 @@ export async function getSailPointAccessToken(creds: SailPointServerCredentials) } const { tokenUrl } = resolveSailPointHosts(creds.tenant, creds.apiVersion) - const response = await fetch(tokenUrl, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'client_credentials', - client_id: creds.clientId, - client_secret: creds.clientSecret, - }).toString(), - cache: 'no-store', - }) + + let attempt = 0 + let response: Response + while (true) { + response = await fetch(tokenUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: creds.clientId, + client_secret: creds.clientSecret, + }).toString(), + cache: 'no-store', + }) + // The token endpoint shares SailPoint's per-client_id rate limit, so back off on a 429 too. + if (response.status === 429 && attempt < MAX_FETCH_RETRIES) { + const retryAfterMs = parseRetryAfter(response.headers.get('retry-after')) + attempt += 1 + await sleep(backoffWithJitter(attempt, retryAfterMs)) + continue + } + break + } const data: unknown = await response.json().catch(() => null) if (!response.ok) { @@ -134,7 +178,8 @@ export async function getSailPointAccessToken(creds: SailPointServerCredentials) throw new Error('SailPoint authentication did not return an access token') } - const expiresInSec = typeof data.expires_in === 'number' ? data.expires_in : 3600 + const parsedExpiry = Number(data.expires_in) + const expiresInSec = Number.isFinite(parsedExpiry) && parsedExpiry > 0 ? parsedExpiry : 3600 tokenCache.set(key, { token: data.access_token, expiresAt: Date.now() + Math.max(expiresInSec * 1000 - TOKEN_EXPIRY_BUFFER_MS, 0), @@ -163,7 +208,7 @@ export async function sailpointFetch( buildRequest: (token: string, hosts: SailPointHosts) => { url: string; init: RequestInit }, options: { maxRetries?: number } = {} ): Promise { - const maxRetries = options.maxRetries ?? 4 + const maxRetries = options.maxRetries ?? MAX_FETCH_RETRIES const hosts = resolveSailPointHosts(creds.tenant, creds.apiVersion) let attempt = 0 let refreshedOn401 = false diff --git a/apps/sim/app/api/tools/sailpoint/load/route.ts b/apps/sim/app/api/tools/sailpoint/load/route.ts index f8b8807f600..e96966a30bf 100644 --- a/apps/sim/app/api/tools/sailpoint/load/route.ts +++ b/apps/sim/app/api/tools/sailpoint/load/route.ts @@ -61,7 +61,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { apiVersion: normalizeApiVersion(body.apiVersion), } - const formData = new FormData() + let fileBuffer: Buffer | null = null + let fileName = 'aggregation.csv' + let fileType = 'text/csv' if (body.file && typeof body.file === 'object') { const userFiles = processFilesToUserFiles([body.file as RawFileInput], requestId, logger) @@ -75,11 +77,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const { buffer } = await downloadServableFileFromStorage(userFile, requestId, logger) - formData.append( - 'file', - new Blob([new Uint8Array(buffer)], { type: userFile.type || 'text/csv' }), - userFile.name || 'aggregation.csv' - ) + fileBuffer = buffer + fileName = userFile.name || 'aggregation.csv' + fileType = userFile.type || 'text/csv' } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady @@ -90,22 +90,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } - if (body.operation === 'sailpoint_load_accounts' && body.disableOptimization) { - formData.append('disableOptimization', 'true') - } - + const includeDisableOptimization = + body.operation === 'sailpoint_load_accounts' && body.disableOptimization === true const loadPath = LOAD_PATHS[body.operation] + /** + * Builds a fresh multipart body for every attempt. A single FormData instance can be a consumed + * (non-replayable) stream, so reusing it across the client's 401/429 retries could send the + * aggregation without the CSV - rebuild it per request instead. + */ + const buildFormData = (): FormData => { + const formData = new FormData() + if (fileBuffer) { + formData.append('file', new Blob([new Uint8Array(fileBuffer)], { type: fileType }), fileName) + } + if (includeDisableOptimization) { + formData.append('disableOptimization', 'true') + } + return formData + } + try { logger.info(`[${requestId}] SailPoint aggregation`, { operation: body.operation, apiVersion: creds.apiVersion, - hasFile: formData.has('file'), + hasFile: fileBuffer != null, }) const result = await sailpointFetch(creds, (_token, hosts) => ({ url: `${hosts.apiBaseUrl}/sources/${encodeURIComponent(body.sourceId)}/${loadPath}`, - init: { method: 'POST', body: formData }, + init: { method: 'POST', body: buildFormData() }, })) if (!result.ok) { diff --git a/apps/sim/app/api/tools/sailpoint/query/route.test.ts b/apps/sim/app/api/tools/sailpoint/query/route.test.ts index f1c549f479e..d3e42f2ea40 100644 --- a/apps/sim/app/api/tools/sailpoint/query/route.test.ts +++ b/apps/sim/app/api/tools/sailpoint/query/route.test.ts @@ -124,6 +124,34 @@ describe('SailPoint query route', () => { expect(data.output.results).toEqual([{ _type: 'identity', id: 'i1' }]) }) + it('preserves the aggregation object returned by /search/aggregate', async () => { + const aggregationResult = { + aggregations: { department: { buckets: [{ key: 'Finance', count: 12 }] } }, + hits: [], + } + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse(aggregationResult)) + + const request = createMockRequest('POST', { + ...baseCreds, + tenant: 'acme-aggregate', + operation: 'sailpoint_search_aggregate', + indices: 'identities', + query: 'attributes.department:*', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://acme-aggregate.api.identitynow.com/v2025/search/aggregate' + ) + // The full AggregationResult object is preserved under `item`, not dropped as an empty list. + expect(data.output).toEqual({ item: aggregationResult }) + }) + it('caches the token across calls with the same credentials', async () => { fetchMock .mockResolvedValueOnce(tokenResponse()) @@ -204,6 +232,38 @@ describe('SailPoint query route', () => { expect(fetchMock).not.toHaveBeenCalled() }) + it('rejects a non-SailPoint tenant host without sending credentials (SSRF guard)', async () => { + const request = createMockRequest('POST', { + ...baseCreds, + tenant: 'evil.example.com', + operation: 'sailpoint_list_identities', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(500) + expect(fetchMock).not.toHaveBeenCalled() + expect(data.error).toContain('not an allowed') + }) + + it('accepts a full *.api.identitynow.com host', async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse([{ id: 'i1' }])) + + const request = createMockRequest('POST', { + ...baseCreds, + tenant: 'https://acme.api.identitynow.com', + operation: 'sailpoint_list_identities', + }) + + const response = await POST(request) + + expect(response.status).toBe(200) + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://acme.api.identitynow.com/v2025/identities') + }) + it('propagates a SailPoint error body', async () => { fetchMock .mockResolvedValueOnce(tokenResponse()) diff --git a/apps/sim/app/api/tools/sailpoint/query/route.ts b/apps/sim/app/api/tools/sailpoint/query/route.ts index 87a02a0edc7..dfd022db7b1 100644 --- a/apps/sim/app/api/tools/sailpoint/query/route.ts +++ b/apps/sim/app/api/tools/sailpoint/query/route.ts @@ -167,7 +167,8 @@ function dispatch( query: body.query ? { query: body.query } : undefined, sort: toStringList(body.sort), searchAfter: toStringList(body.searchAfter), - includeNested: body.includeNested, + // Only send includeNested when explicitly false; the API already defaults it to true. + includeNested: body.includeNested === false ? false : undefined, }) return execute( creds, @@ -195,6 +196,7 @@ function dispatch( const searchBody = filterUndefined({ indices: toStringList(body.indices) ?? ['identities'], query: body.query ? { query: body.query } : undefined, + aggregationsDsl: body.aggregationsDsl, }) return execute( creds, @@ -203,7 +205,9 @@ function dispatch( url: `${h.apiBaseUrl}/search/aggregate${qs({ limit: body.limit, offset: body.offset })}`, init: jsonInit(searchBody), }), - 'search' + // /search/aggregate returns an AggregationResult object (aggregations + hits), not an + // array - route it as an item so the buckets are preserved rather than dropped. + 'item' ) } case 'sailpoint_list_identities': diff --git a/apps/sim/blocks/blocks/sailpoint.ts b/apps/sim/blocks/blocks/sailpoint.ts index abe25f40e04..f9ccac275eb 100644 --- a/apps/sim/blocks/blocks/sailpoint.ts +++ b/apps/sim/blocks/blocks/sailpoint.ts @@ -199,6 +199,22 @@ export const SailPointBlock: BlockConfig = { condition: { field: 'operation', value: 'sailpoint_search' }, mode: 'advanced', }, + { + id: 'aggregationsDsl', + title: 'Aggregations', + type: 'code', + language: 'json', + placeholder: '{ "department": { "terms": { "field": "attributes.department" } } }', + condition: { field: 'operation', value: 'sailpoint_search_aggregate' }, + required: { field: 'operation', value: 'sailpoint_search_aggregate' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a SailPoint search aggregations DSL object (Elasticsearch aggregations syntax) defining the buckets or metrics to compute over the matched documents. Return ONLY valid JSON.', + placeholder: 'Describe the aggregation, e.g. "count identities grouped by department"...', + generationType: 'json-object', + }, + }, { id: 'filters', title: 'Filters', @@ -238,6 +254,18 @@ export const SailPointBlock: BlockConfig = { condition: { field: 'operation', value: LIMIT_OPERATIONS }, mode: 'advanced', }, + { + id: 'count', + title: 'Include Total Count', + type: 'dropdown', + options: [ + { label: 'No (default)', id: '' }, + { label: 'Yes', id: 'true' }, + ], + value: () => '', + condition: { field: 'operation', value: LIMIT_OPERATIONS }, + mode: 'advanced', + }, { id: 'defaultFilter', title: 'Default Filter', @@ -575,6 +603,9 @@ export const SailPointBlock: BlockConfig = { const applyPagination = () => { setNum('limit', params.limit) setNum('offset', params.offset) + if (params.count === 'true' || params.count === true) { + mapped.count = true + } } const applyFilters = () => { setStr('filters', params.filters) @@ -595,11 +626,14 @@ export const SailPointBlock: BlockConfig = { setStr('indices', params.indices) setStr('query', params.query) break - case 'sailpoint_search_aggregate': + case 'sailpoint_search_aggregate': { setStr('indices', params.indices) setStr('query', params.query) + const aggregationsDsl = parseOptionalJsonInput(params.aggregationsDsl, 'aggregations') + if (aggregationsDsl !== undefined) mapped.aggregationsDsl = aggregationsDsl applyPagination() break + } case 'sailpoint_list_identities': applyFilters() setStr('defaultFilter', params.defaultFilter) @@ -731,10 +765,15 @@ export const SailPointBlock: BlockConfig = { query: { type: 'string', description: 'Elasticsearch query string' }, includeNested: { type: 'string', description: 'Include nested objects in search results' }, sort: { type: 'string', description: 'Search sort fields' }, + aggregationsDsl: { + type: 'json', + description: 'Elasticsearch aggregations DSL for search aggregate', + }, filters: { type: 'string', description: 'V3 filter expression' }, sorters: { type: 'string', description: 'Sort expression' }, limit: { type: 'number', description: 'Maximum records to return' }, offset: { type: 'number', description: 'Pagination offset' }, + count: { type: 'string', description: 'Include the total matching record count' }, defaultFilter: { type: 'string', description: 'Identity default filter (CORRELATED_ONLY or NONE)', @@ -794,7 +833,7 @@ export const SailPointBlock: BlockConfig = { } export const SailPointBlockMeta = { - tags: ['identity', 'operations'], + tags: ['identity', 'automation'], url: 'https://www.sailpoint.com', templates: [ { @@ -811,7 +850,7 @@ export const SailPointBlockMeta = { icon: SailPointIcon, title: 'SailPoint access request bot', prompt: - 'Build a workflow where a user describes the access they need in Chat, the agent searches SailPoint entitlements and access profiles, and submits a SailPoint access request on their behalf with a correlation note in client metadata.', + 'Build a Slack bot where a user describes the access they need, the agent searches SailPoint entitlements and access profiles, and submits a SailPoint access request on their behalf with a correlation note in client metadata.', modules: ['agent', 'workflows'], category: 'operations', tags: ['automation', 'self-service'], @@ -840,7 +879,7 @@ export const SailPointBlockMeta = { icon: SailPointIcon, title: 'SailPoint leaver access revocation', prompt: - 'Create a workflow that, given a departing employee, searches their SailPoint identity access, and submits revoke access requests for each directly-assigned entitlement with a comment referencing the offboarding ticket.', + 'Create a workflow that, given a departing employee, searches their SailPoint identity access, and submits revoke access requests for each directly-assigned entitlement with a comment referencing the offboarding ticket in Jira.', modules: ['agent', 'workflows'], category: 'operations', tags: ['automation', 'security'], diff --git a/apps/sim/lib/api/contracts/tools/sailpoint.ts b/apps/sim/lib/api/contracts/tools/sailpoint.ts index 0501c5ab84b..3286bca930e 100644 --- a/apps/sim/lib/api/contracts/tools/sailpoint.ts +++ b/apps/sim/lib/api/contracts/tools/sailpoint.ts @@ -95,6 +95,7 @@ const searchAggregateSchema = z.object({ operation: z.literal('sailpoint_search_aggregate'), indices: stringListField, query: z.string().optional(), + aggregationsDsl: z.preprocess(parseJson, z.record(z.string(), z.unknown())).optional(), limit: limitField(LIMIT_STANDARD), offset: offsetField, }) diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 7b2f1ca82e5..6a3f02d31aa 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -16156,7 +16156,7 @@ }, { "name": "Search Aggregate", - "description": "Return aggregation buckets for a SailPoint search query (e.g. counts grouped by a field)." + "description": "Return the aggregation result (buckets under `aggregations`, plus `hits`) for a SailPoint search query." }, { "name": "List Identities", @@ -16261,7 +16261,7 @@ "authType": "api-key", "category": "tools", "integrationType": "security", - "tags": ["identity", "operations"] + "tags": ["identity", "automation"] }, { "type": "salesforce", diff --git a/apps/sim/tools/sailpoint/search_aggregate.ts b/apps/sim/tools/sailpoint/search_aggregate.ts index 0c241e20631..4fa577887a2 100644 --- a/apps/sim/tools/sailpoint/search_aggregate.ts +++ b/apps/sim/tools/sailpoint/search_aggregate.ts @@ -1,24 +1,20 @@ import { SAILPOINT_QUERY_ROUTE, sailpointCredentialParams, - sailpointSearchOutputs, + sailpointItemOutputs, unwrapSailPointOutput, } from '@/tools/sailpoint/common' -import type { - SailPointSearchAggregateParams, - SailPointSearchOutput, - SailPointSearchResponse, -} from '@/tools/sailpoint/types' +import type { SailPointItemResponse, SailPointSearchAggregateParams } from '@/tools/sailpoint/types' import type { ToolConfig } from '@/tools/types' export const sailpointSearchAggregateTool: ToolConfig< SailPointSearchAggregateParams, - SailPointSearchResponse + SailPointItemResponse > = { id: 'sailpoint_search_aggregate', name: 'SailPoint Search Aggregate', description: - 'Return aggregation buckets for a SailPoint search query (e.g. counts grouped by a field).', + 'Return the aggregation result (buckets under `aggregations`, plus `hits`) for a SailPoint search query.', version: '1.0.0', params: { @@ -33,7 +29,14 @@ export const sailpointSearchAggregateTool: ToolConfig< type: 'string', required: false, visibility: 'user-or-llm', - description: 'Elasticsearch query string', + description: 'Elasticsearch query string to scope the documents before aggregating', + }, + aggregationsDsl: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Elasticsearch aggregations DSL object defining the buckets/metrics to compute, e.g. { "department": { "terms": { "field": "attributes.department" } } }', }, limit: { type: 'number', @@ -61,12 +64,13 @@ export const sailpointSearchAggregateTool: ToolConfig< apiVersion: params.apiVersion, indices: params.indices, query: params.query, + aggregationsDsl: params.aggregationsDsl, limit: params.limit, offset: params.offset, }), }, - transformResponse: (response) => unwrapSailPointOutput(response), + transformResponse: (response) => unwrapSailPointOutput<{ item: unknown }>(response), - outputs: sailpointSearchOutputs, + outputs: sailpointItemOutputs, } diff --git a/apps/sim/tools/sailpoint/types.ts b/apps/sim/tools/sailpoint/types.ts index 496ffd11c59..ff0efbeabe4 100644 --- a/apps/sim/tools/sailpoint/types.ts +++ b/apps/sim/tools/sailpoint/types.ts @@ -73,6 +73,7 @@ export interface SailPointSearchCountParams extends SailPointCredentials { export interface SailPointSearchAggregateParams extends SailPointCredentials { indices?: string[] | string query?: string + aggregationsDsl?: Record | string limit?: number offset?: number } From 52d537210747f9162ab0d35cc425fb9ea3c6872b Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 30 Jul 2026 00:40:32 -0700 Subject: [PATCH 3/5] 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. --- .../docs/en/integrations/sailpoint.mdx | 2 +- .../api/tools/sailpoint/query/route.test.ts | 22 +++++++++++++++++++ .../app/api/tools/sailpoint/query/route.ts | 20 ++++++++++++++--- apps/sim/blocks/blocks/sailpoint.ts | 22 ++++++++++++++----- apps/sim/lib/api/contracts/tools/sailpoint.ts | 7 +++++- apps/sim/tools/sailpoint/search_aggregate.ts | 2 +- 6 files changed, 63 insertions(+), 12 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/sailpoint.mdx b/apps/docs/content/docs/en/integrations/sailpoint.mdx index 25652050a8f..5dc2db03069 100644 --- a/apps/docs/content/docs/en/integrations/sailpoint.mdx +++ b/apps/docs/content/docs/en/integrations/sailpoint.mdx @@ -725,7 +725,7 @@ Return the aggregation result (buckets under `aggregations`, plus `hits`) for a | --------- | ---- | -------- | ----------- | | `indices` | json | No | Indices to aggregate over \(defaults to \["identities"\]\) | | `query` | string | No | Elasticsearch query string to scope the documents before aggregating | -| `aggregationsDsl` | json | No | Elasticsearch aggregations DSL object defining the buckets/metrics to compute, e.g. \{ "department": \{ "terms": \{ "field": "attributes.department" \} \} \} | +| `aggregationsDsl` | json | Yes | Elasticsearch aggregations DSL object defining the buckets/metrics to compute, e.g. \{ "department": \{ "terms": \{ "field": "attributes.department" \} \} \} | | `limit` | number | No | Maximum number of aggregation results \(max 250\) | | `offset` | number | No | Pagination offset \(0-based\) | diff --git a/apps/sim/app/api/tools/sailpoint/query/route.test.ts b/apps/sim/app/api/tools/sailpoint/query/route.test.ts index d3e42f2ea40..130a6b8532c 100644 --- a/apps/sim/app/api/tools/sailpoint/query/route.test.ts +++ b/apps/sim/app/api/tools/sailpoint/query/route.test.ts @@ -139,6 +139,7 @@ describe('SailPoint query route', () => { operation: 'sailpoint_search_aggregate', indices: 'identities', query: 'attributes.department:*', + aggregationsDsl: { department: { terms: { field: 'attributes.department' } } }, }) const response = await POST(request) @@ -148,10 +149,31 @@ describe('SailPoint query route', () => { expect(fetchMock.mock.calls[1]?.[0]).toBe( 'https://acme-aggregate.api.identitynow.com/v2025/search/aggregate' ) + const aggInit = fetchMock.mock.calls[1]?.[1] as RequestInit + expect(JSON.parse(aggInit.body as string)).toEqual({ + indices: ['identities'], + query: { query: 'attributes.department:*' }, + aggregationType: 'DSL', + aggregationsDsl: { department: { terms: { field: 'attributes.department' } } }, + }) // The full AggregationResult object is preserved under `item`, not dropped as an empty list. expect(data.output).toEqual({ item: aggregationResult }) }) + it('rejects a search aggregate without an aggregations definition', async () => { + const request = createMockRequest('POST', { + ...baseCreds, + tenant: 'acme-agg-missing', + operation: 'sailpoint_search_aggregate', + indices: 'identities', + }) + + const response = await POST(request) + + expect(response.status).toBe(400) + expect(fetchMock).not.toHaveBeenCalled() + }) + it('caches the token across calls with the same credentials', async () => { fetchMock .mockResolvedValueOnce(tokenResponse()) diff --git a/apps/sim/app/api/tools/sailpoint/query/route.ts b/apps/sim/app/api/tools/sailpoint/query/route.ts index dfd022db7b1..e2e098a3728 100644 --- a/apps/sim/app/api/tools/sailpoint/query/route.ts +++ b/apps/sim/app/api/tools/sailpoint/query/route.ts @@ -57,11 +57,22 @@ function qs(params: Record): string { return serialized ? `?${serialized}` : '' } -/** Normalizes an array/JSON-string/comma-list into a string[] (or undefined when empty). */ +/** Coerces a primitive (string/number/boolean) to a non-empty string token, else null. */ +function coerceToken(value: unknown): string | null { + if (typeof value === 'string') return value.length ? value : null + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + return null +} + +/** + * Normalizes an array/JSON-string/comma-list into a string[] (or undefined when empty). Numeric or + * boolean array elements are coerced to strings rather than dropped - SailPoint's `searchAfter` + * cursor mirrors sort values, which may include numbers, and must be sent as strings. + */ function toStringList(value: unknown): string[] | undefined { if (value == null) return undefined if (Array.isArray(value)) { - const arr = value.filter((v): v is string => typeof v === 'string' && v.length > 0) + const arr = value.map(coerceToken).filter((v): v is string => v !== null) return arr.length ? arr : undefined } if (typeof value === 'string') { @@ -71,7 +82,7 @@ function toStringList(value: unknown): string[] | undefined { try { const parsed: unknown = JSON.parse(trimmed) if (Array.isArray(parsed)) { - const arr = parsed.filter((v): v is string => typeof v === 'string') + const arr = parsed.map(coerceToken).filter((v): v is string => v !== null) return arr.length ? arr : undefined } } catch { @@ -196,6 +207,9 @@ function dispatch( const searchBody = filterUndefined({ indices: toStringList(body.indices) ?? ['identities'], query: body.query ? { query: body.query } : undefined, + // Interpret aggregationsDsl as Elasticsearch DSL (also SailPoint's default) rather than + // the typed SAILPOINT aggregation mode. + aggregationType: 'DSL', aggregationsDsl: body.aggregationsDsl, }) return execute( diff --git a/apps/sim/blocks/blocks/sailpoint.ts b/apps/sim/blocks/blocks/sailpoint.ts index f9ccac275eb..32b65a43a89 100644 --- a/apps/sim/blocks/blocks/sailpoint.ts +++ b/apps/sim/blocks/blocks/sailpoint.ts @@ -199,6 +199,14 @@ export const SailPointBlock: BlockConfig = { condition: { field: 'operation', value: 'sailpoint_search' }, mode: 'advanced', }, + { + id: 'searchAfter', + title: 'Search After', + type: 'short-input', + placeholder: '["John Doe","2c9180...id"] (cursor from the last result to page past 10k)', + condition: { field: 'operation', value: 'sailpoint_search' }, + mode: 'advanced', + }, { id: 'aggregationsDsl', title: 'Aggregations', @@ -393,25 +401,25 @@ export const SailPointBlock: BlockConfig = { }, { id: 'entitlements', - title: 'Entitlements Filter', + title: 'Entitlement IDs', type: 'short-input', - placeholder: 'true / false', + placeholder: 'Comma-separated entitlement IDs to filter by', condition: { field: 'operation', value: 'sailpoint_list_certification_review_items' }, mode: 'advanced', }, { id: 'accessProfiles', - title: 'Access Profiles Filter', + title: 'Access Profile IDs', type: 'short-input', - placeholder: 'true / false', + placeholder: 'Comma-separated access profile IDs to filter by', condition: { field: 'operation', value: 'sailpoint_list_certification_review_items' }, mode: 'advanced', }, { id: 'roles', - title: 'Roles Filter', + title: 'Role IDs', type: 'short-input', - placeholder: 'true / false', + placeholder: 'Comma-separated role IDs to filter by', condition: { field: 'operation', value: 'sailpoint_list_certification_review_items' }, mode: 'advanced', }, @@ -617,6 +625,7 @@ export const SailPointBlock: BlockConfig = { setStr('indices', params.indices) setStr('query', params.query) setStr('sort', params.sort) + setStr('searchAfter', params.searchAfter) if (params.includeNested === 'false' || params.includeNested === false) { mapped.includeNested = false } @@ -765,6 +774,7 @@ export const SailPointBlock: BlockConfig = { query: { type: 'string', description: 'Elasticsearch query string' }, includeNested: { type: 'string', description: 'Include nested objects in search results' }, sort: { type: 'string', description: 'Search sort fields' }, + searchAfter: { type: 'string', description: 'searchAfter cursor for deep search pagination' }, aggregationsDsl: { type: 'json', description: 'Elasticsearch aggregations DSL for search aggregate', diff --git a/apps/sim/lib/api/contracts/tools/sailpoint.ts b/apps/sim/lib/api/contracts/tools/sailpoint.ts index 3286bca930e..080196840e8 100644 --- a/apps/sim/lib/api/contracts/tools/sailpoint.ts +++ b/apps/sim/lib/api/contracts/tools/sailpoint.ts @@ -95,7 +95,12 @@ const searchAggregateSchema = z.object({ operation: z.literal('sailpoint_search_aggregate'), indices: stringListField, query: z.string().optional(), - aggregationsDsl: z.preprocess(parseJson, z.record(z.string(), z.unknown())).optional(), + aggregationsDsl: z.preprocess( + parseJson, + z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, { + message: 'aggregationsDsl is required and must define at least one aggregation', + }) + ), limit: limitField(LIMIT_STANDARD), offset: offsetField, }) diff --git a/apps/sim/tools/sailpoint/search_aggregate.ts b/apps/sim/tools/sailpoint/search_aggregate.ts index 4fa577887a2..9ef1a8bf47f 100644 --- a/apps/sim/tools/sailpoint/search_aggregate.ts +++ b/apps/sim/tools/sailpoint/search_aggregate.ts @@ -33,7 +33,7 @@ export const sailpointSearchAggregateTool: ToolConfig< }, aggregationsDsl: { type: 'json', - required: false, + required: true, visibility: 'user-or-llm', description: 'Elasticsearch aggregations DSL object defining the buckets/metrics to compute, e.g. { "department": { "terms": { "field": "attributes.department" } } }', From e1b8784367bb30beb39d80fccec8e1e029a97cf5 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 30 Jul 2026 01:01:08 -0700 Subject: [PATCH 4/5] 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. --- apps/sim/app/api/tools/sailpoint/client.ts | 7 +++++- .../api/tools/sailpoint/query/route.test.ts | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/tools/sailpoint/client.ts b/apps/sim/app/api/tools/sailpoint/client.ts index c92617f5450..0c28ab0531f 100644 --- a/apps/sim/app/api/tools/sailpoint/client.ts +++ b/apps/sim/app/api/tools/sailpoint/client.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { sleep } from '@sim/utils/helpers' import { isRecordLike } from '@sim/utils/object' import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' @@ -126,7 +127,11 @@ const tokenCache = new Map() function cacheKey(creds: SailPointServerCredentials): string { const { host } = resolveSailPointHosts(creds.tenant, creds.apiVersion) - return `${host}:${creds.clientId}:${creds.apiVersion}` + // Bind the cache entry to the exact client_secret (hashed) so a caller with a matching + // tenant/clientId but the wrong secret can never reuse another principal's cached token - a + // mismatched secret produces a different key, misses the cache, and fails the token exchange. + const secretHash = createHash('sha256').update(creds.clientSecret).digest('hex').slice(0, 16) + return `${host}:${creds.clientId}:${creds.apiVersion}:${secretHash}` } /** Drops any cached token for these credentials so the next call re-exchanges. */ diff --git a/apps/sim/app/api/tools/sailpoint/query/route.test.ts b/apps/sim/app/api/tools/sailpoint/query/route.test.ts index 130a6b8532c..3876105a0e7 100644 --- a/apps/sim/app/api/tools/sailpoint/query/route.test.ts +++ b/apps/sim/app/api/tools/sailpoint/query/route.test.ts @@ -197,6 +197,30 @@ describe('SailPoint query route', () => { expect(fetchMock.mock.calls[2]?.[0]).toContain('/v2025/accounts') }) + it('does not share a cached token across different client secrets', async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse([{ id: 'a1' }])) + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse([{ id: 'a2' }])) + + const makeRequest = (secret: string) => + createMockRequest('POST', { + clientId: 'shared-client', + clientSecret: secret, + tenant: 'acme-secret', + operation: 'sailpoint_list_accounts', + }) + + await POST(makeRequest('secret-A')) + await POST(makeRequest('secret-B')) + + // A different secret must not reuse the first principal's token: 2 exchanges + 2 API calls. + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(fetchMock.mock.calls[0]?.[0]).toContain('/oauth/token') + expect(fetchMock.mock.calls[2]?.[0]).toContain('/oauth/token') + }) + it('backs off and retries on a 429 response', async () => { fetchMock .mockResolvedValueOnce(tokenResponse()) From 531ae54116fd2d0c3149f331993b85c88c9e15e1 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 30 Jul 2026 01:11:19 -0700 Subject: [PATCH 5/5] 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. --- apps/sim/app/api/tools/sailpoint/client.ts | 5 ++- .../api/tools/sailpoint/query/route.test.ts | 40 +++++++++++++++++++ .../app/api/tools/sailpoint/query/route.ts | 18 +++++++-- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/tools/sailpoint/client.ts b/apps/sim/app/api/tools/sailpoint/client.ts index 0c28ab0531f..f0964980096 100644 --- a/apps/sim/app/api/tools/sailpoint/client.ts +++ b/apps/sim/app/api/tools/sailpoint/client.ts @@ -185,9 +185,12 @@ export async function getSailPointAccessToken(creds: SailPointServerCredentials) const parsedExpiry = Number(data.expires_in) const expiresInSec = Number.isFinite(parsedExpiry) && parsedExpiry > 0 ? parsedExpiry : 3600 + // Use the smaller of the 60s buffer and 10% of the lifetime so a short-lived token (expires_in + // under ~60s) still gets cached for most of its life instead of expiring immediately. + const bufferMs = Math.min(TOKEN_EXPIRY_BUFFER_MS, expiresInSec * 100) tokenCache.set(key, { token: data.access_token, - expiresAt: Date.now() + Math.max(expiresInSec * 1000 - TOKEN_EXPIRY_BUFFER_MS, 0), + expiresAt: Date.now() + Math.max(expiresInSec * 1000 - bufferMs, 0), }) return data.access_token } diff --git a/apps/sim/app/api/tools/sailpoint/query/route.test.ts b/apps/sim/app/api/tools/sailpoint/query/route.test.ts index 3876105a0e7..1da4e00ec3b 100644 --- a/apps/sim/app/api/tools/sailpoint/query/route.test.ts +++ b/apps/sim/app/api/tools/sailpoint/query/route.test.ts @@ -124,6 +124,46 @@ describe('SailPoint query route', () => { expect(data.output.results).toEqual([{ _type: 'identity', id: 'i1' }]) }) + it('returns the total from search count', async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce( + new Response(null, { status: 204, headers: { 'X-Total-Count': '42' } }) + ) + + const request = createMockRequest('POST', { + ...baseCreds, + tenant: 'acme-count', + operation: 'sailpoint_search_count', + indices: 'identities', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.output).toEqual({ total: 42 }) + }) + + it('errors instead of reporting zero when search count has no total header', async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + + const request = createMockRequest('POST', { + ...baseCreds, + tenant: 'acme-count-none', + operation: 'sailpoint_search_count', + indices: 'identities', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(502) + expect(data.success).toBe(false) + }) + it('preserves the aggregation object returned by /search/aggregate', async () => { const aggregationResult = { aggregations: { department: { buckets: [{ key: 'Finance', count: 12 }] } }, diff --git a/apps/sim/app/api/tools/sailpoint/query/route.ts b/apps/sim/app/api/tools/sailpoint/query/route.ts index e2e098a3728..a8de28500e1 100644 --- a/apps/sim/app/api/tools/sailpoint/query/route.ts +++ b/apps/sim/app/api/tools/sailpoint/query/route.ts @@ -145,12 +145,22 @@ async function execute( case 'item': output = { item: result.data ?? null } break - case 'count': - output = { - total: - readTotalCount(result.headers) ?? (typeof result.data === 'number' ? result.data : 0), + case 'count': { + const total = + readTotalCount(result.headers) ?? (typeof result.data === 'number' ? result.data : null) + // Do not report an unknown count as 0 - that reads as "no matches". Surface it as an error. + if (total === null) { + return NextResponse.json( + { + success: false, + error: 'SailPoint did not return a total count (X-Total-Count missing)', + }, + { status: 502 } + ) } + output = { total } break + } case 'write': output = { accepted: result.ok, status: result.status } break