Description
This is a WebServicesAfterOperation rule that allows you to establish account to entitlement relationships when the target API does not provide endpoints to fetch account to entitlement relationships. Instead, the API provides entitlement to account relationships, which is something our current architecture struggles with. This is a similar challenge to that we face with LDAP servers, which hold group membership at the group object and not the account object. This is solved by specialised versions of our LDAP connector that are able to link accounts to entitlements from the entitlement side. The same is not true for Web Services connector, hence this tool.
The idea behind this rule is leveraging a previous entitlement aggregation where entitlement to account relationship can be found. This assumes the source endpoint provides you with the account IDs of each of the accounts that hold an entitlement. Entitlement aggregation should store that relationship or membership in a multi-valued entitlement attribute. Once this aggregation is run, we run account aggregation with this WSAO rule so the rule connects back to IDN to figure out assigned entitlements based on that multi-valued attribute.
Let me explain by example. This was created to connect to IBM Cloud. At this time, IBM cloud only provides you with group to user relationship endpoints:
Check out the members attribute: thatâs the members of the group, but you wonât find that information from an account perspective, only from a group perspective. This kind of endpoint must be leveraged on a child entitlement aggregation call, since we need to point to each group individually:
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"sequenceNumberForEndpoint": "4",
"uniqueNameForEndPoint": "Group Membership Aggregation",
"afterRule": null,
"curlCommand": null,
"rootPath": "$",
"body": {
"jsonBody": null,
"bodyFormat": "raw"
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"members": "$..iam_id"
},
"contextUrl": "iam.cloud.ibm.com/v2/groups/$response.id$/members?account_id=2b79f6c62a3e404785acca926698c4c3",
"pagingSize": 50,
"curlEnabled": false,
"header": {
"Authorization": "Bearer $application.access_token$"
},
"operationType": "Group Aggregation",
"beforeRule": null,
"xpathNamespaces": null,
"parentEndpointName": "Group Aggregation"
}
Thatâs the definition of the child call that depends on the parent Group Aggregation call. Note we leverage $response.id$, the entitlement ID from the parent call, and weâre using a JSONPath expression that collects account IDs from the iam_id JSON keys ($âŚiam_id ). This also requires setting up the entitlement schema correctly, like the one below:
Weâve got our entitlements in, and they also hold membership information:
Well, now itâs time to aggregate accounts and use that membership information to update the account to entitlement link accordingly on the account object. For that, we configure account aggregation as usual but add this WSAO rule to the operation:
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"sequenceNumberForEndpoint": "5",
"uniqueNameForEndPoint": "Account Aggregation",
"afterRule": "WSReverseMembershipAggregation",
"curlCommand": null,
"rootPath": "$.resources",
"body": {
"jsonBody": null,
"bodyFormat": "raw"
},
"paginationSteps": null,
"responseCode": [
"200"
],
"resMappingObj": {
"altphonenumber": "altphonenumber",
"firstname": "firstname",
"account_id": "account_id",
"iam_id": "iam_id",
"user_id": "user_id",
"phonenumber": "phonenumber",
"realm": "realm",
"id": "id",
"state": "state",
"email": "email",
"lastname": "lastname"
},
"contextUrl": "user-management.cloud.ibm.com/v2/accounts/2b79f6c62a3e404785acca926698c4c3/users",
"pagingSize": 50,
"curlEnabled": false,
"header": {
"Authorization": "Bearer $application.access_token$",
"X-IDNClientId": "24113546535b45838b3ddf93e05b8088",
"Accept": "application/json",
"X-IDNClientSecret": "a4c516ba3e33797c27b5957382b52fd203b2cc339db7478567c854d4aa3caa4e",
"X-IDNUrl": "https://company259-poc.identitynow-demo.com",
"X-IDNAttribute": "group.members",
"Content-Type": "application/json"
},
"operationType": "Account Aggregation",
"beforeRule": null,
"xpathNamespaces": null
}
Note the afterRule reference. Also, note the X-* headers (more on that later). First, the operation collects all account attributes but entitlements. After that, the WSAO rule authenticates to IDN API, collects the appropriate entitlements, resolves account memberships and updates accounts accordingly. Remember that your account schema must have an attribute that references the previous entitlements for this membership to be updated:
Youâre all set. Letâs learn how to set this up.
Prerequisites
An IdentityNow tenant and a system with a entitlement to account relationship reporting endpoints.
Limitations
Full account aggregation depends on a previous full run of entitlement aggregation. Account aggregation should not fail if no previous entitlement aggregation was run, but no entitlement information would be added.
Code may need some changes depending on how entitlement to account relationship data is provided by your endpoints.
Configuration
- Configure your Web Services connector as you normally would. Take into account the following points:
- Account schema must present a multi-valued entitlement attribute of the type of the entitlements youâre trying to relate to.
- Group schema must present a multi-valued attribute where group membership is held.
- Install a new WSAO connector rule with this code:
//WSReverseMembershipAggregation
import java.util.HashMap;
import java.util.Map;
import com.jayway.jsonpath.JsonPath;
import org.apache.logging.log4j.Logger;
import java.util.List;
import java.util.ArrayList;
import sailpoint.connector.webservices.WebServicesClient;
import sailpoint.object.Schema;
import sailpoint.object.Application;
import sailpoint.object.AttributeDefinition;
import connector.common.JsonUtil;
String authenticate(String url, String clientId, String clientSecret) throws Exception {
WebServicesClient client = new WebServicesClient();
Map args = new HashMap();
Map header = new HashMap();
Map payload = new HashMap();
List allowedStatuses = new ArrayList();
String request = String.format("%s/oauth/token?grant_type=client_credentials&client_id=%s&client_secret=%s", url, clientId, clientSecret);
args.put(WebServicesClient.ARG_URL, request);
header.put("Accept", "application/json");
allowedStatuses.add("200");
client.configure(args);
try {
String response = client.executePost(request, payload, header, allowedStatuses);
Map responseMap = JsonUtil.toMap(response);
String accessToken = (String) responseMap.get("access_token");
return accessToken;
} catch (Exception e) {
throw new Exception(String.format("Authentication call failed: %s", e.getMessage()));
}
}
String getJSONEntitlements(String url, String accessToken, String appId, String type) throws Exception {
WebServicesClient client = new WebServicesClient();
Map args = new HashMap();
Map header = new HashMap();
List allowedStatuses = new ArrayList();
String request = String.format("%s/beta/entitlements?filters=source.id eq \"%s\" and type eq \"%s\"", url, appId, type);
args.put(WebServicesClient.ARG_URL, request);
header.put("Authorization", String.format("Bearer %s", accessToken));
allowedStatuses.add("200");
client.configure(args);
try {
String response = client.executeGet(request, header, allowedStatuses);
return response;
} catch (Exception e) {
throw new Exception(String.format("Entitlements collection call failed: %s", e.getMessage()));
}
}
Map headers = requestEndPoint.getHeader();
String IDN_URL = (String) headers.get("X-IDNUrl");
if (IDN_URL == null) throw new Exception("X-IDNUrl header is missing");
log.debug("IDN_URL: " + IDN_URL);
String IDN_CLIENT_ID = (String) headers.get("X-IDNClientId");
if (IDN_CLIENT_ID == null) throw new Exception("X-IDNClientId header is missing");
log.debug("IDN_CLIENT_ID: " + IDN_CLIENT_ID);
String IDN_CLIENT_SECRET = (String) headers.get("X-IDNClientSecret");
if (IDN_CLIENT_SECRET == null) throw new Exception("X-IDNClientSecret header is missing");
log.debug("IDN_CLIENT_SECRET: " + IDN_CLIENT_SECRET);
String IDN_ATTRIBUTE = (String) headers.get("X-IDNAttribute");
if (IDN_ATTRIBUTE == null) throw new Exception("X-IDNAttribute header is missing");
if (IDN_ATTRIBUTE.indexOf(".") == -1) throw new Exception("X-IDNAttribute header wrong format. Use <entitlement_type>.<attribute> (e.g. group.members)");
log.debug("IDN_ATTRIBUTE: " + IDN_ATTRIBUTE);
String ENTITLEMENT_TYPE = IDN_ATTRIBUTE.split("\\.")[0];
log.debug("ENTITLEMENT_TYPE: " + ENTITLEMENT_TYPE);
String ENTITLEMENT_ATTRIBUTE = IDN_ATTRIBUTE.split("\\.")[1];
log.debug("ENTITLEMENT_ATTRIBUTE: " + ENTITLEMENT_ATTRIBUTE);
String APP_ID = application.getId();
log.debug("APP_ID: " + APP_ID);
Schema ACCOUNT_SCHEMA = application.getSchema("account");
log.debug("ACCOUNT_SCHEMA: " + ACCOUNT_SCHEMA.toString());
String IDENTITY_ATTRIBUTE = ACCOUNT_SCHEMA.getIdentityAttribute();
log.debug("IDENTITY_ATTRIBUTE: " + IDENTITY_ATTRIBUTE);
String ACCOUNT_ATTRIBUTE = null;
for (String attribute : ACCOUNT_SCHEMA.getAttributeNames()) {
AttributeDefinition attrDef = ACCOUNT_SCHEMA.getAttributeDefinition(attribute);
if (attrDef.isEntitlement() && ENTITLEMENT_TYPE.equals(attrDef.getSchemaObjectType())) {
ACCOUNT_ATTRIBUTE = attribute;
log.debug("ACCOUNT_ATTRIBUTE: " + ACCOUNT_ATTRIBUTE);
break;
}
}
if (ACCOUNT_ATTRIBUTE == null) throw new Exception(String.format("ACCOUNT_ATTRIBUTE was not found. Ensure the account schema contains an entitlement attribute of type %s", ENTITLEMENT_TYPE));
String ACCESS_TOKEN = authenticate(IDN_URL, IDN_CLIENT_ID, IDN_CLIENT_SECRET);
log.debug("ACCESS_TOKEN: " + ACCESS_TOKEN);
String JSON_ENTITLEMENTS = getJSONEntitlements(IDN_URL, ACCESS_TOKEN, APP_ID, ENTITLEMENT_TYPE);
log.debug("JSON_ENTITLEMENTS: " + JSON_ENTITLEMENTS);
for (Map account : processedResponseObject) {
log.debug("Processing record " + account.toString());
try {
String id = (String) account.get(IDENTITY_ATTRIBUTE);
String searchGroups = String.format("$.[?(@.attributes.%s contains '%s')]..value", ENTITLEMENT_ATTRIBUTE ,id);
List groups = JsonPath.parse(JSON_ENTITLEMENTS).read(searchGroups, List.class);
log.debug("Membership found: " + groups.toString());
account.put(ACCOUNT_ATTRIBUTE, groups);
} catch (NullPointerException npe) {
throw new Exception(String.format("Failed to find attribute %s on record %s", IDENTITY_ATTRIBUTE, account.toString()));
}
}
Map responseMap = new HashMap();
Map connectorStateMap = new HashMap();
responseMap.put("data", processedResponseObject);
responseMap.put("connectorStateMap", connectorStateMap);
return responseMap;
You can use this curl command for convenience:
curl --location --request POST 'https://<tenant>/beta/connector-rules' \
--header 'Authorization: Bearer <your_access_token>' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "//WSReverseMembershipAggregation\r\nimport java.util.HashMap;\r\nimport java.util.Map;\r\n\r\nimport com.jayway.jsonpath.JsonPath;\r\n\r\nimport org.apache.logging.log4j.Logger;\r\n\r\nimport java.util.List;\r\nimport java.util.ArrayList;\r\n\r\nimport sailpoint.connector.webservices.WebServicesClient;\r\nimport sailpoint.object.Schema;\r\nimport sailpoint.object.Application;\r\nimport sailpoint.object.AttributeDefinition;\r\nimport connector.common.JsonUtil;\r\n\r\nString authenticate(String url, String clientId, String clientSecret) throws Exception {\r\n WebServicesClient client = new WebServicesClient();\r\n Map args = new HashMap();\r\n Map header = new HashMap();\r\n Map payload = new HashMap();\r\n List allowedStatuses = new ArrayList();\r\n\r\n String request = String.format(\"%s/oauth/token?grant_type=client_credentials&client_id=%s&client_secret=%s\", url, clientId, clientSecret);\r\n args.put(WebServicesClient.ARG_URL, request);\r\n header.put(\"Accept\", \"application/json\");\r\n allowedStatuses.add(\"200\");\r\n\r\n client.configure(args);\r\n try {\r\n String response = client.executePost(request, payload, header, allowedStatuses);\r\n Map responseMap = JsonUtil.toMap(response);\r\n\r\n String accessToken = (String) responseMap.get(\"access_token\");\r\n\r\n return accessToken;\r\n } catch (Exception e) {\r\n throw new Exception(String.format(\"Authentication call failed: %s\", e.getMessage()));\r\n }\r\n}\r\n\r\nString getJSONEntitlements(String url, String accessToken, String appId, String type) throws Exception {\r\n WebServicesClient client = new WebServicesClient();\r\n Map args = new HashMap();\r\n Map header = new HashMap();\r\n List allowedStatuses = new ArrayList();\r\n\r\n String request = String.format(\"%s/beta/entitlements?filters=source.id eq \\\"%s\\\" and type eq \\\"%s\\\"\", url, appId, type);\r\n args.put(WebServicesClient.ARG_URL, request);\r\n header.put(\"Authorization\", String.format(\"Bearer %s\", accessToken));\r\n allowedStatuses.add(\"200\");\r\n\r\n client.configure(args);\r\n \r\n try {\r\n String response = client.executeGet(request, header, allowedStatuses);\r\n return response;\r\n } catch (Exception e) {\r\n throw new Exception(String.format(\"Entitlements collection call failed: %s\", e.getMessage()));\r\n }\r\n}\r\n\r\nMap headers = requestEndPoint.getHeader();\r\n \r\nString IDN_URL = (String) headers.get(\"X-IDNUrl\");\r\nif (IDN_URL == null) throw new Exception(\"X-IDNUrl header is missing\");\r\nlog.debug(\"IDN_URL: \" + IDN_URL);\r\n\r\nString IDN_CLIENT_ID = (String) headers.get(\"X-IDNClientId\");\r\nif (IDN_CLIENT_ID == null) throw new Exception(\"X-IDNClientId header is missing\");\r\nlog.debug(\"IDN_CLIENT_ID: \" + IDN_CLIENT_ID);\r\n\r\nString IDN_CLIENT_SECRET = (String) headers.get(\"X-IDNClientSecret\");\r\nif (IDN_CLIENT_SECRET == null) throw new Exception(\"X-IDNClientSecret header is missing\");\r\nlog.debug(\"IDN_CLIENT_SECRET: \" + IDN_CLIENT_SECRET);\r\n\r\nString IDN_ATTRIBUTE = (String) headers.get(\"X-IDNAttribute\");\r\nif (IDN_ATTRIBUTE == null) throw new Exception(\"X-IDNAttribute header is missing\");\r\nif (IDN_ATTRIBUTE.indexOf(\".\") == -1) throw new Exception(\"X-IDNAttribute header wrong format. Use <entitlement_type>.<attribute> (e.g. group.members)\");\r\nlog.debug(\"IDN_ATTRIBUTE: \" + IDN_ATTRIBUTE);\r\n\r\nString ENTITLEMENT_TYPE = IDN_ATTRIBUTE.split(\"\\\\.\")[0];\r\nlog.debug(\"ENTITLEMENT_TYPE: \" + ENTITLEMENT_TYPE);\r\n\r\nString ENTITLEMENT_ATTRIBUTE = IDN_ATTRIBUTE.split(\"\\\\.\")[1];\r\nlog.debug(\"ENTITLEMENT_ATTRIBUTE: \" + ENTITLEMENT_ATTRIBUTE);\r\n\r\nString APP_ID = application.getId();\r\nlog.debug(\"APP_ID: \" + APP_ID);\r\n\r\nSchema ACCOUNT_SCHEMA = application.getSchema(\"account\");\r\nlog.debug(\"ACCOUNT_SCHEMA: \" + ACCOUNT_SCHEMA.toString());\r\n\r\nString IDENTITY_ATTRIBUTE = ACCOUNT_SCHEMA.getIdentityAttribute();\r\nlog.debug(\"IDENTITY_ATTRIBUTE: \" + IDENTITY_ATTRIBUTE);\r\n\r\nString ACCOUNT_ATTRIBUTE = null;\r\nfor (String attribute : ACCOUNT_SCHEMA.getAttributeNames()) {\r\n AttributeDefinition attrDef = ACCOUNT_SCHEMA.getAttributeDefinition(attribute);\r\n if (attrDef.isEntitlement() && ENTITLEMENT_TYPE.equals(attrDef.getSchemaObjectType())) {\r\n ACCOUNT_ATTRIBUTE = attribute;\r\n log.debug(\"ACCOUNT_ATTRIBUTE: \" + ACCOUNT_ATTRIBUTE);\r\n break;\r\n }\r\n}\r\n\r\nif (ACCOUNT_ATTRIBUTE == null) throw new Exception(String.format(\"ACCOUNT_ATTRIBUTE was not found. Ensure the account schema contains an entitlement attribute of type %s\", ENTITLEMENT_TYPE));\r\n\r\nString ACCESS_TOKEN = authenticate(IDN_URL, IDN_CLIENT_ID, IDN_CLIENT_SECRET);\r\nlog.debug(\"ACCESS_TOKEN: \" + ACCESS_TOKEN);\r\nString JSON_ENTITLEMENTS = getJSONEntitlements(IDN_URL, ACCESS_TOKEN, APP_ID, ENTITLEMENT_TYPE);\r\nlog.debug(\"JSON_ENTITLEMENTS: \" + JSON_ENTITLEMENTS);\r\n\r\nfor (Map account : processedResponseObject) {\r\n log.debug(\"Processing record \" + account.toString());\r\n try {\r\n String id = (String) account.get(IDENTITY_ATTRIBUTE);\r\n String searchGroups = String.format(\"$.[?(@.attributes.%s contains '%s')]..value\", ENTITLEMENT_ATTRIBUTE ,id);\r\n List groups = JsonPath.parse(JSON_ENTITLEMENTS).read(searchGroups, List.class);\r\n log.debug(\"Membership found: \" + groups.toString());\r\n account.put(ACCOUNT_ATTRIBUTE, groups);\r\n } catch (NullPointerException npe) {\r\n throw new Exception(String.format(\"Failed to find attribute %s on record %s\", IDENTITY_ATTRIBUTE, account.toString()));\r\n }\r\n}\r\n\r\nMap responseMap = new HashMap();\r\nMap connectorStateMap = new HashMap();\r\n\r\nresponseMap.put(\"data\", processedResponseObject);\r\nresponseMap.put(\"connectorStateMap\", connectorStateMap);\r\n\r\nreturn responseMap;"
},
"attributes": {}
}'
- Now update your connector operation to use that WSAO rule:
curl --location --request PATCH 'https://<tenant>/beta/sources/<sourceid>' \
--header 'Content-Type: application/json-patch+json' \
--header 'Authorization: Bearer <your_access_token>' \
--data-raw '[
{
"op" : "replace",
"path" : "/connectorAttributes/connectionParameters/x/afterRule",
"value" : "WSReverseMembershipAggregation"
}
]'
Where /x/ is the number of the operation trying to patch.
- Finally, you need to pass on your configuration parameters using custom headers:
- X-IDNUrl: IDN API url
- X-IDNClientId: IDN admin PAT ID
- X-IDNClientSecret: IDN admin PAT secret
- X-IDNAttribute: it identifies the entitlement type and the attribute that holds entitlement to account information. In the previous example, it would be group.members.
You should be good to go. Please try it, have a look at the code and if you find errors or limitations (there will be), let me know.
Hope this helps.