Skip to content

Repository files navigation

cloud-iam-access-review

CI

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.

The problem

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.

What it produces

$ 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

CLI output

With --output-dir, the same run also writes an HTML report for reviewers and a CSV for spreadsheets and ticketing systems.

HTML report

How it works

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]
Loading

Design decisions

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.

Usage

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

Options

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)

The data

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

Detections

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.

Tests

46 tests, run with pytest.

Tests passing

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 Finding containing a script tag is rendered and the output checked, rather than trusting that html.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.

Limitations

  • Job titles are matched by substring, so Administrative Assistant would 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: Contributor on one storage account and Contributor across 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_login dates are absolute, so running clean-org far enough into the future will eventually trigger ORPH-002 for everyone. Tests avoid this with --as-of; the shipped data does not.

Out of scope

No remediation, no live directory connections, no entitlement risk scoring, no approval workflow. See docs/controls.md for the full list and the reasoning.

What I learned

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.

Regulatory context

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.

Licence

MIT

About

Detects overprivileged accounts, orphaned accounts and segregation-of-duties conflicts across synthetic AD, Azure RBAC and HR exports

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages