Automating Role and Access Profile Mapping in SailPoint ISC with Java

Problem

Managing roles in SailPoint Identity Security Cloud (ISC) often means adding new access profiles as business needs change. Doing this manually for dozens—or even hundreds—of roles can take considerable time and lead to missed mappings or duplicate work.

Diagnosis

The challenge comes from updating role mappings manually as access requirements grow. Administrators need to find each role, check its existing access profiles, and add the new ones without disturbing what is already assigned. Repeating these steps across many roles makes it easy to miss a mapping or repeat work.

A CSV-driven Java utility helps by checking the current assignments and adding only the missing access profiles. It also identifies missing or ambiguous names before updating a role.

Solution

The goal is simple: use a CSV file to tell a Java utility which access profiles belong to which roles. Each row contains a role name and the access profiles to add, separated by # when there is more than one.

For example, Role1,AP1#AP2 means “add AP1 and AP2 to Role1.”

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;

import java.io.*;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.*;
import java.util.*;
import java.util.stream.Collectors;

/** Adds existing access profiles to existing ISC roles. Never removes assignments. */
public class RoleAccessProfileMapper {
    static final ObjectMapper JSON = new ObjectMapper();
    static final class Input {
        private final String role, profiles;
        Input(String role, String profiles) { this.role = role; this.profiles = profiles; }
        String role() { return role; }
        String profiles() { return profiles; }
    }
    static final class Result {
        private final String status, added, message;
        Result(String status, String added, String message) {
            this.status = status; this.added = added; this.message = message;
        }
        String status() { return status; }
        String added() { return added; }
        String message() { return message; }
    }

    static class Api {
        private final String baseUrl, clientId, secret;
        private final HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(30))
                .followRedirects(HttpClient.Redirect.NEVER).build();
        private String token;
        private Instant expires = Instant.MIN;

        Api(String baseUrl, String clientId, String secret) {
            URI uri = URI.create(baseUrl);
            if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null
                    || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null
                    || !(uri.getPath().isEmpty() || uri.getPath().equals("/"))) {
                throw new IllegalArgumentException("Base URL must be an HTTPS tenant API origin.");
            }
            this.baseUrl = baseUrl.replaceAll("/$", "");
            this.clientId = clientId;
            this.secret = secret;
        }

        private JsonNode send(HttpRequest request) throws Exception {
            HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            if (response.statusCode() < 200 || response.statusCode() >= 300) {
                // Do not print response bodies that might contain sensitive data.
                throw new IOException("HTTP " + response.statusCode() + " for " + request.method()
                        + " " + request.uri().getPath() + response.headers().firstValue("Retry-After")
                        .map(v -> "; Retry-After: " + v).orElse(""));
            }
            return response.body().isBlank() ? JSON.nullNode() : JSON.readTree(response.body());
        }

        JsonNode call(String method, String path, JsonNode body) throws Exception {
            if (!Instant.now().isBefore(expires)) {
                String form = "grant_type=client_credentials&client_id=" + encode(clientId)
                        + "&client_secret=" + encode(secret);
                JsonNode auth = send(HttpRequest.newBuilder(URI.create(baseUrl + "/oauth/token"))
                        .timeout(Duration.ofSeconds(60))
                        .header("Content-Type", "application/x-www-form-urlencoded")
                        .POST(HttpRequest.BodyPublishers.ofString(form)).build());
                token = auth.path("access_token").asText();
                if (token.isBlank()) throw new IOException("Token response has no access_token.");
                expires = Instant.now().plusSeconds(Math.max(1, auth.path("expires_in").asLong(300) - 60));
            }
            HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(baseUrl + path))
                    .timeout(Duration.ofSeconds(60)).header("Authorization", "Bearer " + token)
                    .header("Accept", "application/json");
            if (body == null) request.method(method, HttpRequest.BodyPublishers.noBody());
            else request.header("Content-Type", "application/json-patch+json; charset=utf-8")
                    .method(method, HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body), StandardCharsets.UTF_8));
            // Do not automatically retry PATCH: a network failure may hide a successful update.
            return send(request.build());
        }

        List<JsonNode> list(String path) throws Exception {
            List<JsonNode> all = new ArrayList<>();
            for (int offset = 0; ; ) {
                JsonNode page = call("GET", path + "?limit=250&offset=" + offset, null);
                if (!page.isArray()) throw new IOException("Expected an array from " + path);
                page.forEach(all::add);
                if (page.size() < 250) return all;
                offset += page.size();
            }
        }
    }

    static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); }

    // CSV handling uses only the JDK: quoted fields, escaped quotes, CRLF and BOM.
    static List<List<String>> parseCsv(String text) throws IOException {
        if (text.startsWith("\uFEFF")) text = text.substring(1);
        List<List<String>> records = new ArrayList<>();
        List<String> row = new ArrayList<>();
        StringBuilder field = new StringBuilder();
        boolean quoted = false, closedQuote = false, started = false;
        for (int i = 0; i < text.length(); i++) {
            char c = text.charAt(i);
            if (quoted) {
                if (c == '"') {
                    if (i + 1 < text.length() && text.charAt(i + 1) == '"') {
                        field.append('"'); i++;
                    } else { quoted = false; closedQuote = true; }
                } else field.append(c);
                continue;
            }
            if (c == ',' || c == '\n' || c == '\r') {
                if (c == ',') {
                    row.add(field.toString()); field.setLength(0);
                    closedQuote = false; started = true;
                } else {
                    if (started || closedQuote || field.length() > 0 || !row.isEmpty()) {
                        row.add(field.toString()); records.add(row); row = new ArrayList<>();
                    }
                    field.setLength(0); closedQuote = false; started = false;
                    if (c == '\r' && i + 1 < text.length() && text.charAt(i + 1) == '\n') i++;
                }
            } else {
                if (closedQuote) throw new IOException("Unexpected character after a closing CSV quote.");
                if (c == '"') {
                    if (field.length() != 0) throw new IOException("Unexpected quote in unquoted CSV field.");
                    quoted = true;
                } else field.append(c);
                started = true;
            }
        }
        if (quoted) throw new IOException("Unclosed quoted CSV field.");
        if (started || closedQuote || field.length() > 0 || !row.isEmpty()) {
            row.add(field.toString()); records.add(row);
        }
        return records;
    }

    static List<Input> readCsv(Path file) throws IOException {
        List<List<String>> records = parseCsv(Files.readString(file, StandardCharsets.UTF_8));
        if (records.isEmpty()) throw new IOException("CSV is empty.");
        List<String> headers = records.get(0);
        if (!headers.containsAll(List.of("RoleName", "accessProfile"))
                || new HashSet<>(headers).size() != headers.size()) {
            throw new IOException("CSV must contain unique RoleName and accessProfile headers.");
        }
        List<Input> rows = new ArrayList<>();
        for (int i = 1; i < records.size(); i++) {
            List<String> record = records.get(i);
            if (record.size() != headers.size()) throw new IOException("Invalid column count at CSV record " + (i + 1));
            rows.add(new Input(record.get(headers.indexOf("RoleName")).trim(), record.get(headers.indexOf("accessProfile"))));
        }
        if (rows.isEmpty()) throw new IOException("CSV has no data rows.");
        return rows;
    }

    static void printCsv(Writer writer, Object... values) throws IOException {
        for (int i = 0; i < values.length; i++) {
            if (i > 0) writer.write(',');
            writer.write('"');
            writer.write(String.valueOf(values[i]).replace("\"", "\"\""));
            writer.write('"');
        }
        writer.write("\r\n");
        writer.flush();
    }
    static JsonNode findUnique(List<JsonNode> objects, String name, String kind) {
        List<JsonNode> matches = objects.stream().filter(n -> name.equals(n.path("name").asText())).collect(Collectors.toList());
        if (matches.size() != 1) throw new IllegalArgumentException("Expected one " + kind + " named '" + name + "'; found " + matches.size());
        id(matches.get(0));
        return matches.get(0);
    }

    static String id(JsonNode object) {
        String id = object.path("id").asText();
        if (id.isBlank()) throw new IllegalArgumentException("API object has no ID.");
        return id;
    }

    static Set<String> profileIds(JsonNode array) {
        if (array == null || (!array.isArray() && !array.isNull()))
            throw new IllegalArgumentException("Role response has missing or invalid accessProfiles.");
        Set<String> ids = new HashSet<>();
        if (array.isArray()) array.forEach(n -> ids.add(id(n)));
        return ids;
    }

    static void op(ArrayNode patch, String operation, String path, JsonNode value) {
        ObjectNode node = patch.addObject();
        node.put("op", operation).put("path", path).set("value", value);
    }

    static Result process(Api api, String rolePath, List<JsonNode> roles,
                          List<JsonNode> profiles, Input row, boolean dryRun) throws Exception {
        if (row.role().isBlank()) throw new IllegalArgumentException("RoleName is empty.");
        JsonNode roleRef = findUnique(roles, row.role(), "role");
        Map<String, JsonNode> requested = new LinkedHashMap<>();
        // Resolve the entire row before writing anything to this role.
        for (String part : row.profiles().split("#", -1)) {
            String name = part.trim();
            if (name.isEmpty()) throw new IllegalArgumentException("An access profile name is empty.");
            JsonNode profile = findUnique(profiles, name, "access profile");
            requested.put(id(profile), profile);
        }
        String path = rolePath + "/" + encode(id(roleRef));
        JsonNode role = api.call("GET", path, null);
        JsonNode existing = role.get("accessProfiles");
        Set<String> existingIds = profileIds(existing);
        List<JsonNode> missing = requested.values().stream().filter(p -> !existingIds.contains(id(p))).collect(Collectors.toList());
        if (missing.isEmpty()) return new Result("Unchanged", "", "All requested access profiles are already assigned.");
        String names = missing.stream().map(p -> p.path("name").asText()).collect(Collectors.joining("#"));
        if (dryRun) return new Result("NotApplied", "", "Would add: " + names);

        ArrayNode patch = JSON.createArrayNode();
        // Reject stale state. Fail closed if the server does not support JSON Patch test.
        op(patch, "test", "/accessProfiles", existing.deepCopy());
        if (existing.isNull()) op(patch, "add", "/accessProfiles", JSON.createArrayNode());
        for (JsonNode profile : missing) {
            ObjectNode reference = JSON.createObjectNode().put("id", id(profile))
                    .put("type", "ACCESS_PROFILE").put("name", profile.path("name").asText());
            op(patch, "add", "/accessProfiles/-", reference);
        }
        api.call("PATCH", path, patch);
        Set<String> verified = profileIds(api.call("GET", path, null).get("accessProfiles"));
        Set<String> required = new HashSet<>(existingIds);
        required.addAll(requested.keySet());
        if (!verified.containsAll(required)) throw new IOException("Post-update verification failed; inspect the role before rerunning.");
        return new Result("Updated", names, "Missing access profiles added; existing assignments verified.");
    }

    public static void main(String[] args) {
        try { System.exit(run(args)); }
        catch (Exception e) { System.err.println("Error: " + e.getMessage()); System.exit(1); }
    }

    static int run(String[] args) throws Exception {
        if (args.length < 3) {
            System.err.println("Usage: java -cp <library-path> RoleAccessProfileMapper.java <base-url> <csv-path> <client-id> [--dry-run] [--api-version=current|v3|v2025]");
            return 1;
        }
        boolean dryRun = false;
        String version = "current";
        for (int i = 3; i < args.length; i++) {
            if (args[i].equals("--dry-run")) dryRun = true;
            else if (args[i].startsWith("--api-version=")) version = args[i].substring("--api-version=".length());
            else throw new IllegalArgumentException("Unknown option: " + args[i]);
        }
        if (!Set.of("current", "v3", "v2025").contains(version)) throw new IllegalArgumentException("Invalid API version.");
        List<Input> rows = readCsv(Path.of(args[1]));
        String secret = System.getenv("ISC_CLIENT_SECRET");
        if (secret == null || secret.isBlank()) {
            Console console = System.console();
            if (console == null) throw new IllegalArgumentException("Set ISC_CLIENT_SECRET or run in a terminal with a password prompt.");
            char[] password = console.readPassword("ISC client secret: ");
            if (password == null || password.length == 0) throw new IllegalArgumentException("Client secret is required.");
            secret = new String(password);
            Arrays.fill(password, '\0');
        }
        Api api = new Api(args[0], args[2], secret);
        String rolePath = version.equals("current") ? "/roles/v1" : "/" + version + "/roles";
        String profilePath = version.equals("current") ? "/access-profiles/v1" : "/" + version + "/access-profiles";
        List<JsonNode> roles = api.list(rolePath);
        List<JsonNode> profiles = api.list(profilePath);
        boolean failed = false;
        Writer report = new OutputStreamWriter(System.out, StandardCharsets.UTF_8);
        printCsv(report, "Record", "RoleName", "Status", "Added", "Message");
        int record = 0;
        for (Input row : rows) {
            Result result;
            try { result = process(api, rolePath, roles, profiles, row, dryRun); }
            catch (InterruptedException e) { Thread.currentThread().interrupt(); throw e; }
            catch (Exception e) {
                failed = true;
                result = new Result("Error", "", e.getMessage()
                        + " No automatic retry attempted; inspect the role if an update was sent.");
            }
            printCsv(report, ++record, row.role(), result.status(), result.added(), result.message());
            report.flush();
        }
        return failed ? 2 : 0;
    }
}


4 Likes