The Zurich release has arrived! Interested in new features and functionalities? Click here for more

custom widget for SailPoint access

Adam43
Tera Contributor

I've been working with our sailpoint(SP) team to build a catalog item that will handle several api calls to SP, deal with the data retrieved, and submit an access change request (both add and remove).  All of this live and dynamic in the service portal.

I went thru several iterations of trying the api call in flow designer (custom scripted actions), catalog with all the standard variables and client scripts talking to script include to call the http method, then all that in reverse to sort and display the data back on portal.  I eventually figured out that building it all in a widget (no more catalog variables, I'm using the widget fields for html, client and server scripts to handle this all) might simplify troubleshooting and logic.

 

I'm having success with the widget- but am stuck.  Here's the status- Submitter chooses an agency from a dropdown and enters a string search value (name or email), then via API an HTTP method calls SailPoint to retrieve relevant user listing based on agency and name/email value.  The widget takes the returned results (possible more than one) and displays a drop down of the results (parsed from the response body).  Submitter then selects one of the users to deal with- at which time several more calls to SailPoint go out to retrieve that specific user's roles (a GET i think), and also the current list of ServiceNow relevant roles that filter by an attribute (a POST)- the widget then computes which roles meet the attribute (requestableInSnow) for that user and the goal is to then display those roles- showing "currently applied" and "available to request" for that user.  Submitter would then make adjustments (add or remove one or more roles) and submit- triggering a SailPoint call again (POST) that submits a SailPoint Request to make the changes.  I'm really hoping to be able to trigger the OOTB Portal "submit" button- but I'd like to record the essential data of what's happening to the resulting RITM somehow- this will be incase of error, the Okta/SailPoint team can manually go check things out via a triggered flow with tasking.

 

I'm stuck at the display of current/available and making the swaps.  I went with having html display 2 columns in a table (similar to slush bucket)- one of available, one of current.  It offers a 'filter by app' at the top (another attribute field in SP) so they can narrow down how many roles display. The initial display of these roles is accurate and functioning (filters etc.).  BUT I cannot get action buttons to work and capture the status of the role changes.  For user experience- I'd like a role, once flipped from one side to the other, to show a status as 'pending add/remove' instead of just the same info as other roles already in that column. 

 

I need help getting the relevant scripts to capture and update the displayed roles after I click 'add' or 'remove' on the role.  Then we want to summarize those changes for sending to SailPoint for action.

 

I'm also trying to log the calls themselves for troubleshooting- I have that controlled with a system property, and also am learning the browser console/network tabs in developer tools while I build this.

ChatGPT is somewhat useful (using NOW Developer GPT), because we don't have NowAssist.

Adam43_0-1754515139972.png

 

Widget Gods- please bless me with your wisdom.

I'm open to architecture suggestions, but really hoping for straight widget coder help.  I mostly don't have issues with the REST/http methods- it's the client/server scripts and html/css fields.  But if you think useful, I can add the http methods I use too.

 

HTML: 

<div class="form-group">
<label>Select Agency</label>
<select class="form-control"
ng-model="c.data.selectedAgency"
ng-change="c.onAgencyChange()">
<option value="">-- Select an Agency --</option>
<option ng-repeat="a in c.data.agencies | orderBy:'name'"
value="{{a.sys_id}}">
{{a.name}}
</option>
</select>
</div>

<div class="form-group" ng-if="c.data.selectedAgency">
<label>Enter Search Value (Last Name or Email)</label>
<input id="searchValue"
type="text"
class="form-control"
ng-model="c.data.searchValue"
placeholder="Type and press Enter"
ng-keydown="$event.which===13 && c.searchUser()" />
<button class="btn btn-primary"
ng-click="c.searchUser()"
ng-disabled="!c.data.agencyAcronymReady || c.loading"
style="margin-top:8px;">
Search
</button>
</div>

<div class="form-group" ng-if="c.data.userSearchResults.length">
<label>Select a User:</label>
<select id="userSelect"
class="form-control"
ng-model="c.data.selectedUser">
<option value="">-- Select a User --</option>
<option ng-repeat="u in c.data.userSearchResults"
value="{{u.id}}">
{{u.display}}
</option>
</select>
</div>

<div ng-if="c.data.selectedUser">
<!-- App filter checkboxes -->
<div class="form-group">
<label>Filter by Application</label>
<div class="checkbox" ng-repeat="app in c.appNames">
<label>
<input type="checkbox"
ng-model="c.selectedApps[app]"
ng-change="c.updateAppFilter()" />
{{app}}
</label>
</div>
</div>

<div class="row">
<!-- Available Roles -->
<div class="col-md-6">
<h4>Available Roles</h4>
<table class="table table-striped table-condensed">
<thead>
<tr><th>Role</th><th>Status</th><th style="width:1%">Action</th></tr>
</thead>
<tbody>
<tr ng-repeat="r in c.data.availableRoles
| filter:c.appNameFilterFn">
<td>{{r.name}}</td>
<td>
<span ng-if="c.isAdded(r)">Add</span>
<span ng-if="!c.isAdded(r)">Available</span>
</td>
<td>
<button ng-if="!c.isAdded(r)"
class="btn btn-xs btn-success"
ng-click="c.moveToCurrent(r)">
Add
</button>
<button ng-if="c.isAdded(r)"
class="btn btn-xs btn-default"
disabled>

</button>
</td>
</tr>
</tbody>
</table>
</div>

<!-- Current Roles -->
<div class="col-md-6">
<h4>Current Roles</h4>
<table class="table table-striped table-condensed">
<thead>
<tr><th>Role</th><th>Status</th><th style="width:1%">Action</th></tr>
</thead>
<tbody>
<tr ng-repeat="r in c.data.currentRoles
| filter:c.appNameFilterFn">
<td>{{r.name}}</td>
<td>
<span ng-if="c.isRemoved(r)">Remove</span>
<span ng-if="!c.isRemoved(r)">Current</span>
</td>
<td>
<button ng-if="!c.isRemoved(r)"
class="btn btn-xs btn-danger"
ng-click="c.moveToAvailable(r)">
Remove
</button>
<button ng-if="c.isRemoved(r)"
class="btn btn-xs btn-default"
disabled>

</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>

<!-- confirmation -->
<p class="text-success text-center"
ng-if="c.data.confirmationMessage">
{{c.data.confirmationMessage}}
</p>
</div>

 

 

 

CSS:

/* Table cell padding and alignment */
.table-condensed th,
.table-condensed td {
padding: 6px 8px;
vertical-align: middle;
}

.table-condensed td:last-child {
text-align: center;
}

/* High contrast labels for status indicators */
.label {
display: inline-block;
padding: 0.25em 0.6em;
font-size: 90%;
font-weight: 700;
line-height: 1;
color: #000 !important;
background-color: #fff !important;
border: 2px solid #000;
border-radius: 0.25em;
}

/* Status-specific colors */
.label-available {
background-color: #ffffff !important;
color: #000000 !important;
border-color: #999999;
}

.label-added {
background-color: #ffff00 !important; /* Bright yellow */
color: #000000 !important;
border-color: #999900;
}

.label-removed {
background-color: #ff0000 !important; /* Red */
color: #ffffff !important;
border-color: #cc0000;
}

.label-current {
background-color: #0000ff !important; /* Blue */
color: #ffffff !important;
border-color: #000099;
}

.label-pending {
background-color: #ffa500 !important; /* Orange */
color: #000000 !important;
border-color: #cc8400;
}

/* App-filter checkboxes */
.checkbox label {
display: block;
font-weight: normal;
}

/* Reset button spacing */
button.btn-warning {
margin-bottom: 1em;
}

/* High-contrast button overrides */
.btn {
border-width: 2px;
font-weight: bold;
}

.btn-success {
background-color: #00ff00 !important;
color: #000 !important;
border-color: #009900;
}

.btn-danger {
background-color: #ff0000 !important;
color: #fff !important;
border-color: #cc0000;
}

.btn-warning {
background-color: #ffcc00 !important;
color: #000 !important;
border-color: #cc9900;
}

.btn-primary {
background-color: #0000ff !important;
color: #fff !important;
border-color: #000099;
}

 

 

SERVER SCRIPT:

// Server script for Sailpoint Role Widget
(function() {
    var LOG_PREFIX = "[SailPoint Role Manager Widget]";

    // 1) Read & parse the sailpoint.log_levels property into an array
    var allLevels = gs.getProperty("sailpoint.log_levels", "")
        .split(",")
        .map(function(v) {
            return v.trim().toLowerCase();
        })
        .filter(function(v) {
            return v;
        });
    data.logLevels = allLevels;
    gs.info(LOG_PREFIX + " logLevels: " + JSON.stringify(allLevels));

    // 2) Debug helper
    function logDebug(category, message, payload) {
        if (allLevels.indexOf(category) > -1) {
            gs.info(
                LOG_PREFIX +
                " [" + category + "] " + message +
                (payload === undefined ? "" : " → " + JSON.stringify(payload))
            );
        }
    }

    // 3) Data-fetching functions

    function getAgencies() {
        var ag = [];
        var gr = new GlideRecord("core_company");
        gr.addQuery("u_agency", true);
        gr.addQuery("u_active", true);
        gr.orderBy("name");
        gr.query();
        while (gr.next()) {
            ag.push({
                sys_id: gr.getUniqueValue(),
                name: gr.getValue("name")
            });
        }
        logDebug("agency_lookup", "Found agencies", ag);
        return ag;
    }

    function unifiedSearch(searchValue, agencyAcronym) {
        var wild = "*" + searchValue + "*";
        var parts = [
            '(attributes.email:' + wild + ' OR attributes.lastname:' + wild + ')',
            'attributes.status:"Active"'
        ];
        if (agencyAcronym)
            parts.push('attributes.company:"' + agencyAcronym + '"');

        var rm = new sn_ws.RESTMessageV2("Sailpoint - User Lookup by variable", "searchByVariable");
        rm.setRequestBody(JSON.stringify({
            indices: ["identities"],
            queryType: "SAILPOINT",
            query: {
                query: parts.join(" AND ")
            },
            queryResultFilter: {
                includes: [
                    "id", "name", "email", "displayName",
                    "attributes.company", "attributes.displayName",
                    "attributes.lastname", "attributes.cloudLifecycleState"
                ]
            }
        }));

        var resp = rm.execute();
        try {
            var parsed = JSON.parse(resp.getBody());
            var results = Array.isArray(parsed) ? parsed : (parsed.hits || []);
            logDebug("user_lookup", "Search results", results);
            return results;
        } catch (e) {
            logDebug("user_lookup", "Search parse failed", e.message);
            return [];
        }
    }

    function getRolesByIdentity(identityId) {
        var rm = new sn_ws.RESTMessageV2("Sailpoint - User Lookup by variable", "getRolesByIdentity");
        rm.setStringParameterNoEscape("identity_id", identityId);
        var resp = rm.execute();
        try {
            var body = JSON.parse(resp.getBody()) || [];
            logDebug("role_assignment", "Roles by identity", body);
            return body;
        } catch (e) {
            logDebug("role_assignment", "Roles parse failed", e.message);
            return [];
        }
    }

    function fetchRequestableRoles() {
        var rm = new sn_ws.RESTMessageV2("Sailpoint - User Lookup by variable", "fetchRequestableRoles");
        rm.setRequestBody(JSON.stringify({
            ammKeyValues: [{
                attribute: "roleRequestableInSnow",
                values: ["true"]
            }]
        }));

        var resp = rm.execute();
        try {
            var roles = JSON.parse(resp.getBody()) || [];
            roles.forEach(function(r) {
                var appAttr = (r.accessModelMetadata && r.accessModelMetadata.attributes || [])
                    .find(function(a) {
                        return a.key === "roleApplicationName";
                    });
                r.appName = (appAttr && appAttr.values && appAttr.values[0] && appAttr.values[0].value) ?
                    appAttr.values[0].value : "";
            });
            logDebug("agency_roles", "Global requestable roles (with appName)", roles);
            return roles;
        } catch (e) {
            logDebug("agency_roles", "Global roles parse failed", e.message);
            return [];
        }
    }

    function getAgencyAcronym(sysId) {
        var gr = new GlideRecord("u_ad_comp_to_agency");
        gr.addQuery("u_agency", sysId);
        gr.setLimit(1);
        gr.query();
        if (gr.next()) {
            var acr = gr.getValue("u_ad_comp");
            logDebug("agency_lookup", "Agency acronym", acr);
            return acr;
        }
        logDebug("agency_lookup", "No mapping for agency", sysId);
        return "";
    }

    function getCurrentAndAvailableRoles(identityId) {
        var userAssignments = getRolesByIdentity(identityId) || [];
        logDebug("role_assignment", "Raw user assignments", userAssignments);

        var globalReq = fetchRequestableRoles() || [];
        logDebug("agency_roles", "Fetched global requestable roles", globalReq);

        var userIds = userAssignments.map(function(r) {
            return (r.role || {}).id;
        });

        var current = userAssignments.filter(function(r) {
            return globalReq.some(function(g) {
                return g.id === r.role.id;
            });
        }).map(function(r) {
            return {
                id: r.role.id,
                name: r.role.name,
                appName: (globalReq.find(function(g) {
                    return g.id === r.role.id;
                }) || {}).appName || ""
            };
        });
        logDebug("role_assignment", "Computed currentRoles", current);

        var available = globalReq.filter(function(g) {
            return userIds.indexOf(g.id) === -1;
        }).map(function(g) {
            return {
                id: g.id,
                name: g.name,
                appName: g.appName
            };
        });
        logDebug("agency_roles", "Computed availableRoles", available);

        return {
            currentRoles: current,
            availableRoles: available
        };
    }

    // 4) Dispatch actions
    if (input && input.action === 'submitToSailPoint') {
        var userId = input.userId;
        var grantRoles = input.grantRoles || [];
        var revokeRoles = input.revokeRoles || [];

        data.results = {
            granted: [],
            revoked: [],
            errors: []
        };

        try {
            function sendAccessRequest(roleId, actionType) {
                var payload = {
                    requestedFor: [userId],
                    requestedItems: [{
                        id: roleId,
                        type: "ROLE",
                        comment: "Requested via ServiceNow"
                    }],
                    requestType: actionType
                };

                logDebug("submission", "Sending access request", payload);

                var msg = new sn_ws.RESTMessageV2('SailPoint - Integration API', 'submitRoleChanges');
                msg.setRequestBody(JSON.stringify(payload));

                var response = msg.execute();
                var httpStatus = response.getStatusCode();
                var body = response.getBody();

                logDebug("submission", "Response received", {
                    status: httpStatus,
                    body: body
                });

                if (httpStatus !== 202) {
                    throw 'Failed to submit role [' + roleId + ']: HTTP ' + httpStatus + ' - ' + body;
                }

                return JSON.parse(body);
            }

            grantRoles.forEach(function(roleId) {
                try {
                    var result = sendAccessRequest(roleId, 'GRANT_ACCESS');
                    data.results.granted.push({
                        roleId: roleId,
                        result: result
                    });
                } catch (ex) {
                    data.results.errors.push({
                        roleId: roleId,
                        action: 'grant',
                        error: '' + ex
                    });
                }
            });

            revokeRoles.forEach(function(roleId) {
                try {
                    var result = sendAccessRequest(roleId, 'REVOKE_ACCESS');
                    data.results.revoked.push({
                        roleId: roleId,
                        result: result
                    });
                } catch (ex) {
                    data.results.errors.push({
                        roleId: roleId,
                        action: 'revoke',
                        error: '' + ex
                    });
                }
            });

        } catch (err) {
            data.results.errors.push({
                general: '' + err
            });
        }
    } else if (input && input.action) {
        switch (input.action) {
            case "get_agencies":
                data.agencies = getAgencies();
                break;

            case "get_agency_acronym":
                data.acronym = getAgencyAcronym(input.agency_sys_id);
                break;

            case "search_user":
                data.results = unifiedSearch(input.search_value, input.agency_acronym);
                break;

            case "get_roles_by_identity":
                data.roles = getRolesByIdentity(input.identity_id);
                break;

            case "get_current_and_available":
                var both = getCurrentAndAvailableRoles(input.identity_id);
                data.currentRoles = both.currentRoles;
                data.availableRoles = both.availableRoles;
                break;
        }
    }
})();

 

CLIENT SCRIPT:

api.controller = function($scope, $timeout) {
    var c = this;
    var LOG_PREFIX = "[SailPoint Role Manager Widget]";

    // 1) Read log levels
    c.logLevels = $scope.data.logLevels || [];

    // 2) Debug helper (logs to console and optionally to syslog)
    function logDebug(cat, msg, payload) {
        if (c.logLevels.indexOf(cat) > -1) {
            var full = LOG_PREFIX + " [" + cat + "] " + msg;
            if (payload !== undefined) full += " → " + JSON.stringify(payload);
            console.log(full);
            // optional server‐side logging stub
            c.server.get({
                action: "log_to_syslog",
                category: cat,
                message: msg,
                payload: JSON.stringify(payload || {})
            });
        }
    }

    // 3) Widget state
    c.data = {
        agencies: [],
        selectedAgency: "",
        agencyAcronym: "",
        searchValue: "",
        userSearchResults: [],
        selectedUser: "",
        currentRoles: [],
        availableRoles: [],
        initialCurrentRoles: [],
        confirmationMessage: "",
        agencyAcronymReady: false
    };
    c.loading = false;
    c.appNames = [];
    c.selectedApps = {};
    c.appNameFilter = [];
    c.toAdd = [];
    c.toRemove = [];

    // 4) Role status & CSS label helpers
    c.isAdded = r => c.toAdd.some(x => x.id === r.id);
    c.isRemoved = r => c.toRemove.some(x => x.id === r.id);
    c.isCurrent = r => c.data.initialCurrentRoles.some(x => x.id === r.id);

    c.getRoleStatus = function(r) {
        if (c.isAdded(r)) return "Pending Add";
        if (c.isRemoved(r)) return "Pending Remove";
        if (c.isCurrent(r)) return "Current";
        return "Available";
    };
    c.getRoleLabelClass = function(r) {
        if (c.isAdded(r)) return "label label-success";
        if (c.isRemoved(r)) return "label label-danger";
        if (c.isCurrent(r)) return "label label-default";
        return "label label-info";
    };

    // 5) Reset all pending changes
    c.resetAllChanges = function() {
        c.toAdd = [];
        c.toRemove = [];
        logDebug("submission", "All pending adds/removes cleared");
    };

    // 6) Build & refresh application‐name filter list
    function refreshAppNames() {
        var names = c.data.availableRoles.concat(c.data.currentRoles)
            .map(r => r.appName)
            .filter(n => n);
        c.appNames = Array.from(new Set(names)).sort();
        c.selectedApps = {};
        c.appNames.forEach(a => c.selectedApps[a] = false);
        c.appNameFilter = [];
    }
    c.updateAppFilter = function() {
        c.appNameFilter = Object.keys(c.selectedApps)
            .filter(a => c.selectedApps[a]);
    };
    c.appNameFilterFn = function(item) {
        return !c.appNameFilter.length || c.appNameFilter.includes(item.appName);
    };

    // 7) Server‐call wrapper
    c.callServer = function(params, cb) {
        c.loading = true;
        c.server.get(params).then(function(resp) {
            c.loading = false;
            logDebug("api_requests", "Server call", params);
            if (cb) cb(resp);
        }, function(err) {
            c.loading = false;
            console.error(LOG_PREFIX, "Server call failed", params, err);
        });
    };

    // 😎 Initialization
    c.$onInit = function() {
        logDebug("agency_lookup", "Widget initialized");
        c.getAgencies();
    };

    // 9) Load agencies
    c.getAgencies = function() {
        c.callServer({
            action: "get_agencies"
        }, function(r) {
            c.data.agencies = r.data.agencies || [];
            logDebug("agency_lookup", "Loaded agencies", c.data.agencies);
        });
    };

    // 10) Handle agency selection → fetch acronym + autofocus
    c.onAgencyChange = function() {
        c.data.agencyAcronym = "";
        c.data.agencyAcronymReady = false;
        if (!c.data.selectedAgency) return;
        c.callServer({
            action: "get_agency_acronym",
            agency_sys_id: c.data.selectedAgency
        }, function(r) {
            c.data.agencyAcronym = r.data.acronym || "";
            c.data.agencyAcronymReady = !!c.data.agencyAcronym;
            logDebug("agency_lookup", "Agency acronym", c.data.agencyAcronym);
            if (c.data.agencyAcronymReady) {
                $timeout(function() {
                    var el = document.getElementById("searchValue");
                    if (el) el.focus();
                });
            }
        });
    };

    // 11) Search user → populate dropdown + autofocus
    c.searchUser = function() {
        if (!c.data.searchValue || !c.data.agencyAcronymReady) {
            return alert("Please select an agency and wait before searching.");
        }
        c.callServer({
            action: "search_user",
            search_value: c.data.searchValue,
            agency_acronym: c.data.agencyAcronym
        }, function(r) {
            var raw = r.data.results || [];
            c.data.userSearchResults = raw.map(function(u) {
                return {
                    id: u.id,
                    display: (u.attributes?.displayName || u.name) +
                        " (" + (u.email || "") + ")"
                };
            }).sort(function(a, b) {
                return a.display.localeCompare(b.display);
            });
            logDebug("user_lookup", "User search results", c.data.userSearchResults);
            $timeout(function() {
                var s = document.getElementById("userSelect");
                if (s) s.focus();
            });
        });
    };

    // 12) Load current & available roles for selected user
    //     → then clear any pending add/remove flags
    c.getRolesForSelectedUser = function(id) {
        if (!id) return;
        c.callServer({
            action: "get_current_and_available",
            identity_id: id,
            agency_acronym: c.data.agencyAcronym
        }, function(r) {
            c.data.currentRoles = r.data.currentRoles || [];
            c.data.availableRoles = r.data.availableRoles || [];
            c.data.initialCurrentRoles = angular.copy(c.data.currentRoles);
            logDebug("role_assignment", "Roles loaded", {
                current: c.data.currentRoles,
                available: c.data.availableRoles
            });
            // clear out old pending flags
            c.resetAllChanges();
            refreshAppNames();
        });
    };

    // 13) Move role to "current" (grant)
    c.moveToCurrent = function(r) {
        var idx = c.data.availableRoles.findIndex(x => x.id === r.id);
        if (idx > -1) c.data.availableRoles.splice(idx, 1);
        c.data.currentRoles.push(r);
        var remIdx = c.toRemove.findIndex(x => x.id === r.id);
        if (remIdx > -1) c.toRemove.splice(remIdx, 1);
        else c.toAdd.push(r);
    };

    // 14) Move role to "available" (revoke)
    c.moveToAvailable = function(r) {
        var idx = c.data.currentRoles.findIndex(x => x.id === r.id);
        if (idx > -1) c.data.currentRoles.splice(idx, 1);
        c.data.availableRoles.push(r);
        var addIdx = c.toAdd.findIndex(x => x.id === r.id);
        if (addIdx > -1) c.toAdd.splice(addIdx, 1);
        else c.toRemove.push(r);
    };

    // 15) Stub for Submit Changes (will be picked up by "Order Now")
    c.submitChanges = function() {
        logDebug("submission",
            "To Add:", c.toAdd.map(x => x.id),
            "To Remove:", c.toRemove.map(x => x.id)
        );
    };

    // 16) Service Portal catalog‐item submit hook
    $scope.$on("spModel.uiAction.submitted", function(evt, payload) {
        logDebug("submission", "Catalog item submitted", payload);
        // you can intercept here and fire off your SailPoint API call
    });

    // 17) Watch for user selection changes
    $scope.$watch("c.data.selectedUser", function(nv, ov) {
        if (nv && nv !== ov) c.getRolesForSelectedUser(nv);
    });
};

 

 

1 REPLY 1

Adam43
Tera Contributor

FYI- this widget is contained in a custom catalog variable for the catalog_item.  I've added a second variable (multi-line text) to use for pushing the summary of action to, so it's recorded in the RITM.  that's the "submission_summary" variable.  That's not working yet either, but might be due to being hung up on capturing the data needed to populate.