Tackling Nested AD Group Certifications in SailPoint ISC Using Role Composition

The Problem: Nested Groups Are a Certification Blind Spot

Reviewing nested Active Directory groups and their memberships is one of the most common and challenging problems organizations face during access certifications. Despite the industry’s steady move toward cloud-based identity platforms, many enterprises still rely heavily on deeply nested Active Directory group structures that have evolved over years, often without a clear ownership model.

When a user is a member of IT-AllAdmins, and IT-AllAdmins contains SQL-Admins, Server-Admins, and Network-Admins as child groups, a standard certification shows the reviewer only the parent group. The reviewer certifies IT-AllAdmins without ever seeing—or understanding—the three privileged child groups underneath it. The effective access being approved is far broader than what’s on the screen.

In SailPoint Identity Security Cloud (ISC), certifications do not support nested group expansion. Child groups do not appear directly in campaigns—a limitation that also existed historically in IdentityIQ. This behavior is intentional, as nested memberships can introduce complexity, performance overhead, and ambiguity into the review process.

There’s a second, quieter problem: in most organizations, the set of groups that must be reviewed each cycle isn’t “every group in the tenant.” It’s a curated list—privileged groups, regulated-application groups, groups flagged by audit. Tenant-wide crawls waste hours analyzing groups nobody needs to certify, and they make campaign scope a moving target every quarter.

For one of our customers, this limitation became a critical challenge. They were in the middle of migrating access reviews from a legacy IGA platform—one that did support nested group reviews—to SailPoint ISC. At the same time, they were not in a position to immediately unnest or redesign their Active Directory group model, as their focus was already stretched across the broader migration effort. Naturally, they expected comparable certification coverage in ISC and began looking for viable alternatives.

This blog post walks through how I addressed that gap with a PowerShell script using role composition certifications to surface nested group access for review. It covers the approach we took to represent child group memberships, the challenges encountered along the way, and the additional considerations introduced for both SailPoint administrators and reviewers. The goal here is not to claim that this is the best substitute for native nested group support, but to share experience and get feedback.

At a high level, the script:

  • Takes an input file listing the groups in scope for the current campaign cycle — nested or not, the script figures that out.
  • Queries Active Directory directly to discover each group’s child groups — no dependency on entitlement hierarchy aggregation in ISC.
  • Resolves every parent and child to its ISC entitlement, auto-discovering all Active Directory sources in the tenant along the way.
  • Creates (or patches) a role per nested parent group, containing only its child entitlements — flagging any group whose owner can’t be determined.
  • Builds the campaign payloads as reviewable JSON files — role composition certifications for the nested groups (the structure review) and identity certifications for the groups in the input list (the access review) and optionally POSTs them to ISC.

One input list, two certification lenses: Who has this access? and What does this access actually contain?

This post walks through the full script, section by section. By the end, you’ll be able to run it against your own tenant or adapt the patterns for other entitlement-hierarchy use cases.

Why roles for the nested groups? ISC’s role composition certification is the cleanest native construct for asking an owner “should these things be grouped together?” By materializing each nested parent group as a role whose membership is exactly its child entitlements, the composition review becomes a direct review of the nesting itself. Flat groups do not need this treatment — for them, the identity certification already tells the whole story.

Prerequisites

Before running the script, you’ll need:

  • PowerShell 5.1+ (works on Windows PowerShell and PowerShell 7)
  • An ISC OAuth client (Personal Access Token or client credentials) with API scopes covering sources, entitlements, roles, and campaigns — sp:scopes:all for simplicity in a sandbox, or scoped-down for production
  • Entitlement hierarchy enabled on your Active Directory sources if you intend to validate nested relationships through ISC APIs. The script itself discovers child groups directly from Active Directory.
  • A recent entitlement aggregation completed on each Active Directory source
  • An input CSV of the groups in scope for this cycle (format below)

How It Works: Five Phases

The script runs as a sequence of five phases, each controlled by an execution flag and each producing its own output files. This design lets you run discovery and role creation one day, then come back the next day (more on why below) and flip on the campaign phases — re-running only what you need.

