Privileged account inventory across AD, cloud IAM, and SaaS admin roles
For developers and platform engineers who need a real inventory of who can do admin-level actions across directories, cloud, and SaaS. This guide shows where privileged rights actually hide, how to extract them with concrete commands and APIs, and how to decide what to inventory first.
TL;DR — A privileged account inventory is not a list of users with "admin" in the title; it is a graph of identities, groups, roles, service principals, break-glass accounts, and trust relationships that can reach high-impact actions. The single most useful starting point is: export effective admins from your directory, cloud subscriptions/accounts/projects, and top SaaS apps into one normalized table keyed by identity, then expand nested groups and role assumptions until you can answer "who can reset auth, read prod data, or change billing?" Reading time: ~7 min
What it is and where it sits
A privileged account inventory is the control-plane map of your estate: which human or machine identities can perform sensitive actions, through which path, and under what conditions. In PAM terms, this is upstream of vaulting, approval, JIT elevation, and session recording. If you cannot enumerate privilege, every later control is partial.
In a typical environment, privilege is split across three places:
- Directory: Active Directory / Entra ID / Google Workspace groups, delegated OU rights, app admin roles, emergency accounts.
- Cloud IAM: AWS IAM users, roles, trust policies, Organizations delegated admins, Azure RBAC, GCP IAM bindings, service accounts.
- SaaS: workspace owners, org admins, billing admins, SCIM/API tokens, app-specific super-admin roles.
What it replaces: spreadsheets, tribal knowledge, and one-off screenshots from admin consoles. What talks to it: directory APIs/LDAP, cloud CLIs and APIs, SaaS admin APIs, your CMDB/asset inventory, and usually your SIEM or data warehouse.
Where it lives in the flow: it is usually a scheduled collector job writing normalized records into a database or warehouse, plus a small rules engine that computes effective privilege.
View as text
[Directory APIs] ---- 1. users/groups/roles ----> [Collector] [Cloud IAM APIs] ---- 2. role bindings/trust ----> [Collector] [SaaS Admin APIs] ---- 3. admins/tokens ----> [Collector] [Collector] ---- 4. normalize + expand ----> [Privilege Graph] [Privilege Graph] ---- 5. reports/alerts ----> [Security + Eng]
The architecture context that matters: this is control-plane data, not request-path data. It does not sit inline on app traffic. It runs out-of-band, usually every few hours, because APIs are rate-limited and some sources are eventually consistent.
How it actually works
The mechanism is straightforward but easy to get wrong: collect direct assignments, expand indirection, then classify impact.
A realistic end-to-end example: you want to know whether any engineer outside the platform team can obtain production admin in AWS through directory group membership.
Step 1: Pull directory group membership, including nesting
If you have AD, start with the groups that map to cloud access, not just Domain Admins. Typical examples: AWS-Prod-Admin, Azure-Subscription-Owner, GCP-Prod-Editors, Okta-Super-Admins.
With LDAP, nested groups are the first place inventories go wrong. Query the group and recursively resolve members.
ldapsearch -LLL -x -H ldaps://dc1.example.com \
-D 'CN=svc_inventory,OU=Service Accounts,DC=example,DC=com' -W \
-b 'DC=example,DC=com' \
'(&(objectClass=group)(cn=AWS-Prod-Admin))' dn member
Typical output shape:
dn: CN=AWS-Prod-Admin,OU=Groups,DC=example,DC=com
member: CN=Platform Team,OU=Groups,DC=example,DC=com
member: CN=alice,OU=Users,DC=example,DC=com
member: CN=breakglass-admin,OU=Users,DC=example,DC=com
Then recurse into Platform Team. In AD environments, delegated rights also hide on OUs and GPOs, not only in well-known groups. If you skip OU ACLs, you will miss accounts that can reset passwords, link GPOs, or join machines to the domain.
Step 2: Pull cloud role assumptions, not just attached policies
In AWS, the critical object is often the role trust policy, because nobody has an IAM user anymore. A directory group may federate into an IAM role that has AdministratorAccess, or worse, a custom policy with broad iam:*, organizations:*, kms:*, or sts:AssumeRole rights.
List roles and inspect trust:
aws iam list-roles --query 'Roles[].RoleName' --output text | tr '\t' '\n' | grep -E 'prod|admin'
aws iam get-role --role-name ProdAdmin
Output shape to look for:
{
"Role": {
"RoleName": "ProdAdmin",
"Arn": "arn:aws:iam::123456789012:role/ProdAdmin",
"AssumeRolePolicyDocument": {
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:saml-provider/CorpIdP"
},
"Action": "sts:AssumeRoleWithSAML",
"Condition": {
"StringEquals": {
"SAML:aud": "https://signin.aws.amazon.com/saml"
}
}
}
]
}
}
}
Then fetch attached policies:
aws iam list-attached-role-policies --role-name ProdAdmin
aws iam list-role-policies --role-name ProdAdmin
If the role can assume another role, your inventory must follow that edge too:
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/ProdAdmin \
--action-names sts:AssumeRole iam:PassRole \
--resource-arns '*'
Exit-code specifics: AWS CLI returns non-zero on auth or permission failures. A common one during inventory is:
An error occurred (AccessDenied) when calling the ListRoles operation: User: arn:aws:sts::123456789012:assumed-role/InventoryRole/session is not authorized to perform: iam:ListRoles on resource: *
That is not just an operational error; it means your inventory role itself is incomplete, so your results are false negatives.
Step 3: Join the directory group to the cloud role mapping
The join point is usually your IdP app assignment or SAML/OIDC claim mapping. The exact UI varies by provider, so use the provider dashboard path for your tenant, for example your IdP dashboard under the AWS enterprise app's group assignments or attribute mappings. You are looking for the group or claim that grants ProdAdmin.
Once you know AWS-Prod-Admin maps to arn:aws:iam::123456789012:role/ProdAdmin, expand members from Step 1 and attach the effective rights from Step 2.
Now your normalized row can look like:
{
"identity": "alice@example.com",
"source": "ad",
"path": [
"CN=alice,OU=Users,DC=example,DC=com",
"CN=Platform Team,OU=Groups,DC=example,DC=com",
"CN=AWS-Prod-Admin,OU=Groups,DC=example,DC=com",
"arn:aws:iam::123456789012:role/ProdAdmin"
],
"effective_privilege": "cloud-admin",
"scope": "aws-account:123456789012",
"evidence_ts": "2026-10-30T12:00:00Z"
}
Step 4: Classify by impact, not label
Do not classify only by names like Admin, Owner, Super Admin. Classify by capability:
- Can change authentication or MFA settings
- Can create/attach policies or roles
- Can read production secrets or databases
- Can change billing or support plans
- Can create API tokens / service accounts
- Can disable logging or retention
This catches the classic misses: billing admins in SaaS, support accounts with impersonation, service accounts with broad API scopes, and break-glass users excluded from SSO.
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| You have more than one directory/cloud/SaaS admin plane and cannot answer "who can get prod admin today?" | Build it now. Start with top 10 systems by blast radius. |
| You are implementing PAM/JIT and need a baseline of existing standing privilege | Required first step. Otherwise you cannot measure reduction. |
| You are a 5-person startup with one cloud account and two SaaS tools | You probably do not need a full inventory system yet; export admins quarterly and review manually. |
| You only need to audit one system for a one-time certification | A point-in-time script may be enough; keep the normalization model so you can grow later. |
| Your org has no API access to key SaaS admin data | Still do partial inventory for directory + cloud; document SaaS blind spots explicitly. |
You probably do not need this if all privileged access is in one small environment, there are no nested groups or federation paths, and a manual review takes under an hour. The moment you have federation, multiple cloud accounts/subscriptions/projects, or contractors with delegated access, manual review stops being trustworthy.
Trade-offs
Benefit and cost come together here.
- Better incident response: you can answer "who could have done this?"
- Cost: ongoing collector maintenance, schema changes when APIs change, and periodic permission fixes for the inventory service account.
- Reduced standing privilege through visibility
- Cost: political friction. Teams will discover shadow admins and emergency accounts they do not want challenged.
- Faster audits and access reviews
- Cost: normalization work. Every source names things differently: users, principals, members, assignees, owners.
- Detection of hidden privilege paths via nested groups and role chaining
- Cost: graph expansion can get expensive and noisy. You need cycle detection and a max-depth policy.
- Central reporting across cloud and SaaS
- Cost: lock-in if you encode too much vendor-specific meaning into your schema. Keep raw evidence alongside normalized facts.
Operationally, the biggest trade-off is freshness versus API pressure. Hourly scans catch drift faster but hit rate limits and increase cost. Daily scans are cheaper but miss short-lived privilege grants unless you also ingest audit logs.
In practice
Example 1: AWS admin-role inventory with trust and attached policies
#!/usr/bin/env bash
set -euo pipefail
for role in $(aws iam list-roles --query 'Roles[].RoleName' --output text); do
doc=$(aws iam get-role --role-name "$role" --query 'Role.AssumeRolePolicyDocument' --output json)
attached=$(aws iam list-attached-role-policies --role-name "$role" --query 'AttachedPolicies[].PolicyArn' --output json)
inline=$(aws iam list-role-policies --role-name "$role" --query 'PolicyNames' --output json)
jq -n \
--arg role "$role" \
--argjson trust "$doc" \
--argjson attached "$attached" \
--argjson inline "$inline" \
'{role:$role, trust:$trust, attached:$attached, inline:$inline}'
done
This emits one JSON object per role for later classification. Gotcha: list-roles is account-scoped; in a multi-account org you must iterate accounts by assuming an inventory role in each account, or you will silently miss most privilege.
Example 2: LDAP nested-group expansion for candidate privileged groups
#!/usr/bin/env python3
import subprocess, sys, re
from collections import deque
LDAP_URI = "ldaps://dc1.example.com"
BIND_DN = "CN=svc_inventory,OU=Service Accounts,DC=example,DC=com"
BASE_DN = "DC=example,DC=com"
seen = set()
queue = deque(sys.argv[1:])
member_re = re.compile(r'^member: (.+)$')
while queue:
dn = queue.popleft()
if dn in seen:
continue
seen.add(dn)
out = subprocess.check_output([
"ldapsearch", "-LLL", "-x", "-H", LDAP_URI,
"-D", BIND_DN, "-W", "-b", BASE_DN,
f"(distinguishedName={dn})", "member", "objectClass", "userPrincipalName"
], text=True)
print(f"# {dn}")
print(out)
for line in out.splitlines():
m = member_re.match(line)
if m:
child = m.group(1)
if "OU=Groups" in child or ",CN=Users," in child:
queue.append(child)
This walks nested group membership starting from one or more DNs. Gotcha: -W prompts for a password interactively; for automation use a secure secret source and a non-interactive bind mechanism supported by your environment, and test against referral/chase behavior if you have multiple domains.
⚠️ Do not "test" privilege by attaching broader policies, adding yourself to admin groups, or changing trust policies in production. Inventory collection should be read-only. Write actions like
aws iam attach-role-policy, directory group modification, or SaaS role grants can create real access and audit noise.
Example 3: Normalize evidence into a table you can query
create table if not exists privilege_evidence (
identity text not null,
identity_type text not null,
source_system text not null,
privilege_class text not null,
scope text not null,
path jsonb not null,
raw_evidence jsonb not null,
collected_at timestamptz not null default now()
);
create index if not exists idx_priv_identity on privilege_evidence(identity);
create index if not exists idx_priv_class on privilege_evidence(privilege_class);
create index if not exists idx_priv_scope on privilege_evidence(scope);
This gives you a durable place to store both normalized facts and raw evidence. Gotcha: keep raw_evidence; when your classifier is wrong, you need to reprocess historical data without recollecting from every API.
Further reading
- NIST SP 800-53 Rev. 5, AC-2 and AC-6
- MITRE ATT&CK, Valid Accounts and Account Discovery
- AWS IAM User Guide, Policy evaluation logic
- Microsoft Learn, Privileged access strategy
- Google Cloud IAM documentation, Understanding roles
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI