Iiq Orphan Account Sweeper
π§Ή IIQ Orphan Account Sweeper
A scheduled CLI utility that hunts down terminated-identity and uncorrelated accounts across every SailPoint IdentityIQ connector, buckets them by root cause, and emits a remediation plan you can feed back into IIQ.
Date
2026-05-10
Type
Utility
Theme
Automation (primary) + Respond (secondary, NIST CSF)
The IIQ problem this addresses
In a mature IdentityIQ environment with 500+ connectors and 50k+ identities, three things happen every week without anyone noticing:
- Termination lag. Workday fires a termination on Friday. The IIQ Lifecycle Event "Terminated" runs, but two or three downstream connector deprovisioning steps fail silently β TLS handshake error against a SaaS app, a stale service account on a custom JDBC connector, a connector that doesn't support
DELETE. The Identity is marked inactive, the Link still exists, the target system still has a live account. - Uncorrelated accounts pile up. Aggregations keep pulling accounts that don't map to any IIQ Identity β leftover service accounts, contractors who left before the HR feed cycled, accounts created by a sysadmin out-of-band. The Uncorrelated Identities report exists but lives at the bottom of the Reports menu and doesn't bucket why an account is uncorrelated.
- The native UI doesn't surface root cause. Out of the box, IIQ shows you that an Identity is
inactive. It does not show you that 11 of that Identity's 14 Links are still active on the connector side. You have to write a custom report or a search query β every time.
The result: a slowly growing population of orphaned access that auditors will eventually find, and that an attacker can occasionally reach via a leftover credential.
What this utility does
iiq-orphan-account-sweeper is a CLI you run from an admin workstation or a scheduled job. Each run:
- Reads a config that lists which IIQ applications (connectors) to sweep and the authoritative HRIS source (Workday CSV export or REST stub) for identity state.
- Pulls accounts for each application from IIQ's
/identityiq/rest/accountsendpoint (or a stubbed local fixture for demos). - Pulls identities from IIQ's
/identityiq/rest/identitiesendpoint and joins onnativeIdentityβLink.identity.id. - Buckets every account into one of five states:
HEALTHYβ Link is present, owning Identity isactive.ORPHAN_TERMEDβ Link is present, owning Identity hasinactive=trueand HRterminationDateis older than the configured grace period.UNCORRELATEDβ no Link exists; account was aggregated but never matched to an Identity.SERVICE_ACCOUNTβ name matches a configured allowlist regex; bucketed separately so it stops being a false positive.RECENT_TERMINATIONβ owning Identity is inactive but inside the grace window; reported, not actioned.
- Emits two outputs:
sweep-results.csvβ one row per orphan with app, native identity, owning identity, status, last login (if connector provides it), and recommended action.provisioning-plans/β one IIQProvisioningPlanXML perORPHAN_TERMEDaccount, in IIQ's native XML schema. These can be fed straight into the IIQ Provisioning Engine via a workflow, or reviewed and submitted manually.
- Refuses to act on its own. The script has no
--applyflag. The output is evidence and a plan. Submitting the plan is a separate, gated step (usually a BPM workflow with an approval).
Why this matters (the business case)
| Outcome | What it gets you |
|---|---|
| Cleaner SOX evidence | Auditors ask "what's your control for orphan accounts on terminated users?" β you now have a weekly machine-generated artifact that proves the sweep happened. |
| Reduced blast radius | Every orphan account is a credential an attacker can phish, brute-force, or pivot from. Closing them weekly shrinks the attack surface predictably. |
| Fewer help-desk tickets six months later | Re-hires sometimes inherit zombie accounts from their prior tenure. Sweeping monthly stops that. |
| A real number for your IAM scorecard | "Open orphan accounts" goes from "unknown" to "12 last Monday, 9 this Monday" β a metric you can show a CISO. |
NIST CSF mapping
- PR.AC-1 β Identities and credentials are issued, managed, verified, revoked, and audited. The sweeper is the revocation-audit side of this control.
- RS.AN-1 β Notifications from detection systems are investigated. Each orphan is a notification with a built-in investigation packet (Identity, termination date, target app).
- RS.MI-1 β Incidents are contained. The provisioning plans are the containment artifact.
- DE.CM-3 β Personnel activity is monitored to detect potential cybersecurity events. Terminated-but-still-active is exactly such an event.
Run it locally (no IIQ required)
# Requires Python 3.10+
python script.py --config sample-config.json --no-network
The --no-network flag tells the script to read accounts and identities from the local fixture files _fixtures/accounts/*.json and _fixtures/identities.json (synthetic data, generated for this demo) instead of hitting an IIQ REST endpoint. The console output you'll see matches sample-output.txt byte-for-byte.
Drop the flag and supply a real iiq_base_url, iiq_username, and IIQ_PASSWORD env var to point the same script at a live IIQ instance. Read-only by design β it never writes back to IIQ.
What's intentionally out of scope
- Applying the provisioning plans. That belongs in a BPM workflow with a human gate. The sweeper produces the plan; it does not submit it.
- Mover detection. Joiner/Mover is a different shape of problem (entitlement drift, not account leakage). A future utility can handle that.
- Service-account governance. Allowlisted service accounts are bucketed and excluded; this script does not attempt to recertify them.
- Connector-specific deletion semantics. Some apps soft-delete, some hard-delete. The generated plan uses
op="Disable"by default; the BPM workflow that consumes the plan should override per-app as needed.
Files in this folder
| File | Purpose |
|---|---|
README.md |
This document. |
requirements.md |
Functional requirements, user stories, success criteria. |
metadata.md |
Provenance β model, generation date, source workflow. |
script.py |
The sweeper itself. Single-file, stdlib-only, runnable from any Python 3.10+ install. |
sample-config.json |
Illustrative configuration the script reads. Server URLs are https://example.invalid; no real secrets. |
sample-output.txt |
The exact console output of python script.py --config sample-config.json --no-network. |
cover-image.png |
Concept art (16:10, no text, nanobanana-generated). |
Requirements
Requirements β IIQ Orphan Account Sweeper
Functional requirements
FR-1 β Read configuration from JSON
The script reads a JSON config file pointed to by --config <path>. The config declares:
iiq.base_urlβ the IIQ REST root, e.g.https://identityiq.example.invalid/identityiq/rest.iiq.usernameβ the service account that performs reads.iiq.password_envβ the name of an environment variable holding the password (the password itself is never in the config).iiq.timeout_secondsβ per-request timeout (default 30).applicationsβ array of objects, each withname(IIQ Application name),enabled(bool), and an optional per-appservice_account_patternregex.hr.sourceβ one ofcsv | rest | fixture.hr.pathβ file path (forcsv/fixture) or URL (forrest).policy.termination_grace_daysβ accounts owned by an Identity terminated fewer than this many days ago are bucketedRECENT_TERMINATION(reported, not actioned).policy.global_service_account_patternβ regex applied across every app.output.results_csvβ path for the per-account CSV.output.plans_dirβ directory to write per-orphanProvisioningPlanXML files.
FR-2 β Pull accounts per application
For each applications[i].enabled === true, the script issues:
GET {base_url}/accounts?application={name}&offset={n}&limit=500
It paginates until the response returns fewer than limit rows. Failure on any single application is logged and the sweep continues; the failing app appears in the run summary with a non-zero exit-code-contributing flag.
FR-3 β Pull identities once per run
The script issues a single paginated pull of identities with the projection it needs:
GET {base_url}/identities?fields=id,name,active,terminationDate,links&offset={n}&limit=500
Identities are cached in memory for the duration of the run.
FR-4 β Bucket every account
Each account is assigned exactly one of:
HEALTHYβ Link present, Identity active.ORPHAN_TERMEDβ Link present, Identity inactive,terminationDateβ€today - termination_grace_days.RECENT_TERMINATIONβ Link present, Identity inactive, but within grace window.UNCORRELATEDβ no Link mapping the account to any Identity.SERVICE_ACCOUNTβ account name matches any configured service-account regex (global or per-app).
FR-5 β Emit results CSV
The script writes output.results_csv with the columns:
application, native_identity, account_display_name, status,
linked_identity_id, linked_identity_name, identity_active,
termination_date, days_since_termination, last_login,
recommended_action, sweep_run_id
Status filter: every account is included so the file doubles as evidence of "what we saw."
FR-6 β Emit ProvisioningPlan XMLs
For every ORPHAN_TERMED row, the script writes a file output.plans_dir/<run-id>__<app>__<native_id_sanitized>.xml containing a valid IIQ ProvisioningPlan element with a single AccountRequest op="Disable" for the application + native identity. The XML is well-formed and namespaces match IIQ 8.x conventions.
FR-7 β Run summary to stdout
At end of run, print a fixed-format summary block:
- Per-app counts of each bucket.
- Total orphan-termed count.
- Total uncorrelated count.
- Path to the CSV and plans directory.
- Run ID.
- Exit code rationale.
FR-8 β No-network demo mode
With --no-network, the script reads accounts from _fixtures/accounts/<app>.json and identities from _fixtures/identities.json instead of hitting IIQ. This mode is for the bundled sample and for offline development.
FR-9 β Exit codes
0β every enabled application swept successfully, zero unhandled exceptions.1β usage error (missing config, malformed JSON, unknown CLI flag).2β one or more applications failed to sweep (network/HTTP error). Successful apps still emit their rows.3β unexpected internal error.
FR-10 β Read-only by design
The script issues only GET requests against IIQ. There is no --apply flag. Submission of plans is a separate, gated process.
User stories
- As an IAM engineer, I want to schedule this script weekly so I get a fresh CSV on Monday morning showing every orphan account, without having to log into IIQ.
- As a SOX auditor, I want a file that proves the orphan-account control runs on a cadence, with a deterministic input config and a deterministic output for a given input.
- As a security operations analyst, I want the worst orphans (high-privilege apps, long-since-termed identities) to be visible in the run summary, not buried in the CSV.
- As an IAM director, I want one number β "open orphan accounts on terminated users" β that I can trend week-over-week and put on a dashboard.
Non-goals
- This is not a recertification engine. It does not re-run access reviews.
- It does not write back to IIQ. The output is evidence and plans; submission is a separate workflow.
- It does not modify connector configurations.
- It does not handle joiner / mover cases β only the leaver side of the lifecycle.
Success criteria
- Running
python script.py --config sample-config.json --no-networkexits 0 and produces output that matchessample-output.txt. - The generated
provisioning-plans/*.xmlfiles validate as well-formed XML and contain exactly oneAccountRequestper file. - The CSV row count equals the sum of bucket counts in the summary block.
- No third-party Python packages are required (stdlib only β
argparse,json,csv,re,pathlib,urllib.request,xml.etree.ElementTree,datetime,os,sys,logging). - No secret value appears anywhere in the file tree.
More from IAM Ideas