Phase What it does Output files
1 — AD Discovery Reads the input CSV; queries Active Directory for each parent group’s child groups ADDiscovery_<OutputTag>.csv, ADNotFound_<OutputTag>.csv, ADNoChildren_<OutputTag>.csv
2 — ISC Resolution Looks up each parent and child group in ISC across all auto-discovered AD sources (by name or DN) NestedGroups_<OutputTag>_<Source>.csv, ParentNotInISC_<OutputTag>.csv, ChildNotInISC_<OutputTag>.csv
3 — Role Creation Creates or PATCHes one role per nested parent group; no-op if already correct; reports groups with no resolvable owner NestedGroupRoles_<OutputTag>_<Source>.csv, MissingOwners_<OutputTag>.csv
4 — Role Composition Campaigns Builds ROLE_COMPOSITION campaign payloads; optionally POSTs them to ISC CampaignPayloads_<OutputTag>_<timestamp>.json
5 — Identity Certifications Builds search-type identity certification campaign payloads per group, with the entitlement owner as certifier; optionally POSTs them

The Input File: Scope as Data, Not Code

The single most useful design decision in this script: campaign scope lives in a CSV, not in the script. Each quarter, the security team updates the list; the script doesn’t change.

Sample Input File

App Key,AD Group Name
SAPP01RO,NYC-Accounts-Payable-RO
SAPP01RW,NYC-Accounts-Payable-RW
SA3X9TQ2LM,ERP_TEAM-ADMINISTRATORS
SB7Y4WN8RD,APP-I-BI ADMINISTRATORS
SC1K5VM3JP,SRVAPPCTX001_ADMINISTRATORS
SD8Q2HX6FT,LON-AV-HRAPP1P_ADMINISTRATORS
SE4N7BL9GW,LON-AV-HRAPP2U_ADMINISTRATORS
SF6T3RC5KY,CHI-TV-FINMGR1_ADMINISTRATORS
SG9M1DZ4QX,DAL-AV-PAYSVC1P_ADMINISTRATORS
SH2P8JF7VN,SEA-RV-RPTSRV1_ADMINISTRATORS
SGCRMMGT,"CN=AGGS-VDAV-DA-CRAMMGT,OU=Provisioning,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL"
  1. AD Group Name accepts either the friendly display name or the full Distinguished Name. Real-world input lists are messy — some teams hand you SQL-Admins, others hand you CN=SQL-Admins,OU=Groups,DC=corp,DC=acme,DC=com. The script handles both.
  2. App Key is the application identifier from the customer’s catalog; it passes through to the output files and campaign descriptions untouched, which keeps the certification evidence traceable back to the application inventory.

This also defines the script’s contract honestly: it certifies what you asked for. Groups that can’t be resolved to an ISC entitlement land in an _Errors CSV with a reason (NOT_FOUND, AMBIGUOUS_MATCH), and the run continues with the resolved set. Nothing silently disappears from scope.

The Intermediate Files: Every Phase Leaves a Paper Trail

Before we get into the code, it’s worth dwelling on the output files, because they turned out to be as valuable as the campaigns themselves. Every phase writes its findings to CSV (or JSON) before the next phase consumes them — which means every run is fully inspectable, resumable, and auditable. All files share an <OutputTag> you set per cycle (e.g., Q3-2026), so consecutive cycles never overwrite each other.

Phase 1 outputs — what AD actually says

ADDiscovery_<OutputTag>.csv is the master discovery file: one row per parent/child pair found in AD.

text

"ParentGroupName","ParentGroupCN","ChildGroupName","ChildGroupCN"
"DEMOBNSDW01_Administrators","DEMOBNSDW01_Administrators","FTSDEMO_Admins","FTSDEMO_Admins"
"DEMOBNSDW01_Administrators","DEMOBNSDW01_Administrators","CINCOMDEMO_APP_SUPPORT","CINCOMDEMO_APP_SUPPORT"
"DEMO-CV-APWRXP1_Administrators","DEMO-CV-APWRXP1_Administrators","Contractor_Support_Admin","Contractor_Support_Admin"
"DEMO-AS-CAX1_Administrators","DEMO-AS-CAX1_Administrators","a-demoeitadmins","a-demoeitadmins"

ADNotFound_<OutputTag>.csv captures input rows that don’t exist in AD at all — typos, decommissioned groups, or groups that live in a different forest:

text

"GroupName","Reason"
"ux_aws_finapp_adm_dev","Group not found in AD"
"ux_aws_finapp_adm_prd","Group not found in AD"
"ux_legacy_agent01","Group not found in AD"
"CN=APP-SVC-DA-MGMT01,OU=Provisioning,OU=Groups,DC=corp,DC=acme,DC=com","Group not found in AD"

In practice this file is gold for the security team: it’s an instant data-quality report on their own scope list. In our first cycle, a meaningful chunk of the input turned out to be groups that had been deleted years earlier but never removed from the review scope.

ADNoChildren_<OutputTag>.csv lists the groups that exist but are flat — no nested children. These groups skip role creation entirely and flow only into the Phase 5 identity certifications. The split between this file and ADDiscovery is effectively the script’s NESTED/FLAT classification, stated as data.

"GroupName","MemberCount","Reason"
"DUB-Accounts-Payable-RO","20","Members exist but none are groups"
"DUB-Accounts-Payable-RW","27","Members exist but none are groups"
"EXA_TEAM-ADMINISTRATORS","8","Members exist but none are groups"
"GGS-I-BI ADMINISTRATORS","11","Members exist but none are groups"
"IGRAWSCTX703_ADMINISTRATORS","2","Members exist but none are groups"
"IRE-AV-HRMS2P_ADMINISTRATORS","2","Members exist but none are groups"

Phase 2 outputs — what ISC knows about

NestedGroups_<OutputTag>_<Source>.csv — one file per ISC source — maps each AD parent/child pair to its ISC entitlement IDs. This is the bridge file: AD names on the left, ISC object IDs on the right.

"OriginalInputName","ParentEntitlementId","ParentEntitlementName","ChildEntitlementId","ChildEntitlementName","SourceId","SourceName"
"DEMO-AV-CMSAP1P_Administrators","26b788cccd473f37ba0be60631701d50","CN=DEMO-AV-CMSAP1P_Administrators,OU=Rackspace,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL","2c9180858120b44b018124aa83a0424a","CN=CCMSSQLDBA,OU=Administration,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL","2c91808477a6b0c60177b5b01c8c7a7b","Active Directory Administrators"
"DEMO-AV-CRMAPP1P_Administrators","075b1dd5732339018907b3a030422972","CN=DEMO-AV-CRMAPP1P_administrators,OU=Rackspace,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL","2c9180867e2626e3017e2c90c8f20895","Enterprise Siebel Support Team","2c91808477a6b0c60177b5b01c8c7a7b","Active Directory Administrators"
"DEMOSNWH803-N_Administrators","157f52ce49c13f0fa4c8fff1e9c8bf15","CN=DEMOSNWH803-N_Administrators,OU=NVA,OU=Rackspace,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL","2c9180867e262763017e2c907cce40f6","EBizEngineers","2c91808477a6b0c60177b5b01c8c7a7b","Active Directory Administrators"
"DEMOSNWH803-N_Administrators","157f52ce49c13f0fa4c8fff1e9c8bf15","CN=DEMOSNWH803-N_Administrators,OU=NVA,OU=Rackspace,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL","2c9180858120b44b018124aa83a0424a","CN=CCMSSQLDBA,OU=Administration,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL","2c91808477a6b0c60177b5b01c8c7a7b","Active Directory Administrators"
"IRE-AV-RDPPL6P_Administrators","65b543288c2039a6be41c63cde855403","CN=IRE-AV-RDPPL6P_Administrators,OU=IRE,OU=Rackspace,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL","2c9180847e2626ec017e2c8e7aab34b5","GGS-E-ICS-AWS-FARM","2c91808477a6b0c60177b5b01c8c7a7b","Active Directory Administrators"

ParentNotInISC_<OutputTag>.csv and ChildNotInISC_<OutputTag>.csv capture the gaps. A group existing in AD but not in ISC usually means one of three things: the group is outside the connector’s search scope, the last entitlement aggregation predates the group, or the group lives in an AD source that isn’t onboarded. The child variant matters more than you’d expect — a parent can be perfectly certifiable while one of its children is invisible to ISC, and the reviewer needs to know the composition review they’re seeing is incomplete.

"ParentGroupName","ParentGroupCN","Reason"
"ASB-AW-EITGIT_Administrators","ASB-AW-EITGIT_Administrators","Parent not found in any configured ISC source"
"CBJ-AV-CMSDB1P_Administrators","CBJ-AV-CMSDB1P_Administrators","Parent not found in any configured ISC source"
"DEMO-TEST-LCOAPP1P_Administrators","DEMO-TEST-LCOAPP1P_Administrators","Parent not found in any configured ISC source"
"DEMO-TEST-LCOWEB1P_Administrators","DEMO-TEST-LCOWEB1P_Administrators","Parent not found in any configured ISC source"
"DEMO-TEST-LCOWEB2P_Administrators","DEMO-TEST-LCOWEB2P_Administrators","Parent not found in any configured ISC source"
"DEMO-TEST-LCOWEB3P_Administrators","DEMO-TEST-LCOWEB3P_Administrators","Parent not found in any configured ISC source"
"ParentGroupName","ChildGroupName","ChildGroupCN","Reason"
"CLR-TV-FS2_Administrators","ServiceAccount-FileMgmt-DO_NOT_USE","ServiceAccount-FileMgmt-DO_NOT_USE","Child not found in any configured ISC source"

