Detects overprivileged accounts, orphaned accounts and toxic permission combinations in a synthetic export of Active Directory groups, Azure RBAC role assignments and an HR directory.
Every bank runs periodic access recertification: a review of who has access to what, and whether they still need it. It is an internal audit obligation, and it is tedious. A reviewer receives three exports that do not agree with each other — the identity directory says one thing, the cloud platform says another, and HR says a third — and has to work out which disagreements matter.
Two of those disagreements matter a great deal:
- An employee who left in March whose account is still active in October.
- An employee who can both raise a payment and approve it.
The first is how breaches happen through credentials nobody is watching. The second is the classic segregation-of-duties failure that lets one person move money without a second pair of eyes.
This tool reconciles the three sources into one view per identity, then runs ten detections against that view. It reports; it never remediates.
$ python -m iam_review.cli --scenario data/risky-org --as-of 2026-08-01
Cloud IAM Access Review
========================
Scenario: data/risky-org
Identities analyzed: 41
Findings: 12 (4 Critical, 2 High, 3 Medium, 3 Low)
[CRITICAL] ORPH-001 rsanchez Terminated on 2026-03-15 but AD account still active
[CRITICAL] PRIV-001 pfernandez Administrative role (Global Administrator) held by non-technical title 'Marketing Specialist'
[CRITICAL] SOD-001 jgarcia Holds conflicting groups: Payment-Approvers + Payment-Initiators
[CRITICAL] SOD-001 mlopez Holds conflicting groups: Payment-Approvers + Payment-Initiators
[HIGH] PRIV-002 atorres Broad write role (Contributor) held by junior title 'Help Desk Analyst'
[HIGH] SOD-002 dgonzalez Holds conflicting groups: Access-Admins + Access-Reviewers
[MEDIUM] ORPH-002 lmartin No login for 273 days (last seen 2025-11-01)
[MEDIUM] PRIV-003 imoreno Member of 8 AD groups (threshold: 5)
[MEDIUM] SOD-003 ealonso Holds conflicting groups: Dev-Team + Prod-Deployers
[LOW] ORPH-003 crodriguez Active identity has no manager on record
[LOW] ORPH-003 dcarrasco Active identity has no manager on record
[LOW] PRIV-004 vjimenez Role(s) assigned directly rather than via group: Contributor
With --output-dir, the same run also writes an HTML report for reviewers and
a CSV for spreadsheets and ticketing systems.
The interesting problem here is not detection, it is reconciliation. Three sources describe the same people and they disagree. A design that lets each detection read the raw CSVs would force every one of them to decide what to do when HR says "terminated" and AD says "active".
Instead, one Identity object is built per username, merging all three sources
in a single place. Detections only ever see Identity objects. Crucially, the
reconciler preserves disagreement rather than resolving it — if it quietly
synced AD status to HR status, ORPH-001 would have nothing left to find.
flowchart LR
AD[ad_groups.csv] --> L[loaders.py<br/>schema validation]
AZ[azure_roles.csv] --> L
HR[hr_directory.csv] --> L
L --> I[identity.py<br/>one Identity per username]
I --> P[privilege.py<br/>PRIV-001..004]
I --> O[orphaned.py<br/>ORPH-001..003]
I --> S[sod.py<br/>SOD-001..003]
P --> F[Findings]
O --> F
S --> F
F --> T[Terminal summary]
F --> H[HTML report]
F --> C[CSV report]
Detection is separated from remediation. The tool never disables an account or removes a group. This is enforced by a test that hashes every source file before and after a full run and fails if anything changed.
SoD rules are data, not code. Adding a new toxic combination is a one-line change to a dictionary. In a bank these rules are owned by internal control, not engineering, and a rule change should be reviewable by the people who own it.
Time-based detections take a reference date. --as-of makes dormancy checks
reproducible. Without it, a test written today would silently start failing next
year.
Exit codes are meaningful. 0 when nothing Critical or High was found, 1
when something was, 2 on a usage or data error — so the tool can gate a CI
pipeline without the caller parsing its output. Medium and Low findings do not
block: every real organisation carries some at any moment, and a tool that
failed the build for all of them would be switched off within a week.
Install in a virtual environment:
pip install -r requirements.txt
pip install -e .
Run against a scenario:
python -m iam_review.cli --scenario data/risky-org
Write reports as well as printing to the terminal:
python -m iam_review.cli --scenario data/risky-org --output-dir reports/
Pin the reference date so time-based detections are reproducible:
python -m iam_review.cli --scenario data/risky-org --as-of 2026-08-01
| Flag | Purpose |
|---|---|
--scenario DIR |
Directory holding the three source CSVs (required) |
--as-of YYYY-MM-DD |
Reference date for dormancy checks (default: today) |
--output-dir DIR |
Write HTML and CSV reports here (created if missing) |
All data is synthetic. No real person, account or system is represented, and
identifiers are obviously fabricated (PLACEHOLDER-SUBSCRIPTION-ID).
Two scenarios are generated from one roster of 41 identities by
data/generate_samples.py:
| Scenario | Findings | Exit code |
|---|---|---|
risky-org |
12 (4 Critical, 2 High, 3 Medium, 3 Low) | 1 |
clean-org |
1 (1 Low) | 0 |
Every identity in risky-org that triggers a finding was placed there to
trigger that specific detection. clean-org is the same roster with those
problems corrected — it exists to show the tool does not fire on a healthy
organisation.
The single remaining finding in clean-org is the org chart root: the Managing
Director has no manager above her, so ORPH-003 reports her. This is correct
behaviour, documented in docs/controls.md.
Regenerate both scenarios with:
python data/generate_samples.py
| ID | Detects | Severity |
|---|---|---|
| PRIV-001 | Admin role on a non-technical job title | Critical |
| PRIV-002 | Contributor/Owner on a junior job title | High |
| PRIV-003 | More than five AD group memberships | Medium |
| PRIV-004 | Azure role assigned directly instead of via a group | Low |
| ORPH-001 | Terminated in HR, still active in AD | Critical |
| ORPH-002 | No login for over 90 days | Medium |
| ORPH-003 | Active identity with no manager on record | Low |
| SOD-001 | Can both initiate and approve payments | Critical |
| SOD-002 | Grants access and reviews their own certification | High |
| SOD-003 | Holds both development and production deploy rights | Medium |
Full logic, remediation guidance, regulatory evidence and per-detection limitations are in docs/controls.md.
46 tests, run with pytest.
The most important one hashes every source CSV before and after a full run and fails if any byte changed. An audit tool that modifies the evidence it examines invalidates its own findings, so that promise is verified rather than assumed.
Others worth calling out:
- Source disagreement is preserved. If someone "improved" the reconciler to sync AD status from HR, ORPH-001 would silently detect nothing. A test fails first.
- Hostile input is escaped. A
Findingcontaining a script tag is rendered and the output checked, rather than trusting thathtml.escape()was called everywhere. - The dormancy threshold is exact. An account exactly 90 days idle is not
yet dormant, which pins the comparison as
>and not>=. - Managers resolve to real identities. Every manager reference must point at someone who exists in the roster.
- Job titles are matched by substring, so
Administrative Assistantwould be wrongly treated as technical by PRIV-001. - Thresholds are hard-coded constants, not configurable per organisation.
- Azure role scope is loaded but never evaluated:
Contributoron one storage account andContributoracross production are treated identically. - Nested AD groups are not resolved; only direct membership is read.
- Service and shared accounts are indistinguishable from human ones, which mainly affects ORPH-002.
- Sample
last_logindates are absolute, so runningclean-orgfar enough into the future will eventually trigger ORPH-002 for everyone. Tests avoid this with--as-of; the shipped data does not.
No remediation, no live directory connections, no entitlement risk scoring, no approval workflow. See docs/controls.md for the full list and the reasoning.
A correct detection can expose incorrect data. ORPH-003 initially reported six identities instead of the one planted case. The detection was right: five department managers had no manager recorded, because the synthetic org chart had no root. Adding a Managing Director fixed the data, and the surviving finding on her is now documented as expected behaviour rather than special-cased away in code.
A permissive baseline is itself a finding. PRIV-002 flagged three Support
Technicians alongside the planted case, because the IT department's default
Azure role was Contributor. Lowering it to Reader was the right fix not
because it produced a cleaner number, but because a department-wide write
default is an IAM anti-pattern.
Verifying the wrong thing is worse than not verifying. After removing semicolons from the regulatory reference text, a grep confirmed zero remained — but the grep only searched that one column. Five semicolons survived in the remediation text. The lesson was not "verify", which I was already doing, but "check that what you are verifying covers what you changed".
A parameter that is accepted but ignored fails silently. detect_orph_002
took an as_of argument from the start, but the aggregator that called it never
forwarded one. The --as-of flag would have been quietly discarded, with no
error and no wrong-looking output.
Python's csv module writes CRLF on every platform. Not a Windows artefact
leaking in — csv.DictWriter follows RFC 4180 by default. Discovered when Git
warned about line ending conversion on files generated on macOS.
Detections reference GDPR Art. 32, DORA Art. 9, PCI-DSS Req. 6.4/7/8 and PSD2 Art. 97. A technical check does not make an organisation compliant with any of these. Each detection contributes evidence toward part of an obligation, and docs/controls.md states where that evidence stops for each one.
MIT


