ArchitectureMay 2026

Implementing Recursive Governance
(How to govern SailPoint ISC PATs using a Web Services Connector)

Tyler

Tyler

IdentityEXE Founder

1. 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.

2. 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 such PATs without any 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
    • API Endpoints:
      • GET /v2025/personal-access-tokens
      • DELETE /v2025/personal-access-tokens/{id}

3. Implementation Deep Dive

Schema Design: What attributes are we mapping and from where?

Account - /v2025/personal-access-tokens
  • identitynameOwner.Name
  • identityidOwner.id
  • PATidsid
Group - /v2025/personal-access-tokens
  • idid
  • scopescope
  • createdcreated
  • lastUsedlastUsed
  • managedmanaged
  • accessTokenValiditySecondsaccessTokenValiditySeconds
  • expirationDateexpirationDate
  • userAwareTokenNeverExpiresuserAwareTokenNeverExpires
  • namename
  • ownertypeOwner.Type
  • owneridOwner.id
  • ownernameOwner.name
  • description ↔ Custom Mapping from Rule

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 done by using the same endpoint (/v2025/personal-access-tokens) for both account and group aggregation.

Virtual Entitlement representation in SailPoint ISC

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 the features flag only contains PROVISIONING, which is all this connector needs to be able to pull in and revoke PATs.

4. Closing the Loop: The Certification Campaign

  • Visibility: Since we can now store these PATs as entitlements on a user's identity cube, that opens up the gates for us to do certifications on the PATs.
  • The Review Process: Managers can now see exactly how many active tokens their developers have and when they were last used.
Certification Campaign Manager Review
  • Remediation: Using the "Revoke" action in the certification to trigger the API call that revokes the token in ISC.
Remediation Revoke Action

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 gist of the rule is that it scans for the first occurrence of a user then will look at all entries to grab the PAT IDs assigned to them then saves that single row to our 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, I used this rule to specifically group all the relevant attributes about each PAT and separate them by |'s 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",
            "type": "STRING",
            "isMulti": true,
            "isEntitlement": false,
            "isGroup": false
        },
        {
            "name": "scope",
            "type": "STRING",
            "isMulti": true,
            "isEntitlement": false,
            "isGroup": false
        },
        {
            "name": "created",
            "type": "STRING"
        },
        {
            "name": "lastUsed",
            "type": "STRING"
        },
        {
            "name": "managed",
            "type": "STRING"
        },
        {
            "name": "accessTokenValiditySeconds",
            "type": "STRING"
        },
        {
            "name": "expirationDate",
            "type": "STRING"
        },
        {
            "name": "userAwareTokenNeverExpires",
            "type": "STRING"
        },
        {
            "name": "name",
            "type": "STRING"
        },
        {
            "name": "ownertype",
            "type": "STRING"
        },
        {
            "name": "ownerid",
            "type": "STRING"
        },
        {
            "name": "ownername",
            "type": "STRING"
        },
        {
            "name": "description",
            "type": "STRING"
        }
    ],
    "name": "group"
}

Source Configuration

{
    "description": "Personal Access Token Management",
    "owner": {
        "type": "IDENTITY",
        "id": "",
        "name": ""
    },
    "features": [
        "PROVISIONING"
    ],
    "type": "Web Services",
    "connector": "web-services-angularsc",
    "connectorAttributes": {
        "connectionType": "direct",
        "authenticationMethod": "OAuth2Login",
        "genericWebServiceBaseUrl": "",
        "connectionParameters": [
            {
                "httpMethodType": "GET",
                "uniqueNameForEndPoint": "Account Aggregation",
                "afterRule": "Personal Access Token Management - After Aggregation Rule",
                "rootPath": "[*]",
                "resMappingObj": {
                    "identityname": "owner.name",
                    "identityid": "owner.id",
                    "PATid": "id"
                },
                "contextUrl": "/v2025/personal-access-tokens",
                "operationType": "Account Aggregation"
            },
            {
                "httpMethodType": "GET",
                "uniqueNameForEndPoint": "Get Object",
                "afterRule": "Personal Access Token Management - After Aggregation Rule",
                "rootPath": "[*]",
                "resMappingObj": {
                    "identityname": "owner.name",
                    "identityid": "owner.id",
                    "PATid": "id"
                },
                "contextUrl": "/v2025/personal-access-tokens?owner-id=$getobject.nativeIdentity$",
                "operationType": "Get Object"
            },
            {
                "httpMethodType": "GET",
                "uniqueNameForEndPoint": "Group Aggregation",
                "afterRule": "Personal Access Token Management - After Group Aggregation Rule",
                "rootPath": "[*]",
                "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",
                "operationType": "Group Aggregation"
            },
            {
                "httpMethodType": "DELETE",
                "uniqueNameForEndPoint": "Remove Entitlement",
                "contextUrl": "/v2025/personal-access-tokens/$plan.PATid$",
                "operationType": "Remove Entitlement"
            }
        ]
    },
    "name": "Personal Access Token Management"
}

Conclusion

By implementing recursive governance, you regain control and visibility over Personal Access Tokens, treating them with the same security rigor as regular entitlements. This ensures that unused or risky PATs can be effectively audited and revoked automatically.

Need help configuring PAT governance?

Setting up custom Web Services connectors, consolidation rules, and certification campaigns in SailPoint requires careful execution. Connect with me directly to get this configured for your tenant.

Talk to an Expert