Phase 3 outputs — what changed in the access model

NestedGroupRoles_<OutputTag>_<Source>.csv is the role manifest: parent group, role name, role ID, owner, child count, and whether the role was Created, Patched, or Unchanged. This file is both the input for Phase 4 and the evidence artifact for “what did this automation change in production.”

"RoleName","RoleId","Status","ParentGroupName","ParentGroupId","SourceName","ChildCount","ChildGroupNames","ErrorMessage"
"DEMO-TEST-AV-CMSAP1P_Administrators","9cb3256dcce74da3bfd4c608089fd192","AlreadyCorrect","CN=DEMO-TEST-AV-CMSAP1P_Administrators,OU=Rackspace,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL","26b788cccd473f37ba0be60631701d50","Active Directory Administrators","1","CN=CCMSSQLDBA,OU=Administration,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL",""
"DEMO-TEST-AV-CRMAPP1P_administrators","(dry-run)","DryRun-WouldCreate","CN=DEMO-TEST-AV-CRMAPP1P_administrators,OU=Rackspace,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL","075b1dd5732339018907b3a030422972","Active Directory Administrators","1","Enterprise Siebel Support Team",""
"IGRDBSNWH803-N_Administrators","(dry-run)","DryRun-WouldCreate","CN=IGRDBSNWH803-N_Administrators,OU=NVA,OU=Rackspace,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL","157f52ce49c13f0fa4c8fff1e9c8bf15","Active Directory Administrators","2","EBizEngineers; CN=CCMSSQLDBA,OU=Administration,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL",""
"DEMO-TEST-AV-RDPPL6P_Administrators","14bef1bbd5bd43e6a0f6f67591cdd4fe","AlreadyCorrect","CN=DEMO-TEST-AV-RDPPL6P_Administrators,OU=DEMO-TEST,OU=Rackspace,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL","65b543288c2039a6be41c63cde855403","Active Directory Administrators","1","GGS-E-ICS-AWS-FARM",""

MissingOwners_<OutputTag>.csv deserves special mention. For each nested parent group, the script tries to resolve an owner — the ISC entitlement owner first, falling back to a configured default. Every group that fell back is recorded here:

text

"DisplayName","EntitlementId","EntitlementName","SourceName","OriginalInputName","Reason"
"EBIZ_Admins","2c91808382b1baa00182b63cb7d84e9f","CN=EBIZ_Admins,OU=Global,OU=Administration,OU=Server_Automation,OU=AD_Groups,DC=AD,DC=CORP,DC=GLOBAL","Active Directory Security Groups","EBIZ_Admins","Entitlement has no owner set in ISC"
"Infoblox-READ","2c9180847e2626ec017e2c8d3d7a317e","CN=Infoblox-READ,OU=Quest,DC=AD,DC=CORP,DC=GLOBAL","Active Directory Security Groups","Infoblox-READ","Entitlement has no owner set in ISC"

Don’t treat this file as noise. It’s a ready-made worklist for an entitlement ownership campaign — and a preview of exactly which reviews will land on your fallback owner’s desk. In our experience, the percentage of privileged AD groups with no assigned owner surprises every stakeholder who sees this file for the first time.

Phase 4 & 5 outputs — the JSON payload files

This is the part of the design I’d defend most strongly: the script writes every campaign request body to a JSON file before sending it—or, in dry-run mode, instead of sending it.

CampaignPayloads_<OutputTag>_<timestamp>.json contains the full ROLE_COMPOSITION campaign payloads, exactly as they would be POSTed to /v2025/campaigns:

json

[
  {
    "name": "Nested Group Composition Review - ERP_TEAM-ADMINISTRATORS - Q3-2026",
    "description": "Role composition certification for nested AD group ERP_TEAM-ADMINISTRATORS (App Key: SA3X9TQ2LM). Reviews 2 child group(s).",
    "type": "ROLE_COMPOSITION",
    "roleCompositionCampaignInfo": {
      "roleIds": ["2c91808a7f3e4b1c017f4a2d9e8b0042"],
      "reviewerId": "2c91808a7f3e4b1c017f4a2d9e8b1101",
      "remediatorRef": { "type": "IDENTITY", "id": "2c91808a7f3e4b1c017f4a2d9e8b1101" }
    },
    "deadline": "2026-07-15T00:00:00Z",
    "emailNotificationEnabled": true,
    "autoRevokeAllowed": false
  }
]

