The Problem: “Invisible” Access
- The Rise of PATs: Personal Access Tokens (PATs) are very common in SailPoint ISC implementations due to almost all ISC API endpoints requiring a PAT to execute the call.
- The Governance Gap: Most organizations certify User-to-Group memberships but lose visibility once a user generates a long-lived PAT.
- The Risk: A PAT often carries the full permissions of the user who created it, effectively acting as a “ghost” credential that bypasses standard SSO/MFA after creation.
The Architecture: Recursive Governance
- The “Why”: Why build a custom connector? Because treating PATs as “Entitlements” in a separate Source allows them to be pulled into the standard Identity Cube and gain crucial visibility.
- Connector Choice: By using a Web Services connector, we can automate the aggregation, removal, and reviews of PATs without manual effort.
- Endpoint Configuration - List Personal Access tokens
- Base URL:
https://{tenant}.api.identitynow.com - Authentication: OAuth2 (Client Credentials).
- POST
https://{tenant}.api.identitynow.com/oauth/token
- POST
- API Endpoints:
- GET
/v2025/personal-access-tokens - DELETE
/v2025/personal-access-tokens/{id}
- GET
- Base URL:
Implementation Deep Dive
-
Schema Design: What attributes are we mapping and from where?
- Account -
/v2025/personal-access-tokens- identityname ↔ Owner.Name
- identityid ↔ Owner.id
- PATids ↔ id
- Group -
/v2025/personal-access-tokens- id ↔ id
- scope ↔ scope
- created ↔ created
- lastUsed ↔ lastUsed
- managed ↔ managed
- accessTokenValiditySeconds ↔ accessTokenValiditySeconds
- expirationDate ↔ expirationDate
- userAwareTokenNeverExpires ↔ userAwareTokenNeverExpires
- name ↔ name
- ownertype ↔ Owner.Type
- ownerid ↔ Owner.id
- ownername ↔ Owner.name
- description ↔ Custom Mapping from Rule
- Account -
-
The “Virtual” Entitlement: Although PATs don’t grant “access”, we can represent them as entitlements so we can perform governance on the tokens themselves. This is accomplished by querying the same
/v2025/personal-access-tokensendpoint for both account and group aggregations.
- Source JSON Changes: Since this connector is custom and serves a very specific purpose, I’ve removed many of the standard features that web services connectors typically have so we don’t accidentally call them and throw errors in the tenant. You’ll see in the source JSON below that the
featuresflag only containsPROVISIONING, which is all this connector needs to be able to pull in and revoke PATs.
Closing the Loop: The Certification Campaign
- Visibility: Since we can now store these PATs as entitlements on a user’s identity cube, this opens the door for us to run certification campaigns on the PATs.
- The Review Process: Managers can now see exactly how many active tokens their developers have and when they were last used.
- Remediation: Using the “Revoke” action in the certification campaign triggers the API call that revokes the token in ISC.
5. Code & Design Choices
After Account Aggregation Rule
The After Account Aggregation rule is what allows us to save back an account object for a user even though we receive multiple entries of PATs in the GET /v2025/personal-access-tokens call. The rule scans for the first occurrence of a user, aggregates all PAT IDs assigned to them, and saves that single consolidated row back to processedResponseObject.
import connector.common.Util;
import java.util.*;
import java.net.*;
import java.io.*;
import org.json.*;
String logPrefix = "Personal Access Token Management - After Aggregation Rule";
// ── CONFIG ────────────────────────────────────────────────────────────────────
String baseUrl = application.getAttributeValue("genericWebServiceBaseUrl");
Map headerMap = requestEndPoint.getHeader();
String accessToken = (headerMap == null) ? null : (String) headerMap.get("Authorization");
if (Util.isNullOrEmpty(accessToken)) throw new Exception("Authorization header is missing");
// ── MAIN ──────────────────────────────────────────────────────────────────────
if (!(processedResponseObject instanceof List)) return processedResponseObject;
List accounts = (List) processedResponseObject;
log.error(logPrefix + "processedResponseObject rows: " + accounts.size());
// ── STEP 1: collect all PATids per identityid from processedResponseObject ───
// identityid -> List of PATids
Map patsByIdentity = new LinkedHashMap();
// identityid -> first row index (the row we'll keep)
Map firstRowIndex = new HashMap();
for (int i = 0; i < accounts.size(); i++) {
Object rowObj = accounts.get(i);
if (!(rowObj instanceof Map)) continue;
Map row = (Map) rowObj;
Object identityIdObj = row.get("identityid");
if (identityIdObj == null) continue;
String identityId = String.valueOf(identityIdObj).trim();
if (Util.isNullOrEmpty(identityId)) continue;
if (!patsByIdentity.containsKey(identityId)) {
patsByIdentity.put(identityId, new ArrayList());
firstRowIndex.put(identityId, i);
}
Object patIdObj = row.get("PATid");
if (patIdObj instanceof List) {
((List) patsByIdentity.get(identityId)).addAll((List) patIdObj);
} else if (patIdObj != null) {
String patId = String.valueOf(patIdObj).trim();
if (Util.isNotNullOrEmpty(patId)) {
((List) patsByIdentity.get(identityId)).add(patId);
}
}
}
log.error(logPrefix + "Unique identities found: " + patsByIdentity.size());
// ── STEP 2: update the first row for each identity with full PATid list,
// mark duplicate rows for removal ────────────────────────────────
Set indicesToRemove = new HashSet();
for (Object identityId : patsByIdentity.keySet()) {
int keepIdx = (Integer) firstRowIndex.get(identityId);
Map keepRow = (Map) accounts.get(keepIdx);
keepRow.put("PATid", patsByIdentity.get(identityId));
log.error(logPrefix + "identityId " + identityId + " -> PATids: " + patsByIdentity.get(identityId));
// Mark all other rows with this identityid for removal
for (int i = 0; i < accounts.size(); i++) {
if (i == keepIdx) continue;
Object rowObj = accounts.get(i);
if (!(rowObj instanceof Map)) continue;
Object rowIdentityId = ((Map) rowObj).get("identityid");
if (rowIdentityId != null && identityId.equals(String.valueOf(rowIdentityId).trim())) {
indicesToRemove.add(i);
}
}
}
// ── STEP 3: remove duplicate rows (iterate in reverse to preserve indices) ───
List indicesToRemoveList = new ArrayList(indicesToRemove);
Collections.sort(indicesToRemoveList, Collections.reverseOrder());
for (Object idx : indicesToRemoveList) {
accounts.remove((int)(Integer) idx);
}
log.error(logPrefix + "Final row count after consolidation: " + accounts.size());
return processedResponseObject;
After Group Aggregation Rule
The After Group Aggregation rule is purely for populating the description attribute of each entitlement. Since we want the reviewer to be able to view all the details up front in the certification campaign, I used this rule to group all the relevant attributes about each PAT and separate them by pipe characters (|) so the reviewer has full context as to what they are reviewing. Here is an example description field for a PAT:
Name: AI Agent | Owner: IdentityEXE | Scopes: iai:access-request-recommender:read, iai:decisions:manage | Created: 2026-05-17T18:45:50.565Z | Last Used: N/A | Expiration Date: N/A | Managed: false | Access Token Validity: 43200 | User Aware Token Never Expires: N/A
import connector.common.Util;
import java.util.*;
String logPrefix = "Personal Access Token Management - After Group Aggregation Rule";
if (!(processedResponseObject instanceof List)) return processedResponseObject;
List groups = (List) processedResponseObject;
for (Object groupObj : groups) {
if (!(groupObj instanceof Map)) continue;
Map group = (Map) groupObj;
// 1. Handle Scopes (Multi-valued to Pipe/Comma Delimited)
String scopesStr = "None";
Object scopeObj = group.get("scope");
if (scopeObj instanceof List) {
scopesStr = Util.listToCsv((List) scopeObj);
} else if (scopeObj != null) {
scopesStr = String.valueOf(scopeObj);
}
// 2. Data Extraction with N/A fallbacks
String name = group.get("name") != null ? String.valueOf(group.get("name")) : "N/A";
String owner = group.get("ownername") != null ? String.valueOf(group.get("ownername")) : "N/A";
String created = group.get("created") != null ? String.valueOf(group.get("created")) : "N/A";
String lastUsed = group.get("lastUsed") != null ? String.valueOf(group.get("lastUsed")) : "N/A";
String expDate = group.get("expirationDate") != null ? String.valueOf(group.get("expirationDate")) : "N/A";
String managed = group.get("managed") != null ? String.valueOf(group.get("managed")) : "N/A";
String validity = group.get("accessTokenValiditySeconds") != null ? String.valueOf(group.get("accessTokenValiditySeconds")) : "N/A";
String neverExp = group.get("userAwareTokenNeverExpires") != null ? String.valueOf(group.get("userAwareTokenNeverExpires")) : "N/A";
// 3. Formatted Description String
StringBuilder sb = new StringBuilder();
sb.append("Name: ").append(name).append(" | ");
sb.append("Owner: ").append(owner).append(" | ");
sb.append("Scopes: ").append(scopesStr).append(" | ");
sb.append("Created: ").append(created).append(" | ");
sb.append("Last Used: ").append(lastUsed).append(" | ");
sb.append("Expiration Date: ").append(expDate).append(" | ");
sb.append("Managed: ").append(managed).append(" | ");
sb.append("Access Token Validity: ").append(validity).append(" | ");
sb.append("User Aware Token Never Expires: ").append(neverExp);
group.put("description", sb.toString());
}
return processedResponseObject;
Account Schema
{
"nativeObjectType": "user",
"identityAttribute": "identityid",
"displayAttribute": "identityname",
"hierarchyAttribute": null,
"includePermissions": false,
"features": [],
"configuration": {},
"attributes": [
{
"name": "identityid",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "identityid",
"isMulti": false,
"isEntitlement": false,
"isGroup": false
},
{
"name": "identityname",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "identityname",
"isMulti": false,
"isEntitlement": false,
"isGroup": false
},
{
"name": "PATid",
"nativeName": null,
"type": "STRING",
"schema": {
"type": "CONNECTOR_SCHEMA",
"id": "GROUPSCHEMAIDHERE",
"name": "group"
},
"description": "PATid",
"isMulti": true,
"isEntitlement": true,
"isGroup": true
}
],
"name": "account"
}
Group Schema
{
"nativeObjectType": "group",
"identityAttribute": "id",
"displayAttribute": "name",
"hierarchyAttribute": null,
"includePermissions": false,
"features": [],
"configuration": {},
"attributes": [
{
"name": "id",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "id",
"isMulti": true,
"isEntitlement": false,
"isGroup": false
},
{
"name": "scope",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "scope",
"isMulti": true,
"isEntitlement": false,
"isGroup": false
},
{
"name": "created",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "created",
"isMulti": false,
"isEntitlement": false,
"isGroup": false
},
{
"name": "lastUsed",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "lastUsed",
"isMulti": false,
"isEntitlement": false,
"isGroup": false
},
{
"name": "managed",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "managed",
"isMulti": false,
"isEntitlement": false,
"isGroup": false
},
{
"name": "accessTokenValiditySeconds",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "accessTokenValiditySeconds",
"isMulti": false,
"isEntitlement": false,
"isGroup": false
},
{
"name": "expirationDate",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "expirationDate",
"isMulti": false,
"isEntitlement": false,
"isGroup": false
},
{
"name": "userAwareTokenNeverExpires",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "userAwareTokenNeverExpires",
"isMulti": false,
"isEntitlement": false,
"isGroup": false
},
{
"name": "name",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "name",
"isMulti": false,
"isEntitlement": false,
"isGroup": false
},
{
"name": "ownertype",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "ownertype",
"isMulti": false,
"isEntitlement": false,
"isGroup": false
},
{
"name": "ownerid",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "ownerid",
"isMulti": false,
"isEntitlement": false,
"isGroup": false
},
{
"name": "ownername",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "ownername",
"isMulti": false,
"isEntitlement": false,
"isGroup": false
},
{
"name": "description",
"nativeName": null,
"type": "STRING",
"schema": null,
"description": "description",
"isMulti": false,
"isEntitlement": false,
"isGroup": false
}
],
"name": "group"
}
Source Configuration
{
"description": "Personal Access Token Management",
"owner": {
"type": "IDENTITY",
"id": "",
"name": ""
},
"cluster": {
"type": "CLUSTER",
"id": "",
"name": ""
},
"accountCorrelationConfig": {
"type": "ACCOUNT_CORRELATION_CONFIG",
"id": "",
"name": ""
},
"accountCorrelationRule": null,
"managerCorrelationMapping": null,
"managerCorrelationRule": null,
"beforeProvisioningRule": null,
"schemas": [
{
"configuration": {},
"type": "CONNECTOR_SCHEMA",
"id": "",
"name": "account"
},
{
"configuration": {},
"type": "CONNECTOR_SCHEMA",
"id": "",
"name": "group"
}
],
"passwordPolicies": null,
"features": [
"PROVISIONING"
],
"type": "Web Services",
"connector": "web-services-angularsc",
"connectorClass": "sailpoint.connector.webservices.WebServicesConnector",
"connectorAttributes": {
"healthCheckTimeout": 30,
"clientCertificate": null,
"deltaAggregationEnabled": false,
"accesstoken": null,
"throwProvBeforeRuleException": true,
"connectionType": "direct",
"client_id": "",
"numPartitionThreads": null,
"enableProvisioningFeature": true,
"password": null,
"client_secret": "",
"clientKeySpec": null,
"saml_headers_to_exclude": null,
"sourceConnected": true,
"saml_headers": null,
"private_key": null,
"version": "v2",
"labels": [
"standard"
],
"slpt-source-diagnostics": "{\"connector\":\"web-services-angularsc\",\"status\":\"SOURCE_STATE_HEALTHY\",\"healthy\":true,\"healthcheckDisabled\":false,\"healthcheckCount\":37,\"lastHealthcheck\":1778971885692,\"statusChanged\":1778885233950}",
"formPath": null,
"refresh_token": null,
"cloudCacheUpdate": 1778971885059,
"saml_request_body": null,
"authenticationMethod": "OAuth2Login",
"httpCookieSpecsStandard": "true",
"connectorName": "Web Services",
"enableStatus": null,
"since": "2026-05-15T22:47:13.950Z",
"status": "SOURCE_STATE_HEALTHY",
"supportsDeltaAgg": "true",
"lastAggregationDate_group": "2026-05-16T22:45:53Z",
"resourceOwnerUsername": null,
"oAuthJwtHeader": null,
"enableHasMore": false,
"isGetObjectRequiredForPTA": true,
"timeoutInSeconds": "60",
"genericWebServiceBaseUrl": "",
"resourceOwnerPassword": null,
"connectionParameters": [
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"sequenceNumberForEndpoint": "1",
"uniqueNameForEndPoint": "Test Connection",
"rootPath": null,
"body": {
"jsonBody": null,
"bodyFormat": "raw"
},
"paginationSteps": null,
"responseCode": null,
"resMappingObj": null,
"contextUrl": "/v2025/personal-access-tokens",
"pagingSize": 50,
"curlEnabled": false,
"header": {
"Authorization": "Bearer $application.accesstoken$"
},
"operationType": "Test Connection",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"sequenceNumberForEndpoint": "2",
"uniqueNameForEndPoint": "Account Aggregation",
"afterRule": "Personal Access Token Management - After Aggregation Rule",
"rootPath": "[*]",
"body": {
"jsonBody": null,
"bodyFormat": "raw"
},
"paginationSteps": null,
"responseCode": [
"2**"
],
"resMappingObj": {
"identityname": "owner.name",
"identityid": "owner.id",
"PATid": "id"
},
"contextUrl": "/v2025/personal-access-tokens",
"pagingSize": 50,
"curlEnabled": false,
"header": {
"Authorization": "$application.accesstoken$"
},
"operationType": "Account Aggregation",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"sequenceNumberForEndpoint": "3",
"uniqueNameForEndPoint": "Get Object",
"afterRule": "Personal Access Token Management - After Aggregation Rule",
"rootPath": "[*]",
"body": {
"jsonBody": null,
"bodyFormat": "raw"
},
"paginationSteps": null,
"responseCode": [
"2**"
],
"resMappingObj": {
"identityname": "owner.name",
"identityid": "owner.id",
"PATid": "id"
},
"contextUrl": "/v2025/personal-access-tokens?owner-id=$getobject.nativeIdentity$",
"pagingSize": 50,
"curlEnabled": false,
"header": {
"Authorization": "$application.accesstoken$"
},
"operationType": "Get Object",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "GET",
"pagingInitialOffset": 0,
"sequenceNumberForEndpoint": "4",
"uniqueNameForEndPoint": "Group Aggregation",
"afterRule": "Personal Access Token Management - After Group Aggregation Rule",
"rootPath": "[*]",
"body": {
"jsonBody": null,
"bodyFormat": "raw"
},
"paginationSteps": null,
"responseCode": [
"2**"
],
"resMappingObj": {
"lastUsed": "lastUsed",
"created": "created",
"managed": "managed",
"ownername": "owner.name",
"ownertype": "owner.type",
"accessTokenValiditySeconds": "accessTokenValiditySeconds",
"scope": "scope",
"name": "name",
"id": "id",
"ownerid": "owner.id",
"userAwareTokenNeverExpires": "userAwareTokenNeverExpires",
"expirationDate": "expirationDate"
},
"contextUrl": "/v2025/personal-access-tokens",
"pagingSize": 50,
"curlEnabled": false,
"header": {
"Authorization": "Bearer $application.accesstoken$"
},
"operationType": "Group Aggregation",
"xpathNamespaces": null,
"parentEndpointName": null
},
{
"httpMethodType": "DELETE",
"pagingInitialOffset": 0,
"sequenceNumberForEndpoint": "5",
"uniqueNameForEndPoint": "Remove Entitlement",
"rootPath": null,
"body": {
"jsonBody": null,
"bodyFormat": "raw"
},
"paginationSteps": null,
"responseCode": [
"2**"
],
"resMappingObj": null,
"contextUrl": "/v2025/personal-access-tokens/$plan.PATid$",
"pagingSize": 50,
"curlEnabled": false,
"header": {
"Authorization": "$application.accesstoken$"
},
"operationType": "Remove Entitlement",
"xpathNamespaces": null,
"parentEndpointName": null
}
],
"lockStatus": null,
"oauth_request_parameters": null,
"grant_type": "CLIENT_CREDENTIALS",
"partitionAggregationEnabled": false,
"deleteStatus": null,
"hasFullAggregationCompleted": true,
"deltaAggregation": null,
"token_url": " ",
"possibleHttpErrors": {
"errorMessages": null,
"errorCodes": null
},
"oauth_body_attrs_to_exclude": null,
"throwProvAfterRuleException": true,
"lastAggregationDate_account": "2026-05-16T22:51:24Z",
"deleteThresholdPercentage": 10,
"fixedPlanMultivaluedAttribute": "true",
"oauth_headers": null,
"templateApplication": "Web Services Template",
"oauth_token_info": "",
"encrypted": "accesstoken,refresh_token,oauth_token_info,client_secret,private_key,private_key_password,clientCertificate,clientKeySpec,resourceOwnerPassword,custom_auth_token_info",
"healthy": true,
"private_key_password": null,
"cloudDisplayName": "Personal Access Token Management",
"oAuthJwtPayload": null,
"oauth_headers_to_exclude": null,
"saml_assertion_url": null,
"beforeProvisioningRule": null,
"username": null
},
"deleteThreshold": 10,
"authoritative": false,
"healthy": true,
"status": "SOURCE_STATE_HEALTHY",
"since": "2026-05-15T22:47:13.950Z",
"connectorId": "web-services-angularsc",
"connectorName": "Web Services",
"connectionType": "direct",
"connectorImplementationId": "web-services-angularsc",
"managementWorkgroup": null,
"credentialProviderEnabled": false,
"category": null,
"accountsFile": null,
"entitlementFiles": null,
"meta": null,
"id": "",
"name": "Personal Access Token Management",
"created": "2026-05-15T17:44:57.412Z",
"modified": "2026-05-16T22:51:25.702Z"
}
How It Works & Key Benefits
Now that the source configuration and rules are in place, let’s look at how the entire solution functions end-to-end:
End-to-End Flow:
-
Aggregation: The Web Services source runs a group aggregation and calls the
/v2025/personal-access-tokensendpoint. The After Group Aggregation rule processes each token as an entitlement (Group) and sets its detailed metadata (owner, scopes, expiration, created, last used) as the entitlement description. Next, during account aggregation, the same endpoint is called. The After Account Aggregation rule consolidates multiple tokens per user into a single account record with a multi-valuedPATidattribute listing all of the user’s active token IDs. -
Access Certification: Administrators schedule a certification campaign targeting this Web Services source. Because the tokens are modeled as entitlements, they appear on each user’s cube. Managers can view each token under the developer’s name, seeing its full metadata (scopes, last used date, etc.) directly in the certification UI.
-
Revocation Remediation: If a manager decides a token is no longer needed (e.g., it is inactive or has excessive scopes), they mark it for revocation. When the certification is submitted, the provisioning engine executes a
Remove Entitlementaction, sending a DELETE request to/v2025/personal-access-tokens/{id}for the selected PAT ID. This instantly revokes the token in SailPoint ISC.
Key Benefits:
- Unified Visibility: Brings previously “invisible” API credentials under the standard governance umbrella, making them visible to security teams and managers in the standard Identity Cube.
- Automated Lifecycle Control: Enables instant, automated remediation. When a manager revokes a PAT entitlement, the token is physically deleted via API without any manual admin tickets or delay.
- Context-Rich Certification: Reviewers aren’t just looking at random token IDs. Thanks to the format of the description generated by the custom group rule, they see scope permissions and usage dates directly in the UI.
- Zero Extra Infrastructure: The solution uses standard Web Services connector attributes and SailPoint Rules, requiring no external databases, servers, or custom integrations.
Conclusion
- Summary: Recursive governance provides an elegant, out-of-the-box workaround to a common security gap in SailPoint ISC. By treating Personal Access Tokens as certifiable entitlements on a loopback source, organizations can secure their API footprint and enforce manager-led lifecycle controls.
- Call to Action: If you have any questions, suggestions, or extensions to this Web Services approach, feel free to comment below. Let’s collaborate on making SailPoint governance even more robust!



