← IAM Ideas
IAM Ideas 2026-05-10

Iiq Orphan Account Sweeper

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.

Type Theme Platform Language Date


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:

  1. 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.
  2. 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.
  3. 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:

  1. 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.
  2. Pulls accounts for each application from IIQ's /identityiq/rest/accounts endpoint (or a stubbed local fixture for demos).
  3. Pulls identities from IIQ's /identityiq/rest/identities endpoint and joins on nativeIdentity β†’ Link.identity.id.
  4. Buckets every account into one of five states:
    • HEALTHY β€” Link is present, owning Identity is active.
    • ORPHAN_TERMED β€” Link is present, owning Identity has inactive=true and HR terminationDate is 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.
  5. 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 IIQ ProvisioningPlan XML per ORPHAN_TERMED account, in IIQ's native XML schema. These can be fed straight into the IIQ Provisioning Engine via a workflow, or reviewed and submitted manually.
  6. Refuses to act on its own. The script has no --apply flag. 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 with name (IIQ Application name), enabled (bool), and an optional per-app service_account_pattern regex.
  • hr.source β€” one of csv | rest | fixture.
  • hr.path β€” file path (for csv / fixture) or URL (for rest).
  • policy.termination_grace_days β€” accounts owned by an Identity terminated fewer than this many days ago are bucketed RECENT_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-orphan ProvisioningPlan XML 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

  1. Running python script.py --config sample-config.json --no-network exits 0 and produces output that matches sample-output.txt.
  2. The generated provisioning-plans/*.xml files validate as well-formed XML and contain exactly one AccountRequest per file.
  3. The CSV row count equals the sum of bucket counts in the summary block.
  4. No third-party Python packages are required (stdlib only β€” argparse, json, csv, re, pathlib, urllib.request, xml.etree.ElementTree, datetime, os, sys, logging).
  5. No secret value appears anywhere in the file tree.

More from IAM Ideas