CertificationPayloads_<OutputTag>_<timestamp>.json contains the search-type identity certification payloads — one per group in the input file, with the entitlement owner as the certifier rather than a generic manager review. Each owner reviews the membership of their own group:

json

[
  {
    "name": "Group Membership Review - NYC-Accounts-Payable-RW - Q3-2026",
    "description": "Identity certification for AD group NYC-Accounts-Payable-RW (App Key: SAPP01RW).",
    "type": "SEARCH",
    "searchCampaignInfo": {
      "type": "ACCESS",
      "query": "@access(id:2c91808a7f3e4b1c017f4a2d9e8b0077)",
      "reviewer": { "type": "IDENTITY", "id": "2c91808a7f3e4b1c017f4a2d9e8b1144" }
    },
    "deadline": "2026-07-15T00:00:00Z",
    "emailNotificationEnabled": true,
    "autoRevokeAllowed": false
  }
]

Why files instead of straight-to-API? Three reasons:

  1. Review-before-fire. A campaign launch sends emails to real reviewers. The JSON file is the last checkpoint where a human can diff, sanity-check counts, and confirm reviewer assignments before anything goes out.
  2. Dry-run becomes meaningful. In dry-run mode the payload files are the entire output of Phases 4–5 — you see precisely what would have been created, not just a log line saying “would create 27 campaigns.”
  3. Audit evidence. The timestamped payload file is the record of what was submitted, in a form an auditor can read without ISC access.

The Script, Section by Section

1. Parameters and Dry-Run by Default

param(
[string]$TenantUrl,          # e.g. acme.api.identitynow.com (no https://)
[string]$ClientId,
[string]$ClientSecret,
[string]$InputFile,          # CSV of groups in scope this cycle
[switch]$DryRun,             # simulate everything, change nothing
[switch]$CreateIdentityCampaigns,  # also launch identity certs for ALL input groups
[string]$LogPath    = ".\Logs",
[string]$OutputPath = ".\Output",
[string]$DefaultOwnerName = "REPLACE_WITH_FALLBACK_OWNER"
)

# Phase execution flags - run only what you need
$Execute_Phase1 = $true    # AD discovery
$Execute_Phase2 = $true    # ISC resolution
$Execute_Phase3 = $true    # Role creation
$Execute_Phase4 = $false   # Role composition campaigns (flip on after ~24h)
$Execute_Phase5 = $true    # Identity certifications (no wait needed)

A few things worth calling out:

  • -DryRun is a first-class citizen. Every mutating operation in the script is guarded by it. You should be able to run this against production at 2 PM on a Tuesday and have it touch nothing.
  • Phase flags decouple the workflow from a single sitting. Day one you run Phases 1–3 and 5; day two you flip $Execute_Phase4 = $true and run again. Phases 1–3 are no-ops on re-run if nothing changed (roles are PATCHed only when their entitlement list differs).
  • -PostCampaigns / -PostCertifications separate building payloads from sending them. Without these switches, Phases 4 and 5 only write the JSON files.
  • $DefaultOwnerName is the fallback owner when a group has no entitlement owner in ISC — every fallback is recorded in MissingOwners_<OutputTag>.csv

2. Phase 1 — Querying AD Directly for Child Groups

Child discovery happens against Active Directory itself, not ISC. This keeps the script independent of whether entitlement hierarchy is aggregated, and it sees nesting changes the moment they happen in AD rather than after the next aggregation:

powershell

function Get-ADChildGroups {
    param([string]$GroupIdentity)   # accepts name or DN

    try {
        $parent = Get-ADGroup -Identity $GroupIdentity -Properties member -ErrorAction Stop
    } catch {
        return @{ Found = $false; Children = @() }
    }

    # Children = members that are themselves groups
    $children = foreach ($dn in $parent.member) {
        $obj = Get-ADObject -Identity $dn -Properties objectClass
        if ($obj.objectClass -eq 'group') {
            Get-ADGroup -Identity $dn | Select-Object Name, DistinguishedName
        }
    }
    return @{ Found = $true; Parent = $parent; Children = @($children) }
}

Every input row exits this phase in exactly one of three buckets — found with children (ADDiscovery), found without (ADNoChildren), or not found (ADNotFound) — and that classification drives everything downstream.

3. Phase 2 — Resolving Groups in ISC Across Auto-Discovered Sources

The input file tells us which groups; auto-discovery tells us where to look in ISC. Earlier iterations required a hardcoded source list — which works until someone adds a fourth AD source to the tenant and nobody updates the script. This version queries the sources API and filters for Active Directory connectors itself:

powershell

function Get-ADSources {
    param([hashtable]$Headers)
    Write-Log "Discovering Active Directory sources in tenant..." "INFO"

    $sources   = Invoke-ISCPagedGet -Endpoint "/v2025/sources" -Headers $Headers
    $adSources = $sources | Where-Object {
        $_.type -like "*Active Directory*" -or
        $_.connectorName -like "*Active Directory*"
    }

    foreach ($src in $adSources) {
        Write-Log "  Found AD source: $($src.name) (ID: $($src.id))" "INFO"
    }
    return $adSources
}

The script then builds a multi-key lookup of each source’s entitlements so every parent and child from Phase 1 can be matched by display name or full DN, case-insensitively. Anything unmatched lands in ParentNotInISC or ChildNotInISC with the source coverage it was checked against — resolved loudly, never guessed. The same group name appearing in multiple AD sources is treated as an exception to disambiguate, not a coin flip: certifying the wrong forest’s admin group is worse than certifying nothing.

4. Phase 3 — Creating Roles, Idempotently, with Owner Resolution

For each nested parent group, the script creates a role whose entitlement membership is exactly the parent’s children. Owner resolution runs first:

powershell

function Resolve-RoleOwner {
    param([object]$ParentEntitlement, [hashtable]$Headers)

    if ($ParentEntitlement.owner -and $ParentEntitlement.owner.id) {
        return @{ Id = $ParentEntitlement.owner.id; Name = $ParentEntitlement.owner.name; Fallback = $false }
    }

    # No owner on the entitlement - fall back and record it
    $fallback = Get-ISCIdentityByName -Name $DefaultOwnerName -Headers $Headers
    $script:MissingOwners += [pscustomobject]@{
        ParentGroup   = $ParentEntitlement.name
        Source        = $ParentEntitlement.sourceName
        ResolvedOwner = "Fallback: $($fallback.name)"
        Reason        = "No entitlement owner in ISC"
    }
    Write-Log "No owner for '$($ParentEntitlement.name)' - using fallback '$($fallback.name)'" "WARN"
    return @{ Id = $fallback.id; Name = $fallback.name; Fallback = $true }
}

Then create-or-patch, guarded by dry-run:

powershell

$rolePayload = @{
    name         = "NGR - $($parent.name)"
    description  = "Auto-generated role representing children of nested AD group '$($parent.name)' for composition certification."
    owner        = @{ id = $owner.Id; type = "IDENTITY" }
    entitlements = @($children | ForEach-Object { @{ id = $_.id; type = "ENTITLEMENT" } })
}

$existing = Get-ISCRoleByName -Name $rolePayload.name -Headers $headers
if ($existing) {
    if (Compare-EntitlementLists $existing.entitlements $rolePayload.entitlements) {
        Write-Log "Role '$($rolePayload.name)' already correct - no-op" "INFO"
        $counts.Unchanged++
    } elseif ($DryRun) {
        Write-Log "DRYRUN: Would PATCH role '$($rolePayload.name)'" "DRYRUN"; $counts.Simulated++
    } else {
        Invoke-ISCSafe -Uri "https://$TenantUrl/v2025/roles/$($existing.id)" -Method PATCH -Headers $headers -Body $patchBody | Out-Null
        Write-Log "Patched role: $($rolePayload.name)" "AUDIT"; $counts.Patched++
    }
} elseif ($DryRun) {
    Write-Log "DRYRUN: Would POST role '$($rolePayload.name)' with $($children.Count) children" "DRYRUN"; $counts.Simulated++
} else {
    $role = Invoke-ISCSafe -Uri "https://$TenantUrl/v2025/roles" -Method POST -Headers $headers -Body $rolePayload
    Write-Log "Created role: $($role.name) (ID: $($role.id))" "AUDIT"; $counts.RolesCreated++
}

A consistent naming prefix (NGR - for “Nested Group Role”) matters more than it looks: it lets later phases — and future cleanup scripts — target these roles unambiguously, and it makes them instantly recognizable to admins browsing the Roles page. The create-or-patch logic makes the whole phase idempotent: re-running for Q2 after Q1 just refreshes the picture, including nesting changes since last cycle.

5. Phases 4 & 5 — Building (and Optionally Posting) the Campaigns

Both campaign phases follow the same pattern: build every payload in memory, write the JSON file, and only then — if the corresponding switch is present and dry-run is off — POST each payload:

powershell

# Phase 4 example: ROLE_COMPOSITION campaigns from the role manifest
$payloads | ConvertTo-Json -Depth 10 |
    Set-Content -Path (Join-Path $OutputPath "CampaignPayloads_${OutputTag}_$ts.json")
