Hi All,
I was able to create a Web Services SaaS connector for Blackline. Along with using some customizers code to work with the role-products that are required for user access. I have posted below some of the source configuration to show API calls that I make to add users to the different, along with the customizer code that I use to translate the role products into a single entitlement value and back again. If you have any questions or improvements, feel free to let me know! For the authentication I have found that doing a curl command is the easiest for this process.
API Calls:
{
"httpMethodType": "POST",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "3",
"uniqueNameForEndPoint": "Create Account ",
"rootPath": "$.",
"body": {
"jsonBody": "{\n \"loginId\": \"$plan.loginId$\",\n \"firstName\": \"$plan.firstname$\",\n \"lastName\": \"$plan.lastName$\",\n \"isActive\": false,\n \"email\": \"$plan.email$\",\n \"allowUserMentions\": true,\n \"allowUserToEditJournalConfig\": false,\n \"allowUserToEditIntercompanyConfig\": false,\n \"allowAdhocMatching\": false,\n \"allowIntercompanySettlement\": false,\n \"requiresJournalReviewer\": false,\n \"allowUserToAccessJournalAnalyser\":false,\n \"jobTitle\": null,\n \"supervisor\": null,\n \"timeZoneId\": 6,\n \"phoneNumber\": null,\n \"annualHours\": 0,\n \"referenceField1\": null,\n \"referenceField2\": null,\n \"referenceField3\": null\n}",
"bodyFormat": "raw"
},
"paginationSteps": null,
"responseCode": [
"201"
],
"resMappingObj": {
"firstName": "firstName",
"lastName": "lastName",
"lastUpdated": "lastUpdated",
"loginId": "loginId",
"fullName": "fullName",
"id": "id",
"isActive": "isActive",
"email": "email"
},
"contextUrl": "/v1/users",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Create Account",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "4",
"uniqueNameForEndPoint": "Account Aggregation - Get Accounts",
"rootPath": "$.items",
"body": {
"bodyFormData": null,
"jsonBody": null,
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"lastName": "lastName",
"lastUpdated": "lastUpdated",
"firstname": "firstName",
"loginId": "loginId",
"fullName": "fullName",
"id": "id",
"isActive": "isActive",
"email": "email"
},
"contextUrl": "/v1/users?pageSize=99999&filter=isActive eq \"true\"",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Account Aggregation",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "5",
"uniqueNameForEndPoint": "Account Aggregation - Get Account Roles-Products",
"rootPath": null,
"body": {
"bodyFormData": null,
"jsonBody": null,
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"productId": "$.items..product.id",
"roleId": "$.items..role.id",
"FullRoleProducts": "$.items[*]"
},
"contextUrl": "/v1/users/$response.id$/roles-products",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Account Aggregation",
"xpathNamespaces": null,
"parentEndpointName": "Account Aggregation - Get Accounts"
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "6",
"uniqueNameForEndPoint": "Account Aggregation - Get User Teams",
"rootPath": "$.items",
"body": {
"bodyFormData": null,
"jsonBody": null,
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"teams": "id"
},
"contextUrl": "/v1/users/$response.id$/teams",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Account Aggregation",
"xpathNamespaces": null,
"parentEndpointName": "Account Aggregation - Get Accounts"
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "7",
"uniqueNameForEndPoint": "Account Aggregation - Get User Entities",
"rootPath": "$.items",
"body": {
"bodyFormData": null,
"jsonBody": null,
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"entities": "id"
},
"contextUrl": "/v1/users/$response.id$/entities",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Account Aggregation",
"xpathNamespaces": null,
"parentEndpointName": "Account Aggregation - Get Accounts"
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "8",
"uniqueNameForEndPoint": "Single Account Aggregation - Get Single Account",
"rootPath": "$.",
"body": {
"bodyFormData": null,
"jsonBody": null,
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"lastName": "lastName",
"lastUpdated": "lastUpdated",
"firstname": "firstName",
"loginId": "loginId",
"fullName": "fullName",
"id": "id",
"isActive": "isActive",
"email": "email"
},
"contextUrl": "/v1/users/$plan.nativeIdentity$",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Get Object",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "9",
"uniqueNameForEndPoint": "Single Account Aggregation - Get User Roles-Products",
"rootPath": null,
"body": {
"bodyFormData": null,
"jsonBody": null,
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"productId": "$.items..product.id",
"roleId": "$.items..role.id",
"FullRoleProducts": "$.items[*]"
},
"contextUrl": "/v1/users/$response.id$/roles-products",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Get Object",
"xpathNamespaces": null,
"parentEndpointName": "Single Account Aggregation - Get Single Account"
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "10",
"uniqueNameForEndPoint": "Single Account Aggregation - Get User Teams",
"rootPath": "$.items",
"body": {
"bodyFormData": null,
"jsonBody": null,
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"teams": "id"
},
"contextUrl": "/v1/users/$response.id$/teams",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Get Object",
"xpathNamespaces": null,
"parentEndpointName": "Single Account Aggregation - Get Single Account"
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "11",
"uniqueNameForEndPoint": "Single Account Aggregation - Get User entities",
"rootPath": "$.items",
"body": {
"bodyFormData": null,
"jsonBody": null,
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"entities": "id"
},
"contextUrl": "/v1/users/$response.id$/entities",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Get Object",
"xpathNamespaces": null,
"parentEndpointName": "Single Account Aggregation - Get Single Account"
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "12",
"uniqueNameForEndPoint": "Group Aggregation - Role Products",
"rootPath": "$.items[*]",
"body": {
"bodyFormData": null,
"jsonBody": null,
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"roleProductId": "role",
"productId": "product.id",
"roleId": "role.id",
"roleName": "role.name",
"productName": "product.name"
},
"contextUrl": "/v1/users/roles-products",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Group Aggregation-roleProduct",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "13",
"uniqueNameForEndPoint": "Group Aggregation - Blackline Teams",
"rootPath": "$.items",
"body": {
"bodyFormData": null,
"jsonBody": null,
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"uniqueCode": "uniqueCode",
"level": "level",
"name": "name",
"id": "id",
"parentId": "parentId"
},
"contextUrl": "/v1/teams?pageSize=9999",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Group Aggregation-Teams",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "14",
"uniqueNameForEndPoint": "Group Aggregation - Entities",
"rootPath": "$.items",
"body": {
"bodyFormData": null,
"jsonBody": null,
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"lineage": "lineage",
"sourceId": "sourceId",
"levelId": "levelId",
"name": "name",
"financialReviewRequired": "financialReviewRequired",
"id": "id",
"varianceGroupRequired": "varianceGroupRequired",
"key": "key",
"parentId": "parentId"
},
"contextUrl": "/v1/mdm/entities",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Group Aggregation-Entity",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "POST",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "15",
"uniqueNameForEndPoint": "Add Entitlement - Teams",
"rootPath": null,
"body": {
"jsonBody": "[\n$plan.teams$\n]",
"bodyFormat": "raw"
},
"paginationSteps": null,
"responseCode": [
"204"
],
"resMappingObj": null,
"contextUrl": "/v1/users/$plan.nativeIdentity$/teams",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Add Entitlement-Teams",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "POST",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "16",
"uniqueNameForEndPoint": "Add Entitlement - Entities",
"rootPath": null,
"body": {
"jsonBody": "[\n$plan.entities$\n]",
"bodyFormat": "raw"
},
"paginationSteps": null,
"responseCode": [
"204"
],
"resMappingObj": null,
"contextUrl": "/v1/users/$plan.nativeIdentity$/entities",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Add Entitlement-Entity",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "POST",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "17",
"uniqueNameForEndPoint": "Add Entitlement - roleProducts",
"rootPath": null,
"body": {
"jsonBody": "[\n{$plan.roleProducts$}\n]",
"bodyFormat": "raw"
},
"paginationSteps": null,
"responseCode": [
"204"
],
"resMappingObj": null,
"contextUrl": "/v1/users/$plan.nativeIdentity$/roles-products",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Add Entitlement-roleProduct",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "DELETE",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "18",
"uniqueNameForEndPoint": "Remove Entitlements - Teams",
"rootPath": null,
"body": {
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"204",
"400"
],
"resMappingObj": null,
"contextUrl": "/v1/users/$plan.nativeIdentity$/teams/$plan.teams$",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Remove Entitlement-Teams",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "DELETE",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "19",
"uniqueNameForEndPoint": "Remove Entitlements - Entities",
"rootPath": null,
"body": {
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"204",
"400"
],
"resMappingObj": null,
"contextUrl": "/v1/users/$plan.nativeIdentity$/entities/$plan.entities$",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Remove Entitlement-Entity",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "DELETE",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "20",
"uniqueNameForEndPoint": "Remove Entitlements - roleProducts",
"rootPath": null,
"body": {
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"204",
"400"
],
"resMappingObj": null,
"contextUrl": "/v1/users/$plan.nativeIdentity$/roles-products?$plan.roleProducts$",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Remove Entitlement-roleProduct",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "POST",
"pagingInitialOffset": 0,
"requestType": "API",
"sequenceNumberForEndpoint": "21",
"uniqueNameForEndPoint": "Disable Account",
"rootPath": "$.",
"body": {
"bodyFormat": null
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"id": "userId"
},
"contextUrl": "/v1/users/$plan.nativeIdentity$/deprovision",
"pagingSize": 50,
"header": {
"Authorization": "Bearer $application.accesstoken_blackline$"
},
"operationType": "Disable Account",
"xpathNamespaces": null,
"parentEndpointName": null
}
Customizer Code:
import {
Context,
createConnectorCustomizer,
readConfig,
logger,
StdAccountReadInput,
StdAccountReadOutput,
StdAccountListOutput,
StdTestConnectionOutput,
StdAccountUpdateInput,
StdEntitlementListInput,
StdEntitlementListOutput,
} from '@sailpoint/connector-sdk'
// Connector customizer must be exported as module property named connectorCustomizer
export const connectorCustomizer = async () => {
// Get connector source config
const config = await readConfig()
return (
createConnectorCustomizer()
.afterStdTestConnection(async (context: Context, output: StdTestConnectionOutput) => {
logger.info('Running after test connection')
return output
})
// .beforeStdAccountRead(async (context: Context, input: StdAccountReadInput) => {
// logger.info(`Running before account, for account ${input.identity}`)
// return input
// })
// .afterStdAccountRead(async (context: Context, output: StdAccountReadOutput) => {
// logger.info(`Running after account read to add custom attribute "location"`)
// output.attributes.location = 'Austin'
// return output
// })
.afterStdAccountList(async (context: Context, output: StdAccountListOutput) => {
logger.info(`Running after account list for account ${JSON.stringify(output)}`)
logger.info(`Account Attributes: ${JSON.stringify(output.attributes)}`)
logger.info(`Entitlements: ${JSON.stringify(output.attributes.roleProducts)}`)
logger.info(`Entitlements: ${JSON.stringify(output.attributes.FullRoleProducts)}`)
logger.info(`Entitlements Count: ${output.attributes.FullRoleProducts}`)
const ensureStringArray = (value: unknown): String[] => {
if (Array.isArray(value)) {
return value.filter((v): v is String => typeof v === 'string')
}
if (typeof value === 'string') {
return [value]
}
return []
}
const roleIds_unnormailzed = output.attributes.roleId
const productIds_unnormailzed = output.attributes.productId
const roleIds = ensureStringArray(roleIds_unnormailzed)
const productIds = ensureStringArray(productIds_unnormailzed)
const roleProducts: string[] = []
if (Array.isArray(roleIds) && Array.isArray(productIds) && roleIds.length === productIds.length) {
for (let i = 0; i < roleIds.length; i++) {
roleProducts.push(`${roleIds[i]}${productIds[i]}`)
}
}
output.attributes.roleProducts = roleProducts
logger.info(`Updated Entitlements: ${JSON.stringify(output.attributes.roleProducts)}`)
return output
})
.afterStdAccountRead(async (context: Context, output: StdAccountReadOutput) => {
logger.info(`Running after account read for account ${JSON.stringify(output)}`)
logger.info(`Running After Account read for Account Attributes ${JSON.stringify(output.attributes)}`)
const ensureStringArray = (value: unknown): String[] => {
if (Array.isArray(value)) {
return value.filter((v): v is String => typeof v === 'string')
}
if (typeof value === 'string') {
return [value]
}
return []
}
const roleIds_unnormailzed = output.attributes.roleId
const productIds_unnormailzed = output.attributes.productId
const roleIds = ensureStringArray(roleIds_unnormailzed)
const productIds = ensureStringArray(productIds_unnormailzed)
const roleProducts: string[] = []
if (Array.isArray(roleIds) && Array.isArray(productIds) && roleIds.length === productIds.length) {
for (let i = 0; i < roleIds.length; i++) {
roleProducts.push(`${roleIds[i]}${productIds[i]}`)
}
}
output.attributes.roleProducts = roleProducts
logger.info(`Entitlements: ${JSON.stringify(output.attributes)}`)
return output
})
.beforeStdAccountUpdate(async (context: Context, input: StdAccountUpdateInput) => {
logger.info(`Running before account, for account ${input.identity}`)
logger.info(`Account Attributes: ${JSON.stringify(input.changes)}`)
if (input.changes && input.changes[0].attribute === 'roleProducts' && input.changes[0].op === 'Add') {
const cleaned = input.changes[0].value.trim()
const role = parseInt(cleaned.slice(0, cleaned.length - 1), 10)
const product = cleaned.charAt(cleaned.length - 1)
logger.info(`Changing input value to custom value for role products`)
input.changes[0].value = `"roleId": ${role}, "productId": "${product}"`
logger.info(`New Value: ${input.changes[0].value}`)
}
for (const change of input.changes) {
if (change && change.attribute === 'roleProducts' && change.op === 'Remove') {
const removeRoleProductsList = []
for (const roleProduct of change.value) {
const cleaned = roleProduct.trim()
const role = parseInt(cleaned.slice(0, cleaned.length - 1), 10)
const product = cleaned.charAt(cleaned.length - 1)
logger.info(`Changing input value to custom value for role products`)
const newValue = `roleId=${role}&productId=${product}`
removeRoleProductsList.push(newValue)
logger.info(`New Value: ${newValue}`)
}
change.value = removeRoleProductsList
}
}
return input
})
.beforeStdEntitlementList(async (context: Context, input: StdEntitlementListInput) => {
logger.info(`Running before entitlement list for entitlement. State ${input.state}`)
logger.info(`Entitlement Schema: ${JSON.stringify(input.schema)}`)
logger.info(`Entitlement Attributes: ${JSON.stringify(input.schema?.attributes)}`)
logger.info(`Entitlement Attributes: ${input.schema?.attributes[0]?.name}`)
logger.info(`Entitlement identityAttribute: ${JSON.stringify(input.schema?.identityAttribute)}`)
return input
})
.afterStdEntitlementList(async (context: Context, output: StdEntitlementListOutput) => {
logger.info(`Running after entitlement list for entitlement type ${output.type}`)
logger.info(`Entitlements: ${JSON.stringify(output.attributes.roleProductId)}`)
if (output.attributes && 'roleProduct' === output.type) {
output.attributes.id = `${output.attributes.roleName}${output.attributes.productId}`
output.attributes.roleProductId = `${output.attributes.roleName}${output.attributes.productId}`
output.attributes.roleProductname = `${output.attributes.roleName}-${output.attributes.productName}`
let entitlement_id = `${output.attributes.roleId}${output.attributes.productId}`
output.key = { simple: { id: entitlement_id } }
}
logger.info(`Entitlements: ${output.attributes.roleName}`)
logger.info(`Identity: ${output.identity}`)
logger.info(`Output Key: ${JSON.stringify(output.key)}`)
logger.info(`Entitlements: ${output.attributes.productName}`)
return output
})
)
}
Please be sure you’ve read the docs and API specs before asking for help. Also, please be sure you’ve searched the forum for your answer before you create a new topic.
Please consider addressing the following when creating your topic:
- What have you tried?
- What errors did you face (share screenshots)?
- Share the details of your efforts (code / search query, workflow json etc.)?
- What is the result you are getting and what were you expecting?