diff --git a/docs/connectivity/saas-connectivity/connector-commands/account-list.md b/docs/connectivity/saas-connectivity/connector-commands/account-list.md index e7f922db61526..5757b45c9f126 100644 --- a/docs/connectivity/saas-connectivity/connector-commands/account-list.md +++ b/docs/connectivity/saas-connectivity/connector-commands/account-list.md @@ -97,7 +97,7 @@ export const connector = async () => { const airtable = new AirtableClient(config) return createConnector() - .stdAccountList(async (context: Context, input: undefined, res: Response) => { + .stdAccountList(async (context: Context, input: StdAccountListInput, res: Response) => { const accounts = await airtable.getAllAccounts() for (const account of accounts) { diff --git a/docs/connectivity/saas-connectivity/connector-commands/application-discovery-list.md b/docs/connectivity/saas-connectivity/connector-commands/application-discovery-list.md new file mode 100644 index 0000000000000..8a91d9752972c --- /dev/null +++ b/docs/connectivity/saas-connectivity/connector-commands/application-discovery-list.md @@ -0,0 +1,155 @@ +--- +id: application-discovery-list +title: Application Discovery List +pagination_label: Application Discovery List +sidebar_label: Application Discovery List +keywords: ['connectivity', 'connectors', 'application discovery'] +description: Return the applications that the source knows about. +slug: /connectivity/saas-connectivity/commands/application-discovery-list +tags: ['Connectivity', 'Connector Command'] +--- + +| Input/Output | Data Type | +| :----------- | :------------------------------------------: | +| Input | StdApplicationDiscoveryListInput | +| Output | StdApplicationDiscoveryListOutput | + +### Example StdApplicationDiscoveryListInput + +```javascript +{ + "datasetId": "sso-okta:applications" +} +``` + +### Example StdApplicationDiscoveryListOutput + +```javascript +{ + "id": "app-5678", + "primaryAppName": "Slack", + "description": "Team messaging", + "status": "ACTIVE", + "connectorCategory": "sso", + "appUrl": "https://example.slack.com", + "usersCount": "250", + "riskLevel": "Low" +} +``` + +## Description + +The application discovery list command returns the applications that the source knows about. For example, an identity provider (SSO) or a configuration management database (CMDB) tracks the apps that a company uses. SailPoint Identity Security shows the results in Application Discovery. + +The tenant must have the Application Discovery license (`idn:application-discovery`). + +To use this command, you must specify this value in the `commands` array: `std:application-discovery:list` + +### Declare datasets in the connector spec + +Add a `datasets` object to `connector-spec.json`. Each key is a dataset ID. Set `type` to `std:application_discovery`. + +```json +{ + "commands": ["std:test-connection", "std:application-discovery:list"], + "datasets": { + "sso-okta:applications": { + "type": "std:application_discovery", + "name": "discovered-application", + "identityAttribute": "id", + "displayAttribute": "primaryAppName", + "schema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "primaryAppName": { "type": "string" }, + "description": { "type": "string" } + }, + "required": ["id", "primaryAppName", "description"] + } + } + } +} +``` + +### When SailPoint Identity Security calls the command + +An application discovery starts in one of these ways: + +- An admin calls the [start application discovery API](/docs/api/start-application-discovery-v-1) with the dataset IDs. On-demand runs have a daily limit for each source. +- A source schedule of type `APPLICATION_DISCOVERY` starts it. The schedule has a cron expression and the dataset IDs: + +```json +{ + "type": "APPLICATION_DISCOVERY", + "cronExpression": "15 * * * *", + "config": { + "datasetIds": ["sso-okta:applications"] + } +} +``` + +SailPoint Identity Security sends `input.datasetIds` with the datasets to discover. SailPoint Identity Security can also send `additionalParameters` to pass extra data to some SailPoint connectors, for example the location of a file to read. Your connector does not need to use it. + +If an application is missing from two discovery runs in a row, SailPoint Identity Security sets its status to `INACTIVE`. + +### Output fields + +Each application needs `id`, `primaryAppName`, and `description`. All other fields are optional. + +| Field | Values | +| :---- | :----- | +| `status` | A string, for example `ACTIVE` or `INACTIVE`. | +| `connectorCategory` | The kind of source, for example `sso` or `cmdb`. | +| `riskLevel` | `High`, `Medium`, or `Low`. | +| `riskScore` | A number. | +| `usersCount` | A string. Convert numbers with `String()`. | + +The application list in SailPoint Identity Security shows the name, description, category, discovery connector, business criticality, status, and the first and last discovery dates. The details page of each application shows the other fields. + +## Implementation + +The SDK has two ways to register this command, but only one can be used at a time. + +| Method | Use it when | +| :----- | :---------- | +| `stdApplicationDiscoveryListWithDataset` | Your connector declares datasets. This is the method for new connectors. | +| `stdApplicationDiscoveryList` | Your connector has no datasets. The input is an empty object. | + +With `stdApplicationDiscoveryListWithDataset`, the SDK calls your handler once for each dataset ID and adds `datasetId` to each object that you send. + +Call `res.send()` once for each application. + +```typescript +import { + Context, + createConnector, + readConfig, + Response, + StdApplicationDiscoveryListInput, + StdApplicationDiscoveryListOutput, +} from '@sailpoint/connector-sdk' +import { MyClient } from './my-client' + +export const connector = async () => { + const config = await readConfig() + const client = new MyClient(config) + + return createConnector() + .stdApplicationDiscoveryListWithDataset(async (context: Context, input: StdApplicationDiscoveryListInput, res: Response) => { + const apps: { id: string; name: string; description: string; url: string; userCount: number; active: boolean }[] = + await client.getApplications(input.datasetId) + for (const app of apps) { + res.send({ + id: app.id, + primaryAppName: app.name, + description: app.description, + status: app.active ? 'ACTIVE' : 'INACTIVE', + connectorCategory: 'sso', + appUrl: app.url, + usersCount: String(app.userCount), + }) + } + }) +} +``` diff --git a/docs/connectivity/saas-connectivity/connector-commands/authenticate.md b/docs/connectivity/saas-connectivity/connector-commands/authenticate.md new file mode 100644 index 0000000000000..888a9631f7932 --- /dev/null +++ b/docs/connectivity/saas-connectivity/connector-commands/authenticate.md @@ -0,0 +1,125 @@ +--- +id: authenticate +title: Authenticate +pagination_label: Authenticate +sidebar_label: Authenticate +keywords: ['connectivity', 'connectors', 'authenticate', 'pass-through authentication'] +description: Check a user's credentials on the source for pass-through authentication. +slug: /connectivity/saas-connectivity/commands/authenticate +tags: ['Connectivity', 'Connector Command'] +--- + +| Input/Output | Data Type | +| :----------- | :---------------------: | +| Input | StdAuthenticateInput | +| Output | StdAuthenticateOutput | + +### Example StdAuthenticateInput + +```javascript +{ + "identity": "john.doe", + "key": { + "simple": { + "id": "john.doe" + } + }, + "username": "john.doe", + "password": "", + "schema": { + "displayAttribute": "displayName", + "identityAttribute": "id", + "groupAttribute": "groups", + "attributes": [ + { + "name": "id", + "description": "Unique ID", + "type": "string" + } + ] + } +} +``` + +### Example StdAuthenticateOutput + +```javascript +{ + "identity": "john.doe", + "key": { + "simple": { + "id": "john.doe" + } + }, + "disabled": false, + "locked": false, + "attributes": { + "id": "john.doe", + "displayName": "John Doe", + "email": "example@sailpoint.com" + } +} +``` + +## Description + +The authenticate command supports pass-through authentication. With pass-through authentication, users sign in to SailPoint Identity Security with their username and password from the source. SailPoint Identity Security does not store these passwords. It sends them to the connector, and the connector tests them against the source. + +If the admin sets the sign-in method to **Directory Connection** with this source, SailPoint Identity Security sends `std:authenticate` each time a user signs in. The connector must do these steps: + +1. Test the username and password against the source. +2. If the credentials are valid, get the user's account from the source. +3. Send the account to SailPoint Identity Security. SailPoint Identity Security then lets the user sign in. + +If the credentials are not valid, throw a `ConnectorError`. SailPoint Identity Security then rejects the sign-in. + +The output has the same shape as the account read output, and it can also include `permissions`. The input includes the account schema of the source in `input.schema`. Use it to decide which attributes to return. + +To use this command, you must specify this value in the `commands` array: `std:authenticate` + +### Disabled and locked accounts + +Let the source decide whether a disabled or locked user can sign in. If the source accepts the credentials, send the account. Set `disabled` and `locked` to the values from the source. + +### Usernames + +The username that reaches the connector depends on what the user types and on the account correlation of the identity profile. If the user cannot sign in, and the connector logs show no `std:authenticate` command, look at the correlation settings first. + +## Implementation + +```typescript +import { + ConnectorError, + Context, + createConnector, + readConfig, + Response, + StdAuthenticateInput, + StdAuthenticateOutput, +} from '@sailpoint/connector-sdk' +import { MyClient } from './my-client' + +export const connector = async () => { + const config = await readConfig() + const client = new MyClient(config) + + return createConnector() + .stdAuthenticate(async (context: Context, input: StdAuthenticateInput, res: Response) => { + const account = await client.authenticate(input.username, input.password) + if (!account) { + throw new ConnectorError(`Authentication failed for ${input.username}`) + } + res.send({ + identity: account.id, + disabled: !account.active, + locked: account.locked, + attributes: { + id: account.id, + displayName: account.displayName, + email: account.email, + }, + }) + }) +} +``` + diff --git a/docs/connectivity/saas-connectivity/connector-commands/config-options-read.md b/docs/connectivity/saas-connectivity/connector-commands/config-options-read.md new file mode 100644 index 0000000000000..f059704a72378 --- /dev/null +++ b/docs/connectivity/saas-connectivity/connector-commands/config-options-read.md @@ -0,0 +1,111 @@ +--- +id: config-options-read +title: Config Options Read +pagination_label: Config Options Read +sidebar_label: Config Options Read +keywords: ['connectivity', 'connectors', 'config options', 'dynamicCheckboxList'] +description: Return a list of options for a source config field. +slug: /connectivity/saas-connectivity/commands/config-options-read +tags: ['Connectivity', 'Connector Command'] +--- + +| Input/Output | Data Type | +| :----------- | :----------------------: | +| Input | StdConfigOptionsInput | +| Output | StdConfigOptionsOutput | + +### Example StdConfigOptionsInput + +```javascript +{ + "key": "cloud-scope" +} +``` + +### Example StdConfigOptionsOutput + +```javascript +{ + "options": { + "cloud-scope": [ + { "scopeId": "123456789012", "name": "Production" }, + { "scopeId": "210987654321", "name": "Development" } + ] + } +} +``` + +## Description + +The config options read command returns the values for a source config field. An admin chooses from these values. If the values come from the source, you cannot list them in `connector-spec.json`. Use this command for these fields. + +To use this command, you must specify this value in the `commands` array: `std:config-options:read` + +### Add the field to the connector spec + +Use a field of type `dynamicCheckboxList` in the `sourceConfig` section of `connector-spec.json`. SailPoint Identity Security shows this field as a searchable list of checkboxes, with options to include all values and to refresh the list. + +```json +{ + "key": "cloud-scope", + "label": "Cloud Scope", + "required": true, + "type": "dynamicCheckboxList", + "valuePath": "$.scopeId" +} +``` + +| Property | Description | +| :---------- | :---------- | +| `key` | The name of the field. SailPoint Identity Security sends this value in `input.key`. | +| `valuePath` | A JSONPath expression that selects the value to save from each option. If the options are objects, use this property. | + +### When SailPoint Identity Security calls the command + +1. The admin opens the source config page. +2. If SailPoint Identity Security has no saved options for the field, or the admin selects **Refresh**, it sends `std:config-options:read`. +3. The command runs in the background. The field is not available until the command finishes. The command times out after 180 seconds. +4. SailPoint Identity Security saves the options and shows them in the field. + +The command receives the source config that the admin saved before. Use `readConfig()` to get the credentials and other values that you need to connect to the source. + +### Output values + +`output.options` maps the field key to its options. The value can be one of these: + +- A string +- An array of strings +- An array of objects with string values. Use `valuePath` to select the value from each object. + +## Implementation + +```typescript +import { + ConnectorError, + Context, + createConnector, + readConfig, + Response, + StdConfigOptionsInput, + StdConfigOptionsOutput, +} from '@sailpoint/connector-sdk' +import { MyClient } from './my-client' + +export const connector = async () => { + const config = await readConfig() + const client = new MyClient(config) + + return createConnector() + .stdConfigOptions(async (context: Context, input: StdConfigOptionsInput, res: Response) => { + if (input.key !== 'cloud-scope') { + throw new ConnectorError(`Unsupported config option key: ${input.key}`) + } + const scopes: { id: string; name: string }[] = await client.getScopes() + res.send({ + options: { + 'cloud-scope': scopes.map((scope) => ({ scopeId: scope.id, name: scope.name })), + }, + }) + }) +} +``` diff --git a/docs/connectivity/saas-connectivity/connector-commands/machine-identity-list.md b/docs/connectivity/saas-connectivity/connector-commands/machine-identity-list.md new file mode 100644 index 0000000000000..7bf90fcbe07ca --- /dev/null +++ b/docs/connectivity/saas-connectivity/connector-commands/machine-identity-list.md @@ -0,0 +1,161 @@ +--- +id: machine-identity-list +title: Machine Identity List +pagination_label: Machine Identity List +sidebar_label: Machine Identity List +keywords: ['connectivity', 'connectors', 'machine identity list', 'agent list', 'dataset'] +description: Aggregate machine identities from one or more datasets on the source. +slug: /connectivity/saas-connectivity/commands/machine-identity-list +tags: ['Connectivity', 'Connector Command'] +--- + +| Input/Output | Data Type | +| :----------- | :-----------------------------: | +| Input | StdMachineIdentityListInput | +| Output | StdMachineIdentityListOutput | + +### Example StdMachineIdentityListInput + +```javascript +{ + "datasetId": "gcp:vertex", + "datasetSchema": { + "name": "GCP Vertex", + "displayAttribute": "name", + "identityAttribute": "identity", + "attributes": [ + { + "name": "identity", + "description": "The unique identifier of the agent.", + "type": "string" + }, + { + "name": "name", + "description": "The resource name of the agent.", + "type": "string" + } + ], + "config": { + "datasetType": "std:machine-identity", + "datasetId": "gcp:vertex" + } + } +} +``` + +### Example StdMachineIdentityListOutput + +```javascript +{ + "identity": "agent-5678", + "attributes": { + "identity": "agent-5678", + "name": "Support agent", + "projectId": "my-project", + "locationId": "us-central1" + } +} +``` + +## Description + +The machine identity list command aggregates machine identities from the source. A machine identity is an identity that a person does not use directly, for example an AI agent, a service account, or a bot. Sailpoint Identity Security shows the results on the **Machine Identity Aggregations** page of the source. + +The tenant must have a Machine Identity Security or an Agent Identity Security license. + +To use this command, you must specify this value in the `commands` array: `std:machine-identity:list` + +:::note Agent list is deprecated + +The SDK also has `stdAgentList` (`std:agent:list`), which has the same input and output. Machine identity aggregation replaces AI agent aggregation, and it supports more kinds of machine identities. Use `stdMachineIdentityList` in new connectors. Some tenants still send `std:agent:list` during the change, so existing connectors can implement both commands with the same handler. + +::: + +### Declare datasets in the connector spec + +Add a `datasets` object to `connector-spec.json`. Each key is a dataset ID. Set `type` to `std:machine-identity`. + +```json +{ + "commands": ["std:test-connection", "std:machine-identity:list"], + "datasets": { + "gcp:vertex": { + "type": "std:machine-identity", + "name": "GCP Vertex", + "identityAttribute": "identity", + "displayAttribute": "name", + "schema": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "The identifier of the project in which agent resides." }, + "locationId": { "type": "string", "description": "GCP region/location in which agent resides" }, + "name": { "type": "string", "description": "The resource name of the agent." }, + "identity": { "type": "string", "description": "The unique identifier of the agent." } + }, + "required": ["projectId", "locationId"] + } + } + } +} +``` + +Older connectors use `"type": "std:agent"` with `std:agent:list`. For new connectors, use `std:machine-identity`. + +Use only simple types in the schema: strings, numbers, booleans, and arrays of these. For example, use an array of strings, not an array of objects. + +### When SailPoint Identity Security calls the command + +An admin starts a machine identity aggregation from the source, or calls the [start machine identity aggregation API](/docs/api/start-machine-identity-aggregation-v-1). Admins can also schedule it. An aggregation can include all dataset types or only some of them. + +SailPoint Identity Security sends one command with a list of dataset IDs. The SDK calls your handler once for each dataset, with the ID and schema of that dataset. The SDK adds `datasetId` to each object that you send, so you do not need to add it yourself. + +Each aggregation creates, updates, and deletes machine identities in SailPoint Identity Security. Admins can set deletion thresholds to protect against accidental deletes. + +### Output fields + +| Field | Description | +| :---- | :---------- | +| `identity` | The native identity of the machine identity. It must be unique in the source. SailPoint Identity Security uses it to correlate machine accounts to the machine identity. | +| `attributes` | The attributes of the machine identity. They must match the schema of the dataset. Include `identity` and `name` in `attributes`. | + +## Implementation + +Call `res.send()` once for each machine identity. + +```typescript +import { + ConnectorError, + Context, + createConnector, + readConfig, + Response, + StdMachineIdentityListInput, + StdMachineIdentityListOutput, +} from '@sailpoint/connector-sdk' +import { MyClient } from './my-client' + +export const connector = async () => { + const config = await readConfig() + const client = new MyClient(config) + + return createConnector() + .stdMachineIdentityList(async (context: Context, input: StdMachineIdentityListInput, res: Response) => { + if (input.datasetId !== 'gcp:vertex') { + throw new ConnectorError(`Unsupported dataset: ${input.datasetId}`) + } + const agents: { id: string; name: string; projectId: string; locationId: string }[] = + await client.getAgents() + for (const agent of agents) { + res.send({ + identity: agent.id, + attributes: { + identity: agent.id, + name: agent.name, + projectId: agent.projectId, + locationId: agent.locationId, + }, + }) + } + }) +} +``` diff --git a/docs/connectivity/saas-connectivity/connector-commands/resource-list.md b/docs/connectivity/saas-connectivity/connector-commands/resource-list.md new file mode 100644 index 0000000000000..6a945f92b9706 --- /dev/null +++ b/docs/connectivity/saas-connectivity/connector-commands/resource-list.md @@ -0,0 +1,170 @@ +--- +id: resource-list +title: Resource List +pagination_label: Resource List +sidebar_label: Resource List +keywords: ['connectivity', 'connectors', 'resource list', 'dataset'] +description: Aggregate the resources in one dataset from the source. +slug: /connectivity/saas-connectivity/commands/resource-list +tags: ['Connectivity', 'Connector Command'] +--- + +| Input/Output | Data Type | +| :----------- | :---------------------: | +| Input | StdResourceListInput | +| Output | StdResourceListOutput | + +### Example StdResourceListInput + +```javascript +{ + "datasetId": "github:repositories" +} +``` + +### Example StdResourceListOutput + +```javascript +{ + "identity": "repo-1234", + "resourceId": "github:repository", + "attributes": { + "id": "repo-1234", + "name": "payments-service", + "visibility": "private" + } +} +``` + +## Description + +The resource list command aggregates the resources in one dataset. A resource is an object on the source that is not an account or an entitlement. Examples are AI agents, credentials, devices, and cloud roles. A dataset is a group of resource types that SailPoint Identity Security aggregates together. + +To use this command, you must specify this value in the `commands` array: `std:resource:list` + +:::note SDK version + +Version 1.2.1 of `@sailpoint/connector-sdk` does not have the `resourceId` field. Use version 1.2.3 or later. + +::: + +### Declare datasets and resources in the connector spec + +Add two top-level arrays to `connector-spec.json`: + +- `datasets` lists the datasets. Each dataset lists the IDs of the resource types that it contains. +- `resources` defines each resource type and its schema. + +```json +{ + "commands": ["std:test-connection", "std:resource:list"], + "datasets": [ + { + "id": "github:repositories", + "name": "GitHub Repositories", + "description": "Repositories in the GitHub organization", + "schedule": "0 */12 * * *", + "requiredLicenses": [], + "resources": [{ "id": "github:repository" }] + } + ], + "resources": [ + { + "id": "github:repository", + "name": "GitHub Repository", + "type": "std:capability", + "identityAttribute": "id", + "displayAttribute": "name", + "schema": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "visibility": { "type": "string" } + } + } + } + ] +} +``` + +| Property | Description | +| :------- | :---------- | +| `datasets[].schedule` | A cron expression for scheduled aggregations. Scheduled aggregations run only after an admin turns on aggregation for the dataset. | +| `datasets[].requiredLicenses` | The licenses that the tenant must have to see the dataset. The tenant must have all of them. If the list is empty or missing, every tenant sees the dataset. | +| `datasets[].resources` | The resource types in the dataset. Each resource type can be in only one dataset. | +| `resources[].id` | The ID of the resource type. The connector sends it as `resourceId`. | +| `resources[].type` | The kind of resource: `std:account`, `std:agent`, `std:application`, `std:capability`, `std:credential`, or `std:device`. | +| `resources[].identityAttribute` | The attribute that holds the unique ID of each resource. | +| `resources[].displayAttribute` | The attribute that SailPoint Identity Security shows as the name of each resource. | +| `resources[].schema` | A JSON Schema for the `attributes` of each resource. | + +Values that `requiredLicenses` uses include `idn:machine-identity-security`, `idn:agentic-identity-governance`, and `saf:datasets`. + +### When SailPoint Identity Security calls the command + +SailPoint Identity Security sends one `std:resource:list` command for each dataset aggregation. `input.datasetId` tells you which dataset to list. You cannot aggregate only one resource type of a dataset. Return all resource types in the dataset. + +An aggregation starts in one of these ways: + +- An admin starts it from the source, or calls the [aggregate dataset API](/docs/api/import-source-dataset-v-1). +- If aggregation is on for the dataset, its `schedule` starts it. + +### Output fields + +| Field | Description | +| :---- | :---------- | +| `resourceId` | The `id` of the resource type in `connector-spec.json`, for example `github:repository`. It is not the ID of the object. | +| `identity` | The stable, native ID of the object on the source. | +| `attributes` | The attributes of the object. They must match the `schema` of the resource type. | + +The SDK adds `datasetId` to each object that you send, so you do not need to add it yourself. + +SailPoint Identity Security uses `attributes[identityAttribute]` as the native identity of the object. If that attribute is empty, it uses `identity`. The native identity must be unique in the source, across all datasets and resource types. If the source uses the same ID for two kinds of objects, add a prefix to make the IDs unique. + +### Deleted resources + +`std:resource:list` returns a full list of the dataset each time. After an aggregation finishes without errors, SailPoint Identity Security deletes the resources that the connector did not send. You do not need to mark deleted resources. + +## Implementation + +Call `res.send()` once for each resource. Do not send an array. + +```typescript +import { + ConnectorError, + Context, + createConnector, + readConfig, + Response, + StdResourceListInput, + StdResourceListOutput, +} from '@sailpoint/connector-sdk' +import { MyClient } from './my-client' + +export const connector = async () => { + const config = await readConfig() + const client = new MyClient(config) + + return createConnector() + .stdResourceList(async (context: Context, input: StdResourceListInput, res: Response) => { + if (input.datasetId !== 'github:repositories') { + throw new ConnectorError(`Unsupported dataset: ${input.datasetId}`) + } + const repositories: { id: string; name: string; visibility: string }[] = + await client.getRepositories() + for (const repository of repositories) { + res.send({ + identity: repository.id, + resourceId: 'github:repository', + attributes: { + id: repository.id, + name: repository.name, + visibility: repository.visibility, + }, + }) + } + }) +} +``` diff --git a/docs/connectivity/saas-connectivity/connector-commands/ssf-stream.md b/docs/connectivity/saas-connectivity/connector-commands/ssf-stream.md new file mode 100644 index 0000000000000..5111ec06f8d20 --- /dev/null +++ b/docs/connectivity/saas-connectivity/connector-commands/ssf-stream.md @@ -0,0 +1,125 @@ +--- +id: ssf-stream +title: SSF Stream Commands +pagination_label: SSF Stream Commands +sidebar_label: SSF Stream Commands +keywords: ['connectivity', 'connectors', 'ssf', 'shared signals', 'stream', 'caep'] +description: Manage Shared Signals Framework (SSF) streams on a third-party transmitter. +slug: /connectivity/saas-connectivity/commands/ssf-stream +tags: ['Connectivity', 'Connector Command'] +draft: true +--- + +## Description + +The Shared Signals Framework (SSF) is an OpenID standard for sending security events between systems through streams. An example is a Continuous Access Evaluation Profile (CAEP) event that says a device is no longer compliant. + +In these commands, SailPoint Identity Security is the SSF receiver. The source is the SSF transmitter, for example a device management or identity provider service. SailPoint Identity Security uses the connector to set up and manage a stream on the transmitter. The transmitter then sends its events to SailPoint Identity Security. + +The connector is a transport layer. Each command sends one HTTP request to the transmitter and returns the result. The connector does not process the security events. + +These commands are different from the [SSF transmitter APIs](/docs/api/create-stream-v-1) of SailPoint Identity Security. With those APIs, SailPoint Identity Security is the transmitter. + +| Command | SDK method | What the connector does on the transmitter | +| :------ | :--------- | :----------------------------------------- | +| `std:ssf-stream:discover` | `stdSsfStreamDiscover` | Get the SSF configuration from `discoveryEndpoint`. | +| `std:ssf-stream:create` | `stdSsfStreamCreate` | Create a stream. | +| `std:ssf-stream:read` | `stdSsfStreamRead` | Get the stream configuration. | +| `std:ssf-stream:update` | `stdSsfStreamUpdate` | Change some fields of the stream configuration. | +| `std:ssf-stream:replace` | `stdSsfStreamReplace` | Replace the full stream configuration. | +| `std:ssf-stream:status-update` | `stdSsfStreamStatusUpdate` | Change the stream status, for example to `enabled` or `disabled`. | +| `std:ssf-stream:verify` | `stdSsfStreamVerify` | Ask the transmitter to send a verification event. | +| `std:ssf-stream:delete` | `stdSsfStreamDelete` | Delete the stream. | + +To use a command, you must specify its value from the first column in the `commands` array. + +## Input + +Every command has the input `{ stream }`. The SDK types `stream` as `any`. SailPoint Identity Security sends this shape: + +```json +{ + "stream": { + "discoveryEndpoint": "https://transmitter.example.com/.well-known/ssf-configuration", + "streamConfiguration": { + "streamID": "ad013776-c43e-42c9-8020-520e8ab20573", + "iss": "https://transmitter.example.com", + "aud": ["https://example.com"], + "eventsRequested": ["https://schemas.openid.net/secevent/caep/event-type/device-compliance-change"], + "delivery": { + "method": "urn:ietf:rfc:8935", + "endpointUrl": "https://tenant.api.identitynow.com/ssf-event/54ea4518-5e54-48a7-ae86-22ef5ae6247b", + "authorizationHeader": null + }, + "description": "Events for the test environment", + "streamStatus": "disabled" + }, + "transmitterMetadata": { + "configurationEndpoint": "https://transmitter.example.com/sse/v1/stream", + "statusEndpoint": "https://transmitter.example.com/sse/v1/status", + "verificationEndpoint": "https://transmitter.example.com/sse/v1/verification" + } + } +} +``` + +| Field | Description | +| :---- | :---------- | +| `discoveryEndpoint` | The URL of the SSF well-known configuration of the transmitter. | +| `streamConfiguration` | The SSF stream configuration, with camel-case field names. `delivery.endpointUrl` is the SailPoint Identity Security URL that receives the events. | +| `transmitterMetadata` | The endpoints of the transmitter, from its SSF configuration. Send create, read, update, replace, and delete requests to `configurationEndpoint`. Send status requests to `statusEndpoint` and verification requests to `verificationEndpoint`. | + +If `streamConfiguration.delivery` or `delivery.endpointUrl` is missing, throw a `ConnectorError`. + +The discover command returns the SSF configuration of the transmitter. SailPoint Identity Security uses its fields, for example `issuer`, `jwks_uri`, and the endpoints, to fill `transmitterMetadata` and to check the signatures of events. + +## Authentication + +Most transmitters protect their SSF endpoints with OAuth. Add the authentication fields that the transmitter needs to `sourceConfig` in `connector-spec.json`, for example an OAuth token or OAuth 2.0 client credentials. + +## Implementation + +This example registers the discover, create, and delete commands. The other commands follow the same pattern. + +```typescript +import { + ConnectorError, + Context, + createConnector, + readConfig, + Response, + StdSsfStreamCreateInput, + StdSsfStreamCreateOutput, + StdSsfStreamDeleteInput, + StdSsfStreamDeleteOutput, + StdSsfStreamDiscoverInput, + StdSsfStreamDiscoverOutput, +} from '@sailpoint/connector-sdk' +import { MyClient } from './my-client' + +export const connector = async () => { + const config = await readConfig() + const client = new MyClient(config) + + return createConnector() + .stdSsfStreamDiscover(async (context: Context, input: StdSsfStreamDiscoverInput, res: Response) => { + const configuration = await client.get(input.stream.discoveryEndpoint) + res.send(configuration) + }) + .stdSsfStreamCreate(async (context: Context, input: StdSsfStreamCreateInput, res: Response) => { + const { streamConfiguration, transmitterMetadata } = input.stream + if (!streamConfiguration?.delivery?.endpointUrl) { + throw new ConnectorError('The stream configuration has no delivery endpoint URL') + } + const stream = await client.post(transmitterMetadata.configurationEndpoint, streamConfiguration) + res.send(stream) + }) + .stdSsfStreamDelete(async (context: Context, input: StdSsfStreamDeleteInput, res: Response) => { + const { streamConfiguration, transmitterMetadata } = input.stream + await client.delete(`${transmitterMetadata.configurationEndpoint}?stream_id=${streamConfiguration.streamID}`) + res.send({}) + }) +} +``` + +`MyClient` is your HTTP client for the transmitter. In this example, its methods return the parsed JSON response. diff --git a/docs/connectivity/saas-connectivity/connector-commands/test-connection.md b/docs/connectivity/saas-connectivity/connector-commands/test-connection.md index 71a628740ba52..73cdbed638b30 100644 --- a/docs/connectivity/saas-connectivity/connector-commands/test-connection.md +++ b/docs/connectivity/saas-connectivity/connector-commands/test-connection.md @@ -74,7 +74,7 @@ export const connector = async () => { const myClient = new MyClient(config) return createConnector() - .stdTestConnection(async (context: Context, input: undefined, res: Response) => { + .stdTestConnection(async (context: Context, input: StdTestConnectionInput, res: Response) => { await myClient.testConnection() res.send({}) }) diff --git a/docs/connectivity/saas-connectivity/connector-customizers/customizer-commands/test-connection.md b/docs/connectivity/saas-connectivity/connector-customizers/customizer-commands/test-connection.md index 57939639ccd3e..4f9329cf7cc77 100644 --- a/docs/connectivity/saas-connectivity/connector-customizers/customizer-commands/test-connection.md +++ b/docs/connectivity/saas-connectivity/connector-customizers/customizer-commands/test-connection.md @@ -15,9 +15,18 @@ Use these commands to intercept the [Test-Connection](../../commands/test-connec | Input/Output | Data Type | | :----------- | :---------------------: | -| Input | undefined | +| Input | StdTestConnectionInput | | Output | StdTestConnectionOutput | +### Example StdTestConnectionInput + +The input is often an empty object. It can include an optional `accountSchema`. + +```javascript +{ +} +``` + ### Example StdTestConnectionOutput ```javascript @@ -32,12 +41,13 @@ Use these commands to intercept the [Test-Connection](../../commands/test-connec Use this logic to implement the command: ```javascript - .beforeStdTestConnection(async (context: Context, input: undefined) => { + .beforeStdTestConnection(async (context: Context, input: StdTestConnectionInput) => { logger.info('Running before test connection') + return input }) ``` -There is no input, so you cannot mutate any data. However, you can make web request calls or perform any type of logging or logic before calling the connector. +The `input` object can be mutated and returned, but the same data type must still be returned. You can also make web request calls or perform any type of logging or logic before calling the connector. ### After test-connection command diff --git a/docs/connectivity/saas-connectivity/connector-customizers/full-example.md b/docs/connectivity/saas-connectivity/connector-customizers/full-example.md index b51804b953348..33480278200ea 100644 --- a/docs/connectivity/saas-connectivity/connector-customizers/full-example.md +++ b/docs/connectivity/saas-connectivity/connector-customizers/full-example.md @@ -15,7 +15,7 @@ tags: ['Connectivity'] This is a full example of a customizer with all commands provisioned: -```javascript +```typescript import { Context, createConnectorCustomizer, @@ -23,6 +23,7 @@ import { logger, StdAccountReadInput, StdAccountReadOutput, + StdTestConnectionInput, StdTestConnectionOutput, StdAccountCreateInput, StdAccountCreateOutput, @@ -37,9 +38,11 @@ import { StdAccountUnlockInput, StdAccountUnlockOutput, StdAccountListInput, + StdAccountListOutput, StdEntitlementReadInput, StdEntitlementReadOutput, StdEntitlementListInput, + StdEntitlementListOutput, StdChangePasswordInput, StdChangePasswordOutput, StdSourceDataDiscoverInput, @@ -59,11 +62,12 @@ export const connectorCustomizer = async () => { logger.info('Running after test connection') return output }) - .beforeStdTestConnection(async (context: Context, input: undefined) => { + .beforeStdTestConnection(async (context: Context, input: StdTestConnectionInput) => { logger.info('Running before test connection') + return input }) .beforeStdAccountCreate(async (context: Context, input: StdAccountCreateInput) => { - logger.info(`Running before account, for account ${input.identity}`) + logger.info(`Running before account create for account ${input.identity}`) return input }) .afterStdAccountCreate(async (context: Context, output: StdAccountCreateOutput) => { @@ -71,7 +75,7 @@ export const connectorCustomizer = async () => { return output }) .beforeStdAccountRead(async (context: Context, input: StdAccountReadInput) => { - logger.info(`Running before account, for account ${input.identity}`) + logger.info(`Running before account read for account ${input.identity}`) return input }) .afterStdAccountRead(async (context: Context, output: StdAccountReadOutput) => { @@ -79,15 +83,15 @@ export const connectorCustomizer = async () => { return output }) .beforeStdAccountUpdate(async (context: Context, input: StdAccountUpdateInput) => { - logger.info(`Running before account, for account ${input.identity}`) + logger.info(`Running before account update for account ${input.identity}`) return input }) .afterStdAccountUpdate(async (context: Context, output: StdAccountUpdateOutput) => { - logger.info(`Running after account update for account ${output.identity}}`) + logger.info(`Running after account update for account ${output.identity}`) return output }) .beforeStdAccountDelete(async (context: Context, input: StdAccountDeleteInput) => { - logger.info(`Running before account, for account ${input.identity}`) + logger.info(`Running before account delete for account ${input.identity}`) return input }) .afterStdAccountDelete(async (context: Context, output: StdAccountDeleteOutput) => { @@ -95,7 +99,7 @@ export const connectorCustomizer = async () => { return output }) .beforeStdAccountEnable(async (context: Context, input: StdAccountEnableInput) => { - logger.info(`Running before account, for account ${input.identity}`) + logger.info(`Running before account enable for account ${input.identity}`) return input }) .afterStdAccountEnable(async (context: Context, output: StdAccountEnableOutput) => { @@ -103,7 +107,7 @@ export const connectorCustomizer = async () => { return output }) .beforeStdAccountDisable(async (context: Context, input: StdAccountDisableInput) => { - logger.info(`Running before account, for account ${input.identity}`) + logger.info(`Running before account disable for account ${input.identity}`) return input }) .afterStdAccountDisable(async (context: Context, output: StdAccountDisableOutput) => { @@ -111,7 +115,7 @@ export const connectorCustomizer = async () => { return output }) .beforeStdAccountUnlock(async (context: Context, input: StdAccountUnlockInput) => { - logger.info(`Running before account, for account ${input.identity}`) + logger.info(`Running before account unlock for account ${input.identity}`) return input }) .afterStdAccountUnlock(async (context: Context, output: StdAccountUnlockOutput) => { @@ -119,9 +123,13 @@ export const connectorCustomizer = async () => { return output }) .beforeStdAccountList(async (context: Context, input: StdAccountListInput) => { - logger.info(`Running before account list for account. State: ${input.state}`) + logger.info(`Running before account list. State: ${input.state}`) return input }) + .afterStdAccountList(async (context: Context, output: StdAccountListOutput) => { + logger.info(`Running after account list for account ${output.identity}`) + return output + }) .beforeStdEntitlementRead(async (context: Context, input: StdEntitlementReadInput) => { logger.info(`Running before entitlement read for account ${input.identity}`) return input @@ -131,9 +139,13 @@ export const connectorCustomizer = async () => { return output }) .beforeStdEntitlementList(async (context: Context, input: StdEntitlementListInput) => { - logger.info(`Running before entitlement list for account. State ${input.state}`) + logger.info(`Running before entitlement list. State: ${input.state}`) return input }) + .afterStdEntitlementList(async (context: Context, output: StdEntitlementListOutput) => { + logger.info(`Running after entitlement list for entitlement type ${output.type}`) + return output + }) .beforeStdChangePassword(async (context: Context, input: StdChangePasswordInput) => { logger.info(`Running before change password for account ${input.identity}`) return input @@ -159,9 +171,8 @@ export const connectorCustomizer = async () => { return output }) .customizedOperation('Operation Identifier', async (context: Context, input: any) => { - logger.info(`Running customized code.`) - return input + logger.info(`Running customized code.`) + return input }) } - ``` diff --git a/docs/connectivity/saas-connectivity/connector-customizers/testing.md b/docs/connectivity/saas-connectivity/connector-customizers/testing.md index 2397ad2c7640b..cfd7c3f3583e8 100644 --- a/docs/connectivity/saas-connectivity/connector-customizers/testing.md +++ b/docs/connectivity/saas-connectivity/connector-customizers/testing.md @@ -39,7 +39,7 @@ export const connector = async () => { const myClient = new MyClient(config) return createConnector() - .stdTestConnection(async (context: Context, input: undefined, res: Response) => { + .stdTestConnection(async (context: Context, input: StdTestConnectionInput, res: Response) => { logger.info("Running test connection") res.send(await myClient.testConnection()) }) @@ -59,7 +59,7 @@ export const connector = async () => { const myClient = new MyClient(config) return createConnector() - .stdTestConnection(async (context: Context, input: undefined, res: Response) => { + .stdTestConnection(async (context: Context, input: StdTestConnectionInput, res: Response) => { logger.info("Running test connection") res.send(await myClient.testConnection()) }) diff --git a/docs/connectivity/saas-connectivity/in-depth/logging.md b/docs/connectivity/saas-connectivity/in-depth/logging.md index 1da14417ef28c..4d0dd8a8fd754 100644 --- a/docs/connectivity/saas-connectivity/in-depth/logging.md +++ b/docs/connectivity/saas-connectivity/in-depth/logging.md @@ -49,7 +49,7 @@ export const connector = async () => { // Use the vendor SDK, or implement own client as necessary, to initialize a client const airtable = new AirtableClient(config) return createConnector() - .stdTestConnection(async (context: Context, input: undefined, res: Response) => { + .stdTestConnection(async (context: Context, input: StdTestConnectionInput, res: Response) => { // print the output to the console console.log('testing connector logging') res.send(await airtable.testConnection()) @@ -99,7 +99,7 @@ export const connector = async () => { // Use the vendor SDK, or implement own client as necessary, to initialize a client const airtable = new AirtableClient(config) return createConnector() - .stdAccountList(async (context: Context, input: undefined, res: Response) => { + .stdAccountList(async (context: Context, input: StdAccountListInput, res: Response) => { const accounts = await airtable.getAllAccounts() // use the logger to send accounts that are fetched @@ -152,7 +152,7 @@ export const connector = async () => { ... const airtable = new AirtableClient(config) return createConnector() - .stdAccountList(async (context: Context, input: undefined, res: Response) => { + .stdAccountList(async (context: Context, input: StdAccountListInput, res: Response) => { logger.debug('Starting account list fetch') const accounts = await airtable.getAllAccounts() @@ -234,7 +234,7 @@ export class AirtableAccount { Now when you log the contents of an `AirtableAccount` object, you will see all the fields redacted. For example, in [index.ts](https://github.com/sailpoint-oss/airtable-example-connector/blob/main/src/index.ts) we log the `accounts` in the following code snippet: ```javascript -.stdAccountList(async (context: Context, input: undefined, res: Response) => { +.stdAccountList(async (context: Context, input: StdAccountListInput, res: Response) => { const accounts = await airtable.getAllAccounts() logger.info(accounts, "fetched the following accounts from Airtable")