Write-Log "Wrote $($payloads.Count) campaign payload(s) to file" "INFO"

if ($PostCampaigns -and -not $DryRun) {
    foreach ($p in $payloads) {
        $campaign = Invoke-ISCSafe -Uri "https://$TenantUrl/v2025/campaigns" -Method POST -Headers $headers -Body $p
        Write-Log "Created campaign: $($p.name) (ID: $($campaign.id))" "AUDIT"
        $counts.CampaignsCreated++
    }
} else {
    Write-Log "PostCampaigns not set (or DryRun) - payloads written to file only" "INFO"
}

Phase 5 builds one identity certification campaign per group in the input file — nested and flat alike — with the entitlement owner as the certifier (falling back to the default owner, consistent with Phase 3). Owner-based review was a deliberate choice over one big manager-review campaign: the group owner is the person who can actually answer “should this account be in my group?”, and per-group campaigns keep each owner’s queue focused instead of burying accountability in a single thousand-line review.

Together, the two campaign types cover the full picture for every group on your list:

Review Question it answers Groups covered Certifier When it can launch
Identity certification (Phase 5) Who has this access — and should they? All input groups (nested + flat) Entitlement owner Same day as Phases 1–3
Role composition certification (Phase 4) What is nested inside this group — and should it be? Nested groups only Role (group) owner After ~24h indexing wait

The 24-Hour Rule: Search Indexing Latency

Roles created via the API are not immediately visible to ISC search — and role composition campaigns resolve their roles through search. If you create roles in Phase 3 and immediately run Phase 4, the campaigns come up empty or fail to resolve the roles.

The fix is simply patience: wait for search indexing to catch up (in practice, up to ~24 hours) before flipping $Execute_Phase4 on. This is exactly why the script is phased with files in between rather than one straight-through pass — and why Phase 5, which has no such dependency (the group entitlements have existed in search since their aggregation), can launch the same day. Build the waiting period into your runbook: Phases 1–3 and 5 on Monday, Phase 4 on Tuesday.


Running It

Always dry-run first:

powershell

.\Create-NestedGroupAndGroupMembershipCampaigns.ps1 `
    -TenantUrl "acme.api.identitynow.com" `
    -ClientId $env:ISC_CLIENT_ID `
    -ClientSecret $env:ISC_CLIENT_SECRET `
    -InputFile ".\InputNestedParentADGroups.csv" `
    -OutputTag "Q3-2026" `
    -DryRun

Review the log — every would-be change appears as a [DRYRUN] line — plus the discovery, exception, and payload files. Fix any ADNotFound / ParentNotInISC rows in the input file, re-run the dry run clean, then run it live:

powershell

# Day 1: discovery, roles, identity certifications
.\Create-NestedGroupAndGroupMembershipCampaigns.ps1 `
    -TenantUrl "acme.api.identitynow.com" -ClientId $env:ISC_CLIENT_ID -ClientSecret $env:ISC_CLIENT_SECRET `
    -InputFile ".\InputNestedParentADGroups.csv" -OutputTag "Q3-2026" -PostCertifications

# Day 2: flip $Execute_Phase4 = $true, then launch role composition campaigns
.\Create-NestedGroupAndGroupMembershipCampaigns.ps1 `
    -TenantUrl "acme.api.identitynow.com" -ClientId $env:ISC_CLIENT_ID -ClientSecret $env:ISC_CLIENT_SECRET `
    -InputFile ".\InputNestedParentADGroups.csv" -OutputTag "Q3-2026" -PostCampaigns

The execution summary tells you exactly what happened:

text

========== EXECUTION SUMMARY ==========
  Input groups in scope     : 142
  Found in AD               : 131
  Not found in AD           : 11
  Nested (with children)    : 27
  Flat (no children)        : 104
  Resolved in ISC           : 128
  Parent/child gaps in ISC  : 3
  Roles created             : 25
  Roles patched             : 2
  Roles unchanged           : 0
  Owners missing (fallback) : 9
  Campaign payloads built   : 27
  Cert payloads built       : 128
  Campaigns POSTed          : 0   (Phase 4 deferred)
  Certifications POSTed     : 128
  Failed                    : 0
=======================================

Lessons Learned

A few things I’d want anyone adapting this to know up front:

Scope-as-input changes the conversation with stakeholders. When the certification scope is a CSV the security team owns, “what’s in this quarter’s review” becomes a business decision with a paper trail — not a script change request. It also makes cycle-over-cycle comparisons trivial: diff the input files.

The exception files are a product, not a byproduct. ADNotFound exposed stale entries in the customer’s review scope; MissingOwners quantified the ownership gap on privileged groups; ChildNotInISC revealed aggregation scope issues nobody had noticed. Budget time in your cycle to act on these files — they’re where the program-level findings live.

Resolve ambiguity loudly, never silently. The same group name appearing in multiple AD sources is common (prod/non-prod forests, migration leftovers). Failing those rows into an exception file — instead of picking one — saved us from certifying the wrong forest’s admin group.

Ownership is the hard part, not the API. Discovering nested groups took an afternoon; deciding who certifies groups with no owner took meetings. Resolve your fallback-owner policy before you run this live, because that person will receive every orphaned review — and MissingOwners_<OutputTag>.csv tells you exactly how many that will be, before any reviewer gets an email.

Payload files beat straight-to-API. Twice during testing, a review of the JSON payload file caught a problem (a wrong deadline, a misassigned reviewer) that would otherwise have emailed dozens of reviewers. Build the checkpoint in.

Name everything for the cleanup you haven’t written yet. The NGR - prefix made it trivial to later write a teardown script that removes the roles after a certification cycle completes. Your future self will thank you.

Deleted groups leave dangling references. If a child group is deleted in AD between role creation and campaign launch, the role carries a stale reference. Re-running Phases 1–3 (idempotent) right before Phase 4 keeps the picture fresh.


The same pattern — input-driven scope, Active Directory-side discovery, ISC resolution, payload-file checkpoint — generalizes beyond Active Directory to any source where group structure lives outside ISC.

The full script and a sample input CSV are attached below. If you adapt this approach for your own tenant, I’d love to hear how it works for you, especially if you’ve solved the nested-certification challenge in a different way. Drop a comment and share your experience.

Resources:
Create-NestedGroupAndGroupMembershipCampaigns.ps1 (107.7 KB)

2 Likes

Hi @ajmerasunny1
Few questions on the approach?

  1. Are we bundling the parent and child groups into a role and let the certifier take a call at the role level and SailPoint will remove both the groups from the user?
  2. As SailPoint ISC doesn’t show the child groups in certification, so bundling it in the role will be a work around.
  3. Sorry for a basic question, When a user has parent group in AD, AD automatically assign the child group too and SailPoint will pull the as independent groups to their AD account link?
  4. How are we handling the members only part of Child group? Is it through Manager certification?
  5. Did we use any AI to create business descriptions for this role ?
  1. Are we bundling parent and child into a role and letting the certifier decide at role level — does SailPoint remove both groups from the user?

Not quite — the role is named after the parent group but contains only the child group entitlements. The parent itself isn’t in the role; it’s covered separately by the identity certification.

More importantly, the role is created with requestable = false and no identity members. It’s a synthetic construct that exists purely to give role composition certification something to review. So a revoke decision in the composition campaign removes the entitlement from the role definition — it does not deprovision anything from any user, because no user holds the role.

  1. When a user has the parent group in AD, does AD auto-assign the child group, and does SailPoint pull them as independent groups on the AD link?

AD nesting is a group-to-group membership — the child group object is a member of the parent group. Membership flows upward: a user who is a direct member of the child inherits the parent’s effective access. A user placed directly in the parent does not get the child’s access.

AD never writes anything extra to the user. The user’s memberOf still lists only their direct memberships, and that’s what the ISC AD connector aggregates onto the account link. Inherited access is computed at authorization time (SID chain in the token), not materialized as a membership record — which is exactly why it never shows up in a standard certification.

  1. How are we handling the members-only part of the child group? Manager certification?

Manager cert works as a backstop, but it breaks the ownership model — it puts the user’s manager in the reviewer seat instead of the parent group owner, which is the thing the customer was trying to preserve.

The cleaner answer is the input file. The script takes one list of in-scope groups and produces both campaign types from it. If you want the child group’s own direct members reviewed, put the child groups in the input list too — they get their own identity certification, still routed to the group owner.
So: input file for anything with a known owner, manager certification only as the fallback for orphaned or unowned child groups. I’d flag those in the script output rather than let them silently fall through.

  1. Did we use any AI to create business descriptions for this role?

No — the role descriptions are deterministic, built from a template string using the parent group name, source name, and child group count. Nothing generated using AI.

That’s a deliberate choice for an audit artifact: the description has to be reproducible and traceable to the AD object. If you want richer business-language descriptions, that’s a separate exercise — and I’d want owner sign-off on anything generated before it lands in a certification a reviewer is making decisions from.