Most teams turn on IAM Access Analyzer, glance at the external-access findings once, and forget it exists. That leaves the three genuinely valuable capabilities on the table: continuous detection of unused access (dormant roles, stale keys, granted-but-never-used permissions), CloudTrail-driven policy generation that bootstraps least privilege from real activity, and the policy-check APIs that let you fail a pull request the moment someone widens a trust policy or adds iam:PassRole to the wrong principal. This is the build I reach for when a platform team has to make least privilege a default rather than an audit finding — and it is the difference between a SOC 2 control you can demonstrate and a spreadsheet of IAM debt nobody dares touch.
A note before the commands: Access Analyzer ships three distinct analyzer resources plus a set of stateless check APIs. They share a service namespace but solve different problems, and conflating them is the single most common reason these projects stall. The stateful analyzers watch your org and emit findings; the stateless checks gate a policy document in a pipeline and store nothing. Keep that taxonomy straight and the rest follows; blur it and you will pay per-entity charges for an analyzer you meant to run as a free pipeline check, or wait for findings that a stateless API would have returned synchronously.
By the end you will be able to stand up an org-wide analyzer from a delegated administrator account, triage external access without paging the same partner-bucket finding twice, surface dormant roles and unused permissions at fleet scale, generate a tight policy from a role’s own CloudTrail history, and wire validate-policy plus the check-* APIs into Terraform and CloudFormation so a dangerous policy never reaches an account. Because this is a reference you will return to mid-incident and mid-review, the analyzer types, the finding types, the API limits, the error reasons and the remediation paths are all laid out as scannable tables — read the prose once, then keep the tables open.
What problem this solves
In a real organization, IAM permissions only ever ratchet up. A pipeline breaks, someone adds Action: "*" “to unblock the deploy,” the ticket closes, and the wildcard is now load-bearing. Multiply that across sixty accounts and a few years and you have hundreds of roles carrying access nobody can justify and nobody dares remove — because removing the wrong action breaks production, and there is no evidence of which actions are safe to remove. That is the pain: not that least privilege is unknown as a principle, but that retrofitting it onto a live estate is terrifying without data.
What breaks without this: audit findings that never close (the auditor flags broad roles; you cannot prove what is unused, so you cannot trim them); a blast radius nobody has measured (a leaked key for an over-permissioned role can reach far more than its job requires); and a review process that rubber-stamps IAM PRs because a human eyeball cannot tell whether a 200-line policy grants one new dangerous action. The wildcard that lets a deployment role call iam:* is invisible in code review and catastrophic in an incident.
Who hits this: every platform, security and SRE team running a multi-account AWS org; teams under SOC 2 / PCI / FedRAMP that must demonstrate least privilege, not merely assert it; and anyone who has inherited an estate where roles accreted permissions faster than anyone removed them. The fix is almost never “write tighter policies by hand” — it is “let the service read what each role actually did, generate the tight policy, and gate every future change so the wildcards can never come back.”
To frame the whole field before the deep dive, here is every capability this article covers, the question it answers, whether it is stateful or stateless, and what it costs:
| Capability | Resource / API | What it answers | Stateful? | Cost |
|---|---|---|---|---|
| External access | ORGANIZATION / ACCOUNT analyzer |
“Which resources are reachable by a principal outside my zone of trust?” | Stateful | Free |
| Unused access | ORGANIZATION_UNUSED_ACCESS / ACCOUNT_UNUSED_ACCESS analyzer |
“Which roles, keys and permissions are granted but never used?” | Stateful | Per analyzed role/user, monthly |
| Internal access | ORGANIZATION_INTERNAL_ACCESS / ACCOUNT_INTERNAL_ACCESS analyzer |
“Which principals inside my org can reach critical resources?” | Stateful | Paid tier |
| Policy generation | start-policy-generation |
“What policy grants exactly what this role actually did?” | Stateless job | Free |
| Policy validation | validate-policy |
“Does this JSON have errors or security warnings?” | Stateless | Free |
| Custom checks | check-no-new-access, check-access-not-granted, check-no-public-access |
“Does this change grant new or forbidden access?” | Stateless | Per check |
Learning objectives
By the end of this article you can:
- Name all six analyzer
--typevalues, explain which are free versus billed-per-entity, and choose the right scope (account vs organization) for each. - Deploy an
ORGANIZATIONexternal-access analyzer and anORGANIZATION_UNUSED_ACCESSanalyzer from a delegated administrator account, with deliberateunusedAccessAgeandexclusions. - Triage external-access findings with archive rules so known-good cross-account access never pages anyone twice, and route only genuinely new findings to your SOC via EventBridge.
- Surface and remediate the three unused-finding classes (
UnusedIAMRole,UnusedIAMUserAccessKey/Password,UnusedPermission) at fleet scale, and track the count as a privilege-reduction KPI. - Generate a least-privilege policy from a role’s CloudTrail history with
start-policy-generation, and explain exactly what that policy misses and how to converge it. - Wire
validate-policyinto CI to fail onERRORandSECURITY_WARNING, and addcheck-no-new-access/check-access-not-granted/check-no-public-accessas relative and absolute guardrails. - Run the checks against the rendered Terraform/CloudFormation document before apply, and prove the gate actually fails with a negative test.
Prerequisites & where this fits
You should already understand IAM policy evaluation: that a request is allowed only if an explicit Allow matches and no explicit Deny does, that identity policies, resource policies, permission boundaries and SCPs all participate, and that a trust policy is what makes a role assumable. If that chain is fuzzy, read IAM Fundamentals: Users, Roles, Policies & Evaluation first; the boundary/SCP ceiling mechanics live in IAM Least Privilege with Permission Boundaries. You should be comfortable running aws CLI v2, reading JSON output, and have an AWS Organization with a delegated administrator pattern — the management account should delegate security tooling to an audit account, as set up in AWS Organizations, SCPs & Delegated Admin.
This sits in the Identity & Governance track. It assumes the org foundation from AWS Control Tower Multi-Account Landing Zone and pairs tightly with CloudWatch & CloudTrail Observability, because policy generation reads from a CloudTrail trail and finding routing flows through EventBridge. Do not confuse this service with the similarly named VPC Network Access Analyzer / Reachability Analyzer covered in Network Reachability & Access Analyzer — that one reasons about network paths, this one reasons about IAM access.
A quick map of who owns what during a least-privilege program, so you escalate to the right person:
| Layer | What lives here | Who usually owns it | What it controls in this build |
|---|---|---|---|
| Management account | Org root, delegated-admin registration | Cloud platform / org admins | Where you register-delegated-administrator |
| Delegated admin (audit) account | The analyzers, archive rules, findings | Security / GRC team | All stateful findings land here |
| Member accounts | The roles, keys, resource policies being analyzed | App / workload teams | The entities that generate findings |
| CI/CD pipelines | Terraform/CFN, the check-* calls |
Platform / DevOps | Where stateless gates run pre-apply |
| Trail + EventBridge | CloudTrail history, finding events | Observability / SecOps | Generation source + finding routing |
| SOC / ticketing | Triaged, escalated findings | Security operations | Consumes only genuinely-new findings |
Core concepts
Five mental models make every later command obvious.
Stateful analyzers watch; stateless checks gate. An analyzer is a long-lived resource that continuously evaluates your account or org and stores findings. The validate-policy and check-* APIs are stateless functions: you call them against a JSON document, they return an answer, nothing is persisted. Almost everything below is these two halves working together — the analyzers tell you where you are (broad, unused access today), and the checks stop you from getting worse (a PR that widens access). Reach for an analyzer when you want continuous detection; reach for a check when you want a build gate.
External, unused and internal are three separate resources. There is no combined analyzer. External access answers “who outside my zone of trust can reach this?” and is free, so leave it on forever. Unused access answers “what is granted but idle?” and is billed per analyzed IAM role and user per month, so you deploy it deliberately and prune what it watches. Internal access answers “who inside my org can reach this critical resource?” and is a paid tier you enable for crown-jewel monitoring. Mixing them up is how you either pay for analysis you did not intend or wait for findings the wrong analyzer will never produce.
The valid --type values are a closed set. Straight from the service model, the only legal analyzer types are: ACCOUNT, ORGANIZATION, ACCOUNT_UNUSED_ACCESS, ORGANIZATION_UNUSED_ACCESS, ACCOUNT_INTERNAL_ACCESS, ORGANIZATION_INTERNAL_ACCESS. An ORGANIZATION-scoped analyzer automatically covers existing and future member accounts — you never enumerate them. Account-scoped analyzers see only their own account. There is no --type for “unused + external”; deploy one of each.
A finding is a fact, not a verdict. Access Analyzer flags access that crosses your zone of trust or sits unused — it does not know your intent. A bucket policy granting a partner account access is expected external access; the service flags it because it is external, and your job is to mark it intended (an archive rule). The discipline is: archive rules absorb the known-good, EventBridge escalates the genuinely new, and nobody triages the same finding twice.
Generation is a floor, checks are a ceiling. start-policy-generation reads a role’s CloudTrail history and writes the smallest policy that would have permitted what the role did — a floor you build up from reality instead of guessing. The check-* APIs encode what a policy must never grant — a ceiling you enforce in CI. The generated floor plus the unused-access feedback loop converges far faster than authoring by hand; the ceiling makes the improvement permanent. Neither alone is enough: generation can undershoot (it only sees the trail window), and a valid policy can still be too broad.
The vocabulary in one table
Pin down every moving part before the deep sections; the glossary repeats these for lookup, this table is the model side by side:
| Concept | One-line definition | Where it lives | Why it matters |
|---|---|---|---|
| Analyzer | Stateful resource that emits findings | Delegated admin account | The thing you pay for / leave on |
| Zone of trust | Your account or org boundary | Implicit per analyzer scope | External = crosses it |
| Finding | One detected access fact | Stored on the analyzer | Triaged, archived, or escalated |
| Archive rule | Auto-archives matching findings | On the analyzer | Absorbs known-good; cuts noise |
unusedAccessAge |
Idle days before an entity is flagged | Unused-analyzer config | Noise & relevance lever |
exclusions |
Accounts/tags the unused analyzer skips | Unused-analyzer analysisRule |
Cost & signal lever |
validate-policy |
Stateless lint of a policy doc | Called in CI | Catches malformed + dangerous |
check-no-new-access |
Fails if a policy grants new access | Called in CI | Relative guardrail |
check-access-not-granted |
Fails if a policy grants forbidden access | Called in CI | Absolute guardrail |
check-no-public-access |
Fails if a resource policy is public | Called in CI | Resource-policy guardrail |
start-policy-generation |
Async job: CloudTrail → policy | Triggered ad hoc | Bootstraps least privilege |
| Delegated administrator | The account that runs org analyzers | Audit/security account | Not the management account |
The analyzer types and the check APIs, end to end
Before any setup, internalize the full surface area. The external/unused/internal analyzers are stateful resources that continuously evaluate and emit findings; the validate-policy and check-* APIs are stateless functions you call against a document in a pipeline — no analyzer required, no findings stored. Here is every analyzer type with its scope, what it produces, and the cost model:
--type value |
Scope | Finding types it produces | Cost model | Leave on permanently? |
|---|---|---|---|---|
ACCOUNT |
Single account | ExternalAccess |
Free | Yes |
ORGANIZATION |
Whole org (auto future accts) | ExternalAccess |
Free | Yes (one per org) |
ACCOUNT_UNUSED_ACCESS |
Single account | UnusedIAMRole, UnusedIAMUserAccessKey, UnusedIAMUserPassword, UnusedPermission |
Per analyzed role/user / month | Deliberately, with exclusions |
ORGANIZATION_UNUSED_ACCESS |
Whole org | Same four unused types | Per analyzed role/user / month | Deliberately, with exclusions |
ACCOUNT_INTERNAL_ACCESS |
Single account | InternalAccess |
Paid tier | For crown-jewel resources |
ORGANIZATION_INTERNAL_ACCESS |
Whole org | InternalAccess |
Paid tier | For crown-jewel resources |
And here is the stateless half — the validate/check APIs, what each takes, what it returns, and the one job it owns:
| API | Input | Returns | The one job it owns | Needs an analyzer? |
|---|---|---|---|---|
validate-policy |
One policy doc + --policy-type |
List of findings (ERROR, SECURITY_WARNING, WARNING, SUGGESTION) |
Lint: malformed + dangerous patterns | No |
check-no-new-access |
Existing doc + new doc + reference | PASS / FAIL + reasons |
Block any new access vs the old policy | No |
check-access-not-granted |
One doc + --access list |
PASS / FAIL + message |
Block specific forbidden actions/resources | No |
check-no-public-access |
One resource policy + resource type | PASS / FAIL |
Block a resource policy that is public | No |
start-policy-generation |
Principal ARN + trail details | jobId (async) |
Build a policy from real CloudTrail activity | No (reads a trail) |
When you have a policy document in hand, this is the lookup for which tool applies to it — match the document you’re reviewing to the analysis it deserves:
| You have a… | validate-policy type |
Relevant check(s) | Relevant analyzer |
|---|---|---|---|
| Identity policy (role/user/group) | IDENTITY_POLICY |
check-no-new-access, check-access-not-granted |
Unused-access (for the role) |
Trust policy (AssumeRolePolicyDocument) |
RESOURCE_POLICY |
check-no-new-access (widening) |
External-access |
| S3 bucket / KMS key / SQS policy | RESOURCE_POLICY |
check-no-public-access |
External-access, internal-access |
| Service Control Policy (SCP) | SERVICE_CONTROL_POLICY |
— (validate syntax) | — |
| Resource Control Policy (RCP) | RESOURCE_CONTROL_POLICY |
— (validate syntax) | — |
| Secrets Manager secret policy | RESOURCE_POLICY |
check-no-public-access |
External-access |
The six finding types you will filter on, mapped to the analyzer that produces each and the remediation it implies:
| Finding type | Produced by | Means | Default remediation |
|---|---|---|---|
ExternalAccess |
External analyzer | A principal outside your zone of trust can reach a resource | Archive if intended; else scope the resource policy |
UnusedIAMRole |
Unused analyzer | A role unassumed within unusedAccessAge |
Confirm last_used, then delete |
UnusedIAMUserAccessKey |
Unused analyzer | A long-lived access key idle past the window | Deactivate, then delete |
UnusedIAMUserPassword |
Unused analyzer | A console password idle past the window | Disable console access |
UnusedPermission |
Unused analyzer | A used role with actions/services it never touched | Trim the policy to the used set |
InternalAccess |
Internal analyzer | An in-org principal can reach a monitored critical resource | Verify intent; tighten if unexpected |
The service-level limits worth knowing before you design the rollout — these are the boundaries that shape how many analyzers you run and how you scope them:
| Limit / quota | Value | Why it matters |
|---|---|---|
| Analyzers of one type per account/region | 1 | You can’t run two ORGANIZATION external analyzers — one covers the org |
unusedAccessAge range |
1–365 days | Below ~30 floods you with on-call/quarterly noise |
| Org analyzer member coverage | All current + future accounts | No enumeration; new accounts auto-join |
| Generation source | 1 CloudTrail trail per job | The trail must already cover your window |
| Findings retention | Persisted on the analyzer until resolved/archived | Archive (don’t delete) to keep the audit trail |
Resource types for check-no-public-access |
S3, S3 AP, SQS, KMS, Secrets, SNS, and more | Identity policies are out of scope for this check |
| Billing unit (unused) | Per analyzed role and user, per month | Exclusions reduce both cost and noise |
A recurring theme in the findings — especially external access — is the condition key that turns a “public” grant into an org-scoped one. The keys you’ll add to resource policies to clear false external findings and close the real exposure:
| Condition key | Scopes access to… | Clears which finding | Typical placement |
|---|---|---|---|
aws:PrincipalOrgID |
Any principal in your org | External access from your own org accounts | S3/KMS/SQS resource policies |
aws:PrincipalOrgPaths |
Principals under a specific OU path | Over-broad in-org access | Resource policies for OU-scoped sharing |
aws:SourceAccount |
A specific calling account | Confused-deputy cross-service access | Service resource policies |
aws:SourceArn |
A specific source resource | Over-broad service-to-service grants | SNS/SQS/KMS policies |
aws:PrincipalTag/<k> |
Principals carrying a tag (ABAC) | Tag-mismatched access | Identity + resource policies |
1. Deploy an organization analyzer with delegated administration
Run Access Analyzer from a delegated administrator account (your security/audit account), not the management account. That account then sees external-access findings for every member account in one place, and you keep the management account clean of day-to-day tooling. First, register the delegated admin from the management account — once:
# From the Organizations management account, once.
aws organizations register-delegated-administrator \
--service-principal access-analyzer.amazonaws.com \
--account-id 222222222222 # your security/audit account
Now, in the delegated admin account, create the organization-scoped external-access analyzer. An ORGANIZATION analyzer automatically covers existing and future member accounts — you do not enumerate them:
aws accessanalyzer create-analyzer \
--analyzer-name org-external-access \
--type ORGANIZATION \
--tags Team=security,ManagedBy=terraform
External-access findings are free, so leave that analyzer on permanently. The unused-access analyzer is billed per analyzed IAM role and user per month, so its configuration is where cost and signal live. Set the tracking window (unusedAccessAge, in days) and use exclusions to skip break-glass roles and sandbox accounts you never want flagged:
aws accessanalyzer create-analyzer \
--analyzer-name org-unused-access \
--type ORGANIZATION_UNUSED_ACCESS \
--configuration '{
"unusedAccess": {
"unusedAccessAge": 90,
"analysisRule": {
"exclusions": [
{ "accountIds": ["333333333333"] },
{ "resourceTags": [ { "break-glass": "true" } ] }
]
}
}
}'
unusedAccessAge: 90 means an entity must be idle for 90 days before it is reported, which kills the noise from quarterly jobs and on-call roles. The exclusions block is your cost and signal lever: exclude a whole sandbox account by accountIds, or exclude tagged roles by resourceTags. Every knob on these two analyzers, with its default, range and the trade-off you are making:
| Setting | Applies to | Default | Valid range / values | When to change | Trade-off / gotcha |
|---|---|---|---|---|---|
--type |
Both | none (required) | The six legal types | Per analyzer purpose | Wrong type = wrong findings or surprise billing |
--analyzer-name |
Both | none (required) | 1–255 chars, unique per account | Always | Name is the handle for archive rules |
unusedAccessAge |
Unused | 90 | 1–365 days | Lower for tight estates; higher to cut noise | Too low floods you with on-call/quarterly roles |
exclusions.accountIds |
Unused | none | Member account IDs | Skip sandboxes / dev accounts | Excluded accounts are not billed either |
exclusions.resourceTags |
Unused | none | Tag key/value pairs | Skip break-glass, service-linked roles | Tags must actually be set on the roles |
--tags |
Both | none | Up to 50 tags | Always (ownership, IaC) | Drives your own cost allocation |
| Region | Both | per-call | Any enabled region | Per-region IAM-resource coverage | Findings are regional for non-global resources |
Codify all of this in Terraform so the org analyzer is reproducible and reviewable:
resource "aws_accessanalyzer_analyzer" "org_external" {
analyzer_name = "org-external-access"
type = "ORGANIZATION"
}
resource "aws_accessanalyzer_analyzer" "org_unused" {
analyzer_name = "org-unused-access"
type = "ORGANIZATION_UNUSED_ACCESS"
configuration {
unused_access {
unused_access_age = 90
analysis_rule {
exclusion {
account_ids = ["333333333333"]
}
exclusion {
resource_tags = [{ "break-glass" = "true" }]
}
}
}
}
}
Account-scoped versus organization-scoped is a deliberate choice, not a default — here is how they differ on every axis that matters:
| Axis | ACCOUNT / ACCOUNT_UNUSED_ACCESS |
ORGANIZATION / ORGANIZATION_UNUSED_ACCESS |
|---|---|---|
| Coverage | Only the account it lives in | Every member account, current and future |
| Where you create it | Any account | The delegated admin (or management) account |
| New accounts | Not covered automatically | Covered automatically on join |
| Findings location | That account | Centralized in the delegated admin account |
| Best for | A single sensitive account, or pre-org pilots | The standard org-wide deployment |
| Quota | 1 analyzer per type per account/region | 1 org analyzer per type per org |
2. Triage external-access findings with archive rules
List the live, unresolved external-access findings with list-findings-v2 — the current API, the one that understands all six finding types:
ANALYZER_ARN=$(aws accessanalyzer list-analyzers \
--query "analyzers[?name=='org-external-access'].arn" --output text)
aws accessanalyzer list-findings-v2 \
--analyzer-arn "$ANALYZER_ARN" \
--filter '{"status":{"eq":["ACTIVE"]},"findingType":{"eq":["ExternalAccess"]}}' \
--query 'findings[].{Resource:resource,Type:resourceType,Principal:principal,Action:action}' \
--output table
The --filter object is the same vocabulary for list-findings-v2, archive rules, and EventBridge patterns — learn the keys once and reuse them everywhere. The ones you reach for most:
| Filter key | Matches | Operators | Example use |
|---|---|---|---|
status |
Finding state | eq |
{"status":{"eq":["ACTIVE"]}} — hide archived |
findingType |
One of the six types | eq |
Isolate UnusedPermission only |
resourceType |
e.g. AWS::S3::Bucket |
eq |
Triage by resource class |
principal.AWS |
The external principal | eq, contains |
Whitelist a partner account |
resource |
The resource ARN | eq, contains |
Pin one shared bucket/key |
condition |
Conditions on the grant | exists |
Accept org-scoped grants |
error |
Findings the analyzer couldn’t resolve | exists |
Surface, never auto-archive |
createdAt / updatedAt |
Finding timestamps | eq, contains |
Window a triage sweep |
A finding is not automatically a problem. A bucket policy that grants a partner account access is expected external access; Access Analyzer flags it because it crosses your zone of trust, and your job is to mark it intended. Do that with an archive rule so the finding never pages anyone again — an archive rule auto-archives matching findings, current and future, that you have decided are acceptable:
# Auto-archive any external access granted to our known partner account.
aws accessanalyzer create-archive-rule \
--analyzer-name org-external-access \
--rule-name trusted-partner-444 \
--filter '{"principal.AWS":{"contains":["444444444444"]}}'
For a one-off finding you have reviewed, archive it directly instead of writing a rule:
aws accessanalyzer update-findings \
--analyzer-name org-external-access \
--status ARCHIVED \
--ids "ab-1234abcd-..."
A finding moves through three states; knowing what drives each transition keeps your triage honest:
| Status | Meaning | How it gets there | What you do |
|---|---|---|---|
ACTIVE |
Live, unresolved access | New finding, or access still present | Triage: archive if intended, else fix |
ARCHIVED |
Reviewed, accepted | update-findings or an archive rule match |
Nothing — it won’t page you again |
RESOLVED |
The underlying access is gone | You removed the grant; analyzer re-scans | Confirm and move on |
Archive rules are filters; the fields you can match on (and the operators) decide how surgical your rule is:
| Filter key | Matches on | Example operator | Use it to… |
|---|---|---|---|
principal.AWS |
The external principal ARN/account | contains |
Whitelist a known partner account |
resource |
The resource ARN | eq / contains |
Accept a specific shared bucket/key |
resourceType |
e.g. AWS::S3::Bucket |
eq |
Accept a whole resource class |
condition |
Conditions on the grant | exists |
Accept access gated by aws:PrincipalOrgID |
error |
Findings the analyzer couldn’t fully evaluate | exists |
Surface (not archive) evaluation errors |
Route everything else to your SOC. Access Analyzer emits findings to EventBridge and, if enabled, Security Hub. An EventBridge rule that fires only on new active findings keeps the noise down:
{
"source": ["aws.access-analyzer"],
"detail-type": ["Access Analyzer Finding"],
"detail": {
"status": ["ACTIVE"]
}
}
Wire that rule to SNS or a Lambda that opens a ticket. The two delivery channels are not interchangeable — pick by what the consumer needs:
| Channel | Latency | Best consumer | What it carries | Note |
|---|---|---|---|---|
| EventBridge | Near-real-time (per event) | Lambda, SNS, ticketing automation | The individual finding event | Filter on status=ACTIVE to cut noise |
| Security Hub | Aggregated (control view) | Central security dashboards | Findings as ASFF, alongside other tools | Enable the integration explicitly |
The pattern that works: archive rules absorb the known-good, EventBridge escalates the genuinely new, and nobody triages the same partner-bucket finding twice. The decision table for any external-access finding that lands:
| If the finding is… | It’s probably… | Do this |
|---|---|---|
| Access to a partner account you intended | Expected external access | Archive rule on principal.AWS |
Gated by aws:PrincipalOrgID to your own org |
Not really external | Archive rule on the condition |
| A public S3/SQS/KMS grant you didn’t intend | A real exposure | Fix the resource policy; do not archive |
| A new principal you don’t recognize | Genuinely new, unreviewed | Let EventBridge escalate to the SOC |
Flagged with an error |
Something the analyzer couldn’t fully resolve | Investigate manually; never blanket-archive |
3. Surface dormant roles and unused permissions at scale
The unused-access analyzer is where the real cleanup lives. Pull its findings the same way, filtered to the unused types:
UNUSED_ARN=$(aws accessanalyzer list-analyzers \
--query "analyzers[?name=='org-unused-access'].arn" --output text)
aws accessanalyzer list-findings-v2 \
--analyzer-arn "$UNUSED_ARN" \
--filter '{"status":{"eq":["ACTIVE"]},
"findingType":{"eq":["UnusedIAMRole","UnusedPermission","UnusedIAMUserAccessKey"]}}' \
--max-results 50
Three buckets come back and each has a different remediation, a different confirmation step, and a different blast radius if you get it wrong:
| Finding type | What it is | Confirm before acting | Remediation | Risk if wrong |
|---|---|---|---|---|
UnusedIAMRole |
A role nobody assumed in your window | aws iam get-role → RoleLastUsed |
Delete the role | Break a rare/seasonal consumer |
UnusedIAMUserAccessKey |
A stale long-lived key | aws iam get-access-key-last-used |
Deactivate, then delete | Break a forgotten integration |
UnusedIAMUserPassword |
An idle console password | Last-console-login in the cred report | Disable console access | Lock out a rarely-used human |
UnusedPermission |
A used role with untouched actions | get-finding-v2 for the action list |
Trim the policy to used actions | Over-trim → access-denied at runtime |
UnusedPermission is the most common type at scale and your richest right-sizing signal — a role that is used, but carries actions or whole services it has never touched. For an UnusedPermission finding, get-finding-v2 returns the specific unused services and actions so you can trim the policy precisely:
aws accessanalyzer get-finding-v2 \
--analyzer-arn "$UNUSED_ARN" \
--id "ab-5678efgh-..." \
--query 'findingDetails'
The exact IAM calls that corroborate a finding before you act on it — never delete on the finding alone:
| To confirm… | Run | Look for | Safe to act when… |
|---|---|---|---|
| A role is truly dormant | aws iam get-role --role-name R --query Role.RoleLastUsed |
Empty or a very old LastUsedDate |
No use within (and beyond) your window |
| A key is truly stale | aws iam get-access-key-last-used --access-key-id AK... |
LastUsedDate absent or old |
Deactivate first; delete after a soak |
| A console password is idle | aws iam generate-credential-report then get-credential-report |
Old password_last_used |
Disable console access |
| Which actions are unused | aws accessanalyzer get-finding-v2 --id ... |
The unused services/actions list | You’ve removed exactly those |
| Nothing still assumes a role | CloudTrail LookupEvents for AssumeRole |
No recent events | After the soak period passes |
At fleet scale you do not click through these. Export findings on a schedule, join them against your IaC, and open one PR per owning team that strips the unused actions. The remediation order matters — confirm-before-delete is the rule that keeps you from turning a cleanup into an incident:
| Step | UnusedIAMRole | UnusedPermission | UnusedIAMUserAccessKey |
|---|---|---|---|
| 1. Confirm | RoleLastUsed is empty/old |
get-finding-v2 lists the unused actions |
get-access-key-last-used is old/never |
| 2. Stage | Detach from anything referencing it | Draft the trimmed policy in a PR | Deactivate the key (reversible) |
| 3. Soak | Watch for assume attempts | Watch for new access-denied |
Watch dependent jobs for failures |
| 4. Remove | Delete the role | Apply the trimmed policy | Delete the key |
| 5. Prove | Finding moves to RESOLVED |
UnusedPermission count drops |
Finding moves to RESOLVED |
The measurable outcome is the count of UnusedPermission findings trending toward zero release over release — that is your privilege-reduction KPI, a number you can put on a slide. Pull the aggregate without paging through individual findings:
aws accessanalyzer get-findings-statistics --analyzer-arn "$UNUSED_ARN"
4. Generate a least-privilege policy from CloudTrail activity
When you are building a policy rather than trimming one, do not guess the actions. Have Access Analyzer read the role’s CloudTrail history and write the policy for you. You need a CloudTrail trail (the service reads from it) and a service role it can assume to do so:
aws accessanalyzer start-policy-generation \
--policy-generation-details '{"principalArn":"arn:aws:iam::111111111111:role/data-pipeline"}' \
--cloud-trail-details '{
"trails": [
{ "cloudTrailArn":"arn:aws:cloudtrail:us-east-1:111111111111:trail/org-trail",
"regions":["us-east-1","eu-west-1"] }
],
"accessRole":"arn:aws:iam::111111111111:role/AccessAnalyzerCloudTrailReader",
"startTime":"2026-03-01T00:00:00Z",
"endTime":"2026-06-01T00:00:00Z"
}'
The call returns a jobId. Generation is asynchronous; poll it, then fetch the result once it succeeds:
JOB=ab-1111-2222-... # jobId from the start call
aws accessanalyzer get-generated-policy --job-id "$JOB" \
--query 'jobDetails.status' # IN_PROGRESS | SUCCEEDED | FAILED | CANCELED
aws accessanalyzer get-generated-policy --job-id "$JOB" \
--query 'generatedPolicyResult.generatedPolicies[0].policy' \
--output text > data-pipeline-generated.json
The inputs to a generation job, what each does, and the gotcha that makes jobs fail or undershoot:
| Field | What it does | Required | Gotcha |
|---|---|---|---|
principalArn |
The role/user to profile | Yes | Must be in the same account as the call |
trails[].cloudTrailArn |
The trail to read history from | Yes | A trail must already exist and cover the window |
trails[].regions |
Which regions’ events to include | Yes | Miss a region → miss that region’s actions |
accessRole |
Role Access Analyzer assumes to read the trail | Yes | Needs cloudtrail/s3 read on the trail bucket |
startTime / endTime |
The activity window | Yes | Window > activity period → still misses rare jobs |
A generation job ends in exactly one of four states; here is what each means and your next move:
jobDetails.status |
Meaning | Next move |
|---|---|---|
IN_PROGRESS |
Still reading the trail | Poll again; large windows take minutes |
SUCCEEDED |
Policy is ready | get-generated-policy → save the JSON |
FAILED |
Couldn’t complete | Check jobError; usually the access role or trail |
CANCELED |
You (or the service) canceled it | Re-submit with corrected inputs |
Treat the output as a starting point, not a finished artifact. Access Analyzer can only see actions that appear in CloudTrail, so a three-month window misses anything that runs less often — and it omits actions CloudTrail does not log at all. The known blind spots, and what to do about each:
| Blind spot | Why it happens | Mitigation |
|---|---|---|
| Rare / seasonal actions | Outside the trail window | Use a longer window; reconcile by hand |
| Actions CloudTrail doesn’t log | Some data-plane calls aren’t recorded | Add them manually from known requirements |
| Resource-level ARNs | Generation favors broad resources | Tighten Resource after generation |
Conditions / aws:PrincipalOrgID |
Not inferred from activity | Add conditions yourself |
| First run after a change | New code paths not yet exercised | Generate after a representative period |
Use the generated policy to replace a wildcard, then watch for new UnusedPermission findings (which tell you it is still too broad) and for access-denied errors in the workload (which tell you it is too narrow). The generated document plus the unused-access feedback loop converges far faster than authoring by hand — generation gives you the floor, the unused analyzer proves the trim, and access-denied tells you when you cut too deep.
5. Validate policies in CI/CD and fail on security warnings
validate-policy is stateless — no analyzer, no findings store — so it belongs directly in your pipeline. It returns four findingType values; the gate you want is: fail the build on any ERROR or SECURITY_WARNING, surface the rest as annotations:
aws accessanalyzer validate-policy \
--policy-type IDENTITY_POLICY \
--policy-document file://policy.json \
--query "findings[?findingType=='ERROR' || findingType=='SECURITY_WARNING']" \
--output json
The four finding types, what each means, and exactly how to treat it in a pipeline:
findingType |
Meaning | Example | CI action |
|---|---|---|---|
ERROR |
The policy is malformed / won’t work | Invalid action name, bad JSON, wrong key | Fail the build |
SECURITY_WARNING |
Valid but dangerous | Privilege-escalation PassRole, effectively-public resource |
Fail the build |
WARNING |
Likely a mistake, not dangerous | Deprecated global condition key | Annotate; review |
SUGGESTION |
Style / tightening hint | Empty statement, redundant action | Annotate; optional |
SECURITY_WARNING is the one that earns its keep: it flags things like a policy that lets a principal escalate its own privileges (PassRole paired with a service that can assume any role), or a resource policy that is effectively public. Set --policy-type correctly, because the rule set differs per type:
--policy-type |
Validates a… | Typical source | Rule-set focus |
|---|---|---|---|
IDENTITY_POLICY |
Identity (user/role/group) policy | aws_iam_role_policy, managed policies |
Action validity, priv-esc, wildcards |
RESOURCE_POLICY |
Resource policy | S3 bucket, KMS key, SQS policy | Public access, cross-account principals |
SERVICE_CONTROL_POLICY |
An SCP | Organizations SCP | SCP-specific syntax + effect rules |
RESOURCE_CONTROL_POLICY |
An RCP (data perimeter) | Organizations RCP | RCP-specific syntax + effect rules |
A GitHub Actions step that blocks the merge:
- name: Validate IAM policies
run: |
fail=0
for f in $(git diff --name-only origin/main... | grep 'policies/.*\.json$'); do
echo "::group::validate $f"
findings=$(aws accessanalyzer validate-policy \
--policy-type IDENTITY_POLICY \
--policy-document "file://$f" \
--query "findings[?findingType=='ERROR' || findingType=='SECURITY_WARNING']" \
--output json)
echo "$findings"
[ "$(echo "$findings" | jq 'length')" -gt 0 ] && fail=1
echo "::endgroup::"
done
exit $fail
This catches the malformed and the dangerous before either reaches an account. It does not, on its own, stop a valid policy that simply grants too much — that is the next section.
6. Custom policy checks as automated guardrails
Two stateless APIs let you encode organizational intent as a build gate. Both return a plain PASS or FAIL.
check-no-new-access compares a proposed policy against the current one and fails if the change grants any access that did not exist before. You express your guardrail as a reference policy describing the access you are watching for. This is the check for “trust policies must not widen” and “nobody adds a new wildcard.” You supply the existing document, the new document, and a reference of access deltas you refuse to allow:
aws accessanalyzer check-no-new-access \
--policy-type IDENTITY_POLICY \
--existing-policy-document file://existing.json \
--new-policy-document file://proposed.json \
--query '{Result:result, Reasons:reasons}'
# Result: FAIL -> the proposed policy grants access the existing one did not
check-access-not-granted is an absolute assertion, independent of any “before” state: it fails if the policy grants a specific action or resource you have declared off-limits. This is how you enforce “no policy in this repo may ever grant iam:CreateAccessKey” or “nothing may delete the audit bucket.” You pass the access list inline:
aws accessanalyzer check-access-not-granted \
--policy-type IDENTITY_POLICY \
--policy-document file://proposed.json \
--access '[{"actions":["iam:CreateAccessKey","iam:DeleteRolePermissionsBoundary","organizations:LeaveOrganization"]}]' \
--query '{Result:result, Message:message}'
# Result: PASS -> none of the forbidden actions are granted
You can scope --access to actions, resources, or both: actions-only checks whether the policy permits any of those actions on any resource; adding resources narrows it to those ARNs. The three checks side by side — relative versus absolute is the distinction that decides which one you reach for:
| Check | Question | Needs a “before” doc? | Guardrail type | Typical use |
|---|---|---|---|---|
check-no-new-access |
“Does the change grant access the old one didn’t?” | Yes (existing + new) | Relative | Trust-policy widening, no new wildcard |
check-access-not-granted |
“Does it grant any forbidden action/resource?” | No | Absolute | Priv-esc set, crown-jewel resources |
check-no-public-access |
“Would this resource policy be public?” | No | Absolute (resource) | S3/SQS/KMS/Secrets resource policies |
The output schema is small but you must parse it correctly to wire a reliable gate:
| Field | Type | Meaning | How CI uses it |
|---|---|---|---|
result |
PASS / FAIL |
The verdict | FAIL → exit non-zero |
reasons[] |
List | Which statements caused a FAIL (no-new-access) |
Surface in the PR comment |
message |
String | Human-readable summary | Log it for the reviewer |
check-no-public-access evaluates a resource policy for an S3 bucket, SQS queue, KMS key, Secrets Manager secret and similar types, and fails if it would be public — drop it into the same pipeline for any resource-policy change. The resource types it understands:
| Resource type | --resource-type value |
Common public-exposure trap |
|---|---|---|
| S3 bucket | AWS::S3::Bucket |
Principal:"*" without an org/account condition |
| S3 access point | AWS::S3::AccessPoint |
Inherited broad bucket policy |
| SQS queue | AWS::SQS::Queue |
Cross-account send left wide open |
| KMS key | AWS::KMS::Key |
Key policy granting kms:* to * |
| Secrets Manager secret | AWS::SecretsManager::Secret |
Resource policy without a principal condition |
| SNS topic | AWS::SNS::Topic |
Public subscribe/publish |
Maintain one shared list of forbidden actions (the IAM privilege-escalation set, plus your own crown-jewel resources) and run check-access-not-granted against every policy in every pipeline. check-no-new-access is the relative guardrail; check-access-not-granted is the absolute one. Together they are a far stronger gate than validate-policy alone, because they reason about what the policy grants, not just whether it is well-formed. A starter forbidden-actions set, with why each belongs on the list:
| Forbidden action(s) | Why it’s dangerous | Category |
|---|---|---|
iam:*, iam:CreateAccessKey |
Mint credentials / self-escalate | Privilege escalation |
iam:PassRole (unscoped) |
Hand a service a more-powerful role | Privilege escalation |
iam:PutRolePolicy, iam:AttachRolePolicy |
Grant yourself more access | Privilege escalation |
iam:UpdateAssumeRolePolicy |
Widen who can assume a role | Privilege escalation |
iam:DeleteRolePermissionsBoundary |
Remove the inescapable ceiling | Boundary bypass |
organizations:LeaveOrganization, organizations:* |
Escape org-level SCP control | Org integrity |
s3:DeleteBucket on the audit bucket |
Destroy the evidence trail | Crown-jewel protection |
kms:ScheduleKeyDeletion on a CMK |
Make encrypted data unrecoverable | Crown-jewel protection |
7. Embed the checks in Terraform/CloudFormation pipelines
The leverage is running these against the policy your IaC is about to apply, before apply. With Terraform, render the plan to JSON, extract the policy documents, and check each one. A pre-apply gate:
terraform plan -out=tf.plan
terraform show -json tf.plan > plan.json
# Pull every IAM policy document the plan will create or update.
jq -r '
.resource_changes[]
| select(.type=="aws_iam_policy" or .type=="aws_iam_role_policy")
| select(.change.actions | index("create") or index("update"))
| .change.after.policy
' plan.json > /tmp/policies.ndjson
while IFS= read -r doc; do
echo "$doc" > /tmp/p.json
res=$(aws accessanalyzer check-access-not-granted \
--policy-type IDENTITY_POLICY \
--policy-document file:///tmp/p.json \
--access "$(cat forbidden-actions.json)" \
--query 'result' --output text)
echo "check-access-not-granted => $res"
[ "$res" = "FAIL" ] && exit 1
done < /tmp/policies.ndjson
For CloudFormation, run the same checks against the rendered template’s policy resources in the change-set stage, before execute-change-set. The principle holds across both — the check runs on the exact document about to be deployed, and a FAIL stops the deploy. Where each gate slots into each pipeline:
| Pipeline stage | Terraform | CloudFormation | Gate that runs |
|---|---|---|---|
| Author | local edit | local edit | validate-policy on saved .json |
| Plan / change-set | terraform plan -out → show -json |
create-change-set → get-template |
check-access-not-granted, check-no-new-access |
| Resource-policy diff | aws_s3_bucket_policy etc. in plan |
resource-policy resources in template | check-no-public-access |
| Apply / execute | terraform apply |
execute-change-set |
(blocked if any check FAILed) |
| Post-apply | drift detection | drift detection | analyzers re-scan, findings update |
Keep forbidden-actions.json in a shared repo so every pipeline enforces one definition of “never.” Choosing where to enforce is itself a decision — each control point has a different reach and failure mode:
| Enforcement point | Reach | Catches | Misses |
|---|---|---|---|
validate-policy in CI |
One repo’s policies | Malformed + dangerous patterns | Valid-but-too-broad |
check-* in CI |
One repo’s policies | New / forbidden / public access | Out-of-band console changes |
| SCP / RCP at the org | Every principal in the OU | Anything the SCP denies, always | Things you didn’t think to deny |
| Permission boundary | Every role a team creates | Unbounded role creation | Resource policies |
| Unused analyzer (detective) | The whole org, continuously | Drift after the fact | Doesn’t block, only reports |
Finally, close the loop on measurement. Snapshot the unused-access findings count on a schedule and chart it; get-findings-statistics gives you the aggregates without paging through individual findings. A downward trend in UnusedPermission and UnusedIAMRole counts is the proof that the policy-generation and right-sizing loop is working — and it is the metric to report up, not the raw finding list.
The full operational surface in one place — every command you actually run, in roughly the order you’d run them, with what it’s for and where it executes:
| Command | What it does | Where you run it | Notes |
|---|---|---|---|
organizations register-delegated-administrator |
Delegate Access Analyzer to the audit account | Management account, once | Service principal access-analyzer.amazonaws.com |
accessanalyzer create-analyzer |
Create an external/unused/internal analyzer | Delegated admin account | One per --type |
accessanalyzer list-analyzers |
List analyzers + status/ARN | Delegated admin account | Capture the ARN for finding calls |
accessanalyzer list-findings-v2 |
List findings with a --filter |
Delegated admin account | The v2 API; understands all six types |
accessanalyzer get-finding-v2 |
Detail one finding (unused actions) | Delegated admin account | Drives precise UnusedPermission trims |
accessanalyzer create-archive-rule |
Auto-archive matching findings | Delegated admin account | Absorbs known-good external access |
accessanalyzer update-findings |
Archive/unarchive specific finding IDs | Delegated admin account | One-off triage |
accessanalyzer start-policy-generation |
Async: CloudTrail → policy | Same account as the principal | Returns a jobId |
accessanalyzer get-generated-policy |
Poll status / fetch the policy | Same account | Status + generatedPolicies[0].policy |
accessanalyzer validate-policy |
Lint a policy document | Any account / CI | Free, stateless |
accessanalyzer check-no-new-access |
Block access a new policy adds | Any account / CI | Relative guardrail |
accessanalyzer check-access-not-granted |
Block forbidden actions/resources | Any account / CI | Absolute guardrail |
accessanalyzer check-no-public-access |
Block a public resource policy | Any account / CI | Resource policies only |
accessanalyzer get-findings-statistics |
Aggregate finding counts | Delegated admin account | Source of the KPI |
Architecture at a glance
The diagram traces least privilege as a closed loop, left to right, the way the system actually runs. On the far left sits the activity source: an organization CloudTrail trail in the delegated-admin (audit) account, account 222222222222, capturing every API call across the org. That feeds two things in parallel. It feeds the stateful analyzers — the free ORGANIZATION external-access analyzer, the billed-per-entity ORGANIZATION_UNUSED_ACCESS analyzer (age 90 days), and the paid internal-access analyzer — which continuously emit findings. And it feeds the stateless generate-and-check zone, where start-policy-generation reads the trail and writes a draft policy, validate-policy lints it, and check-access-not-granted/check-no-new-access assert the guardrails. Those checks are the gate inside the CI/CD zone: a Terraform plan is rendered to JSON, every policy document is checked, and a FAIL blocks the merge before apply. On the right, the sink and score zone routes only ACTIVE findings through EventBridge to Security Hub and a SOC ticket, and get-findings-statistics charts the UnusedPermission count toward zero — the KPI that proves the whole loop is working.
Follow the two dashed feedback arrows, because they are the point. The CI/CD gate sends a trimmed role back into the analyzers (the generated, checked policy replaces a wildcard, and the unused analyzer confirms the trim). And the findings sink sends reconcile signals back to generation (a new UnusedPermission says “still too broad,” an access-denied says “too narrow”). Five numbered badges mark where a control bites or a project stalls: the unused analyzer’s billing-and-noise lever (badge 1), generation undershooting its window (badge 2), a well-formed-but-too-broad policy that only check-access-not-granted catches (badge 3), a gate that silently returns PASS (badge 4), and the statistics call that turns invisible progress into a number (badge 5). Read the legend as symptom · how to confirm · fix for each.
Real-world scenario
Meridian Pay, a fintech platform team, ran a 60-account AWS organization with a recurring, embarrassing audit finding: dozens of CI/CD deployment roles carried Action: "*" or broad service wildcards “to unblock pipelines,” and the count only grew quarter over quarter. Hand-trimming was hopeless — every trim risked breaking a deploy, so nobody trimmed, and the SOC 2 Type II auditor had flagged “excessive IAM permissions” two cycles running. The team was four platform engineers; the deadline was eleven weeks out.
The constraint was hard and explicit: they could not break a single production pipeline, and they had to demonstrate improvement, not assert it. They started by standing up the two org analyzers in their audit account (222222222222) via the delegated-admin pattern, with the unused analyzer set to unusedAccessAge: 90 and exclusions for their three sandbox accounts and every break-glass:true-tagged role — which immediately cut the billed entity count by about a third and silenced the on-call-role noise. Day one, the ORGANIZATION_UNUSED_ACCESS analyzer reported 312 UnusedPermission findings across the deployment roles.
The fix combined the generation and check halves. For each deployment role they ran start-policy-generation over a 90-day CloudTrail window to get a real action list, then replaced the wildcards with the generated documents in a staging account first, and let the unused analyzer confirm the trim by watching UnusedPermission findings drop on those roles. Generation undershot exactly where the docs warn it would — three roles had quarter-end reporting jobs that fell outside the window — so they reconciled those by hand from the runbooks, guided by access-denied errors in staging. Then they added one non-negotiable gate to every IaC pipeline so the wildcards could never come back:
# Pipeline guardrail: deployment-role policies may never grant a bare service wildcard
# on IAM or Organizations, and may never grant privilege-escalation actions.
aws accessanalyzer check-access-not-granted \
--policy-type IDENTITY_POLICY \
--policy-document file://rendered-role-policy.json \
--access '[
{"actions":["iam:*"]},
{"actions":["organizations:*"]},
{"actions":["iam:CreateAccessKey","iam:PassRole","iam:PutRolePolicy",
"iam:AttachRolePolicy","iam:UpdateAssumeRolePolicy"]}
]' \
--query 'result' --output text # FAIL blocks the merge
The one mistake that cost them a day: the first version of the gate logged the result but never checked the exit code, so a PR adding iam:PassRole passed. They caught it only because a teammate ran the negative test from the runbook — feed a known-bad policy, expect FAIL — and got PASS. After fixing the script to exit 1 on FAIL, they made that negative test a required step in the pipeline itself, so a mis-wired gate can never ship again.
Over the quarter the UnusedPermission finding count fell from 312 to 41 — roughly 87% — the audit finding closed with the get-findings-statistics trend chart as evidence, and the part the team cared about: not one pipeline broke, because every trim was backed by observed activity and every regression was blocked at the PR. The generation API gave them a safe starting policy; the check API made the improvement permanent; and the negative test made the gate trustworthy.
The program as a timeline, because the order of moves is the lesson:
| Week | State | Action | Effect |
|---|---|---|---|
| 0 | 312 UnusedPermission, audit open |
Stand up org analyzers, set exclusions | Billed entities −⅓; noise gone; baseline captured |
| 1–2 | Baseline known | start-policy-generation per role (90d window) |
Draft least-privilege policies in hand |
| 3–5 | Drafts ready | Apply in staging; watch unused findings drop | Trims proven safe; 3 roles need hand-reconcile |
| 5 | Gate added | check-access-not-granted in every pipeline |
Wildcards blocked — but gate mis-wired |
| 6 | Gate “passing” everything | Negative test from runbook returns PASS |
Bug caught; add exit 1; make test mandatory |
| 7–10 | Rollout to prod | Promote trimmed policies; chart the KPI | Count 312 → 41 (−87%) |
| 11 | Audit review | Present get-findings-statistics trend |
Finding closed; zero pipelines broken |
Advantages and disadvantages
Access Analyzer turns least privilege from an assertion into a measured, gated process — but it has real edges you must design around. Weigh it honestly:
| Advantages | Disadvantages |
|---|---|
| Generation reads actual CloudTrail activity, so trims are evidence-backed, not guesses | Generation only sees the trail window — rare/seasonal actions are missed and must be reconciled by hand |
| The unused analyzer gives a quantitative privilege-reduction KPI you can chart and report | Unused analysis is billed per analyzed role/user/month — blanket-enabling the org is needlessly expensive |
Stateless check-* APIs gate a PR before anything reaches an account |
A check that isn’t exit-coded correctly silently passes — a guardrail that never fails isn’t one |
validate-policy catches malformed and dangerous (priv-esc) policies in CI, free |
validate-policy alone won’t stop a valid policy that simply grants too much |
| Org analyzers auto-cover current and future member accounts — no enumeration | Findings are a fact, not a verdict; expected external access still needs manual archiving |
| External-access detection is free and continuous — leave it on forever | Internal-access analysis is a paid tier; scope it to crown jewels, not everything |
| Archive rules + EventBridge mean a finding is triaged once, then automated | CloudTrail doesn’t log every action, so a generated policy can be subtly too narrow |
The service is right for any org that has to retrofit least privilege onto a live estate and prove it — the generation/feedback loop converges far faster than hand-authoring, and the checks make the gains permanent. It bites hardest when teams blanket-enable unused analysis without exclusions (a cost surprise), trust a generated policy as final (subtly too narrow), or wire a check whose failure path never exits non-zero (a guardrail that never fails). Every disadvantage is manageable — with deliberate scoping, hand reconciliation, and a mandatory negative test — which is the entire point of running this as a program rather than a one-time scan.
Hands-on lab
Prove the stateless half end to end — validate-policy, check-access-not-granted, check-no-new-access — with nothing but the CLI and local JSON files. Every call here is free and needs no analyzer, so there is nothing to tear down beyond a temp directory. Run in any account where you can call accessanalyzer.
Step 1 — Set up a workspace and a deliberately dangerous policy.
mkdir -p /tmp/aa-lab && cd /tmp/aa-lab
cat > bad.json <<'JSON'
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":"iam:CreateAccessKey","Resource":"*"}]}
JSON
cat > star.json <<'JSON'
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":"*","Resource":"*"}]}
JSON
Step 2 — validate-policy flags the star policy. Expect at least one SECURITY_WARNING or WARNING:
aws accessanalyzer validate-policy \
--policy-type IDENTITY_POLICY --policy-document file://star.json \
--query "findings[].findingType" --output text
# Expected: SECURITY_WARNING (and possibly WARNING) — never empty
Step 3 — check-access-not-granted FAILS on the forbidden action (the negative test). This is the single most important assertion in the whole build:
aws accessanalyzer check-access-not-granted \
--policy-type IDENTITY_POLICY --policy-document file://bad.json \
--access '[{"actions":["iam:CreateAccessKey"]}]' \
--query 'result' --output text
# Expected: FAIL (if this returns PASS, your gate is not wired correctly)
Step 4 — Prove the gate also PASSES a clean policy. A guardrail that fails everything is as useless as one that fails nothing:
cat > good.json <<'JSON'
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:GetObject"],"Resource":"arn:aws:s3:::reports/*"}]}
JSON
aws accessanalyzer check-access-not-granted \
--policy-type IDENTITY_POLICY --policy-document file://good.json \
--access '[{"actions":["iam:CreateAccessKey"]}]' \
--query 'result' --output text
# Expected: PASS
Step 5 — check-no-new-access catches a widening. Compare a tight existing policy against a proposed one that adds an action:
cat > existing.json <<'JSON'
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:GetObject"],"Resource":"arn:aws:s3:::reports/*"}]}
JSON
cat > proposed.json <<'JSON'
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:GetObject","s3:DeleteObject"],"Resource":"arn:aws:s3:::reports/*"}]}
JSON
aws accessanalyzer check-no-new-access \
--policy-type IDENTITY_POLICY \
--existing-policy-document file://existing.json \
--new-policy-document file://proposed.json \
--query '{Result:result, Reasons:reasons}'
# Expected: Result FAIL — proposed grants s3:DeleteObject that existing did not
Validation checklist. You linted a dangerous policy (validate-policy), proved the absolute gate fails on a forbidden action and passes on a clean one (check-access-not-granted), and proved the relative gate catches a widening (check-no-new-access) — the exact three calls that make a CI guardrail. Each step mapped to what it proves:
| Step | Call | Expected | What it proves |
|---|---|---|---|
| 2 | validate-policy on star.json |
SECURITY_WARNING |
Lint catches the dangerous-but-valid |
| 3 | check-access-not-granted on bad.json |
FAIL |
The negative test: gate actually blocks |
| 4 | check-access-not-granted on good.json |
PASS |
The gate doesn’t block everything |
| 5 | check-no-new-access widening |
FAIL |
Relative guardrail catches a delta |
Cleanup.
rm -rf /tmp/aa-lab
Cost note. Every call in this lab is a free, stateless API — total cost is ₹0. To exercise the stateful analyzers, create an ACCOUNT external analyzer (also free) in a sandbox; only the unused and internal analyzers carry charges, which is why they stay out of a free-tier lab.
Common mistakes & troubleshooting
This is the playbook — the part you bookmark. First as a scannable table, then the entries that bite hardest expanded with the exact confirm command.
| # | Symptom | Root cause | Confirm (exact command) | Fix |
|---|---|---|---|---|
| 1 | The CI gate PASSes a policy that grants iam:* |
Script logs result but never checks it / no exit 1 |
Run the negative test: bad policy → expect FAIL, got PASS |
[ "$res" = "FAIL" ] && exit 1; make the negative test mandatory |
| 2 | Surprise monthly Access Analyzer bill | Unused analyzer enabled org-wide with no exclusions |
aws accessanalyzer list-analyzers shows *_UNUSED_ACCESS; check entity count |
Add exclusions for sandboxes + break-glass; or scope to key accounts |
| 3 | Generated policy causes AccessDenied in prod |
Generation window missed a rare/seasonal action | App logs show AccessDenied; action not in data-pipeline-generated.json |
Add the action by hand; widen the window; reconcile from runbooks |
| 4 | start-policy-generation job ends FAILED |
accessRole can’t read the trail bucket, or wrong trail ARN |
get-generated-policy --job-id → read jobError |
Grant the access role s3/cloudtrail read; fix the trail ARN |
| 5 | Same partner-bucket finding pages the SOC weekly | No archive rule; EventBridge fires on all findings | list-findings-v2 shows it ACTIVE repeatedly |
Archive rule on principal.AWS; filter EventBridge to status=ACTIVE |
| 6 | create-analyzer returns ValidationException on --type |
Typo or invented type (e.g. UNUSED, EXTERNAL) |
Compare against the six legal --type values |
Use exactly ORGANIZATION_UNUSED_ACCESS etc. |
| 7 | Org analyzer created but sees no member accounts | Created in a non-delegated account, or delegation missing | aws organizations list-delegated-administrators |
register-delegated-administrator; create in that account |
| 8 | validate-policy returns nothing for an SCP |
Wrong --policy-type (used IDENTITY_POLICY) |
Re-run with --policy-type SERVICE_CONTROL_POLICY |
Match the policy type to the document |
| 9 | check-no-new-access always FAILs on first apply |
No existing policy, so everything is “new” | The role/policy doesn’t exist yet in the plan | Use check-access-not-granted for net-new; reserve no-new-access for edits |
| 10 | Unused analyzer flags a break-glass role you need | The role isn’t tagged/excluded | list-findings-v2 shows the role; tag is absent |
Tag it break-glass:true and add to exclusions |
| 11 | check-no-public-access errors on an identity policy |
It only evaluates resource policies | You passed an identity policy / wrong resource type | Use it only on S3/SQS/KMS/Secrets resource policies |
| 12 | UnusedPermission count won’t drop after trimming |
Trimmed in code but never applied, or wrong account | get-findings-statistics flat; diff applied vs repo |
Apply the change; confirm you trimmed the live role |
The expanded form for the entries that cost the most time:
1. The CI gate passes a policy that obviously grants too much.
Root cause: The pipeline step calls check-access-not-granted but only logs the result — it never inspects the value or exits non-zero on FAIL, so the dangerous policy merges.
Confirm: Run the negative test from your runbook — feed a policy granting iam:CreateAccessKey and assert the script exits non-zero. If it exits 0, the gate is decorative.
Fix: res=$(... --query 'result' --output text); [ "$res" = "FAIL" ] && exit 1. Then make that negative test a required pipeline step so a mis-wired gate can never ship.
2. An unexpected Access Analyzer line item on the bill.
Root cause: ORGANIZATION_UNUSED_ACCESS (or the account variant) is billed per analyzed IAM role and user per month; enabling it across a 60-account org with thousands of roles and no exclusions analyzes — and bills for — every one.
Confirm: aws accessanalyzer list-analyzers shows a *_UNUSED_ACCESS type; the bill scales with org role/user count.
Fix: Add exclusions for sandbox accountIds and break-glass/service-linked roles via resourceTags; excluded entities are not billed. If you only need a few sensitive accounts, use ACCOUNT_UNUSED_ACCESS there instead of org-wide.
3. A generated policy is too narrow and breaks the workload.
Root cause: start-policy-generation only sees actions present in the trail window; a job that runs monthly or quarterly falls outside a 90-day window and is omitted, plus some actions CloudTrail never logs.
Confirm: The workload logs AccessDenied for an action not present in the generated JSON.
Fix: Add the missing action by hand, widen the generation window to cover the longest cadence, and reconcile known-rare actions from runbooks. Treat generation as a floor you build up from, watching access-denied as the signal you cut too deep.
4. The generation job fails outright.
Root cause: The accessRole lacks read on the CloudTrail S3 bucket, or the cloudTrailArn/regions are wrong.
Confirm: aws accessanalyzer get-generated-policy --job-id "$JOB" → read jobDetails.jobError.
Fix: Grant the access role cloudtrail/s3:GetObject on the trail bucket and prefix; verify the trail ARN and that its regions cover the role’s activity.
5. The SOC keeps getting paged for the same accepted finding.
Root cause: No archive rule absorbs the known-good, and the EventBridge rule fires on every finding rather than only new ACTIVE ones.
Confirm: list-findings-v2 --filter '{"status":{"eq":["ACTIVE"]}}' shows the same finding repeatedly.
Fix: Create an archive rule keyed on principal.AWS (or the condition); scope the EventBridge pattern to detail.status = ACTIVE so only genuinely new findings escalate.
Best practices
- Run analyzers from the delegated admin account, never the management account. Keep org tooling out of the root account; centralize findings in your audit account.
- Leave external-access on forever; deploy unused-access deliberately. External is free and continuous. Unused is billed per entity — set
unusedAccessAgeandexclusionsbefore you flip it on org-wide. - Use exclusions as your cost-and-signal lever. Exclude sandbox
accountIdsand break-glass/service-linked roles by tag — it cuts both the bill and the noise. - Generate, don’t guess. Bootstrap every new role’s policy from
start-policy-generationover a representative CloudTrail window, then tighten — never start from a wildcard. - Treat a generated policy as a floor, not a finished artifact. Reconcile rare actions by hand, widen on
AccessDenied, and let the unused analyzer prove the trim. - Run
validate-policyin CI and fail onERRORandSECURITY_WARNING. It is free, stateless, and catches both malformed and dangerous policies before they ship. - Layer relative and absolute checks.
check-no-new-accessgates widening;check-access-not-grantedenforces a shared forbidden-actions list;check-no-public-accessguards resource policies. Use all three. - Maintain one
forbidden-actions.jsonfor the whole org. The IAM privilege-escalation set plus your crown-jewel resources, enforced identically in every pipeline. - Run checks on the rendered IaC document, before
apply/execute-change-set. Check the exact policy about to be deployed, not the HCL/template source. - Make a negative test mandatory in every pipeline. Feed a known-bad policy and assert
FAIL— a guardrail that never fails is not a guardrail. - Archive the known-good, escalate only the new. Archive rules absorb expected external access; EventBridge (filtered to
ACTIVE) routes genuinely new findings to the SOC. - Chart the KPI. Snapshot
get-findings-statisticson a schedule and trendUnusedPermission/UnusedIAMRoledownward — that is the number you report up.
Security notes
- Least privilege for the tooling itself. The
accessRolethat generation assumes needs only read access to the CloudTrail bucket and prefix — not broads3:*. Scope it tightly; it is a role that reads your entire audit history. - Protect the audit trail and findings. The CloudTrail bucket and the analyzer findings are your evidence. Put
s3:DeleteBucketon the trail bucket andkms:ScheduleKeyDeletionon its CMK into your forbidden-actions list so no policy can destroy them. - Don’t archive what you can’t explain. Archiving a finding marks it accepted forever. Only archive external access you can justify (a named partner, an org-scoped condition); never blanket-archive findings carrying an
error. - Prefer org-scoped conditions over wildcards in resource policies. Gate cross-account grants with
aws:PrincipalOrgIDrather thanPrincipal:"*"; this both removes the external-access finding and closes the real exposure. - Treat the forbidden-actions list as a security control under change management. Adding or removing an entry changes what every pipeline blocks — review it like any other guardrail, in a PR.
- Combine the detective and preventive layers. Access Analyzer is detective (findings) and CI-preventive (checks); pair it with SCPs/RCPs and permission boundaries so the same intent is also enforced at the org and role-creation level — see Resource Control Policies & Data Perimeters.
- Guard the secret and key surfaces too. Run
check-no-public-accesson Secrets Manager and KMS resource policies, not just S3 — a public key policy is as dangerous as a public bucket. The deep mechanics live in KMS Encryption Deep Dive and Secrets Manager & Parameter Store.
The controls that both secure and operationalize this build — they pull in the same direction:
| Control | Mechanism | Secures against | Also enables |
|---|---|---|---|
Tight accessRole |
Scoped s3/cloudtrail read |
Over-broad audit-history access | Reliable, least-priv generation |
| Forbidden-actions list | check-access-not-granted |
Priv-esc + crown-jewel destruction | One org-wide definition of “never” |
| Org-scoped conditions | aws:PrincipalOrgID in resource policies |
Genuine external exposure | Clears the false external finding |
check-no-public-access |
Resource-policy gate | Public S3/SQS/KMS/Secrets | Catches exposure pre-deploy |
| SCP / RCP + boundaries | Org + role-creation ceilings | Out-of-band console widening | Defense in depth beyond CI |
| Archive-rule discipline | Justified, named rules only | Silently accepting real exposure | Clean, trustworthy finding queue |
Cost & sizing
The bill is dominated by exactly one thing, and the rest is free — which makes sizing mostly a scoping exercise:
- Unused-access analysis is the only material charge. It is billed per analyzed IAM role and user, per month. The cost scales with how many entities the analyzer watches, which is precisely why
exclusions(sandbox accounts, break-glass roles) is a cost lever, not just a noise lever. - External-access analysis is free — one
ORGANIZATIONanalyzer covers the whole org at no charge, so there is no reason not to run it permanently. - Internal-access analysis is a paid tier — scope it to crown-jewel resources (the audit bucket, production KMS keys, regulated data stores), not the whole estate.
- Every stateless API is free —
validate-policy, all threecheck-*calls, andstart-policy-generationcarry no per-call charge, so you can run them on every PR and every plan without cost anxiety. - The indirect cost is engineering time, not AWS spend: triaging findings, reconciling generated policies, and maintaining the forbidden-actions list. Budget that as program time, and the KPI (falling
UnusedPermissioncount) is what justifies it.
A rough monthly picture for a 60-account org: external analyzer ₹0; unused analyzer billed on, say, 1,500 analyzed entities after exclusions (a small per-entity rate that you confirm against current pricing for your region); internal analyzer scoped to a couple dozen crown-jewel resources; and ₹0 for all the CI checks. The cost drivers and how to control each:
| Cost driver | Billed on | Control lever | Watch-out |
|---|---|---|---|
| Unused-access analyzer | Per analyzed role/user/month | exclusions (accounts + tags); scope to key accounts |
Org-wide with no exclusions = the surprise bill |
| Internal-access analyzer | Paid tier | Limit to crown-jewel resources | Don’t enable estate-wide “just in case” |
| External-access analyzer | Free | — | None — leave it on |
validate-policy / check-* |
Free | — | None — run on every PR |
start-policy-generation |
Free | — | Time cost is reconciliation, not money |
| Engineering time | Your team | Automate triage; archive rules; KPI dashboard | The real cost; justify with the KPI |
Interview & exam questions
1. What are the three analyzer types, and which one costs money? External, unused and internal access. External answers “who outside my zone of trust can reach this?” and is free. Unused answers “what is granted but idle?” and is billed per analyzed IAM role and user per month. Internal answers “who inside my org can reach this critical resource?” and is a paid tier. There is no combined analyzer — you deploy one resource per type.
2. List the six legal analyzer --type values. ACCOUNT, ORGANIZATION, ACCOUNT_UNUSED_ACCESS, ORGANIZATION_UNUSED_ACCESS, ACCOUNT_INTERNAL_ACCESS, ORGANIZATION_INTERNAL_ACCESS. Anything else (e.g. UNUSED, EXTERNAL) returns a ValidationException. An ORGANIZATION-scoped analyzer automatically covers current and future member accounts.
3. Difference between check-no-new-access and check-access-not-granted? check-no-new-access is relative: it compares a proposed policy to an existing one and fails if the change grants any access the old policy did not — ideal for “trust policies must not widen.” check-access-not-granted is absolute: independent of any before-state, it fails if the policy grants a specific forbidden action or resource — ideal for “never grant iam:CreateAccessKey.” Use both: relative for edits, absolute for a shared forbidden-actions list.
4. Why is a generated policy only a starting point? start-policy-generation can only include actions that appear in the CloudTrail window you give it, so anything running less often than the window (quarterly jobs) is missed, and some actions CloudTrail does not log at all are omitted. The result can be subtly too narrow. You reconcile rare actions by hand, widen on AccessDenied, and let the unused analyzer confirm the trim isn’t too broad.
5. An app throws AccessDenied after you replaced its wildcard with a generated policy. What happened and how do you fix it? The generation window missed an action the app needs — a rare/seasonal call outside the window, or one CloudTrail doesn’t log. Confirm the action is absent from the generated JSON, then add it by hand and widen the window for next time. This is expected: generation is a floor you build up from, with access-denied as the signal you cut too deep.
6. How do you stop the SOC being paged for the same accepted external-access finding repeatedly? Create an archive rule keyed on the known-good principal (e.g. principal.AWS containing a partner account), which auto-archives matching findings current and future; and scope the EventBridge rule to detail.status = ACTIVE so only genuinely new findings escalate. Archive rules absorb the known-good, EventBridge escalates the new, nobody triages twice.
7. What does validate-policy catch that the check-* APIs don’t, and vice versa? validate-policy lints a single document for ERROR (malformed) and SECURITY_WARNING (valid-but-dangerous, e.g. privilege escalation) — but it won’t stop a valid policy that merely grants too much. The check-* APIs reason about what the policy grants relative to a baseline or a forbidden list, catching the too-broad-but-valid case. You want both in CI.
8. Why must you run the policy checks on the rendered IaC document rather than the source? Because the source (HCL/template) may interpolate variables, modules, or generated documents — the rendered policy is what actually reaches the account. Run terraform show -json on the plan (or get-template on the CloudFormation change-set) and check each resolved policy document before apply/execute-change-set, so the gate evaluates exactly what will be deployed.
9. Your check-access-not-granted gate let a dangerous policy merge. What’s the most likely bug and how do you prevent recurrence? The pipeline step logged result but never inspected it or exited non-zero on FAIL, so the dangerous policy passed. Fix with [ "$res" = "FAIL" ] && exit 1, then add a mandatory negative test — feed a known-bad policy and assert the step fails — so a mis-wired gate can never ship. A guardrail that never fails is not a guardrail.
10. How do you control the cost of the unused-access analyzer? It is billed per analyzed IAM role and user per month, so cost scales with the entity count it watches. Use exclusions to skip sandbox accountIds and break-glass/service-linked roles by resourceTags (excluded entities are not billed), and consider ACCOUNT_UNUSED_ACCESS on a few sensitive accounts instead of org-wide if you only need targeted coverage.
11. What is the privilege-reduction KPI and where does it come from? The count of UnusedPermission (and UnusedIAMRole) findings over time, trending toward zero. Pull it with get-findings-statistics, which aggregates without paging through individual findings; snapshot it on a schedule and chart the trend. A downward line is the auditable proof that the generation/right-sizing loop is working — the metric you report up, not the raw finding list.
12. Which --policy-type values does validate-policy accept, and why does it matter? IDENTITY_POLICY, RESOURCE_POLICY, SERVICE_CONTROL_POLICY, and RESOURCE_CONTROL_POLICY. The rule set differs per type — validating an SCP as an IDENTITY_POLICY produces wrong or empty results. Always match the type to the document you’re checking.
These map most directly to AWS Certified Security – Specialty (SCS-C02) — identity and access management, governance, and least privilege at scale — and the IAM/governance domains of Solutions Architect Professional (SAP-C02) and DevOps Engineer Professional (DOP-C02) for the CI/CD-gating angle. A compact cert mapping for revision:
| Question theme | Primary cert | Domain |
|---|---|---|
| Analyzer types, external/unused/internal | Security – Specialty | IAM & detective controls |
check-* / validate-policy in CI |
DevOps Pro / Security – Specialty | Automation; policy as code |
| Policy generation from CloudTrail | Security – Specialty | Least privilege; CloudTrail |
| Delegated admin, org analyzers | SA Pro / Security – Specialty | Multi-account governance |
| Archive rules, EventBridge routing | Security – Specialty | Findings triage & response |
| Cost of unused analysis | SA Pro | Cost-aware design |
Quick check
- You enabled
ORGANIZATION_UNUSED_ACCESSacross all 60 accounts and got a surprise bill. What is the charge based on, and what is the one lever that lowers it? - A teammate adds
iam:PassRoleto a deployment role and the PR’scheck-access-not-grantedstep still goes green. What is the most likely bug? - You replaced a wildcard with a generated policy and the app now throws
AccessDeniedon a month-end job. Why, and what’s the fix? - Which check is relative (needs a “before” document) and which is absolute (does not)?
- Name the six legal analyzer
--typevalues.
Answers
- It is billed per analyzed IAM role and user per month, so the bill scales with the number of entities the analyzer watches. The lever is
exclusionsin theanalysisRule— exclude sandboxaccountIdsand break-glass/service-linked roles byresourceTags; excluded entities are not analyzed and not billed. (Or scope toACCOUNT_UNUSED_ACCESSon a few sensitive accounts.) - The pipeline step logs the
resultbut never checks it or exits non-zero onFAIL, so the dangerous policy merges. Fix with[ "$res" = "FAIL" ] && exit 1and add a mandatory negative test (a known-bad policy that must produceFAIL). start-policy-generationonly sees actions in the CloudTrail window you gave it; a month-end job outside that window was omitted, so the generated policy is too narrow. Add the missing action by hand, widen the window to cover the longest cadence, and useAccessDeniedas the signal you cut too deep.check-no-new-accessis relative — it takes an existing and a new document and fails on any added access.check-access-not-grantedis absolute — it takes one document plus a forbidden--accesslist and fails if any is granted, with no before-state.ACCOUNT,ORGANIZATION,ACCOUNT_UNUSED_ACCESS,ORGANIZATION_UNUSED_ACCESS,ACCOUNT_INTERNAL_ACCESS,ORGANIZATION_INTERNAL_ACCESS.
Glossary
- IAM Access Analyzer — the AWS service that detects external/unused/internal access via stateful analyzers and validates/checks policies via stateless APIs.
- Analyzer — a stateful resource (one of six
--typevalues) that continuously evaluates an account or org and emits findings. - Zone of trust — your account or organization boundary; access that crosses it is “external.”
- Finding — one detected access fact (external, unused, or internal); a fact, not a verdict.
- Archive rule — a filter on an analyzer that auto-archives matching findings, current and future, that you have accepted.
unusedAccessAge— the number of idle days (1–365, default 90) before the unused analyzer flags an entity.exclusions— the unused analyzer’sanalysisRuleblock listing accounts and resource tags to skip — a cost and noise lever.- External access — a principal outside your zone of trust can reach a resource (free analysis).
- Unused access — a role, key, password, or permission granted but idle past the window (billed per entity).
- Internal access — an in-org principal can reach a monitored critical resource (paid tier).
UnusedPermission— a finding for a used role carrying actions/services it never touched; the core right-sizing signal.start-policy-generation— an async job that reads a principal’s CloudTrail history and writes the least-privilege policy for what it did.validate-policy— a stateless API that lints a policy, returningERROR,SECURITY_WARNING,WARNING, andSUGGESTIONfindings.check-no-new-access— a stateless check that fails if a proposed policy grants any access the existing one did not (relative guardrail).check-access-not-granted— a stateless check that fails if a policy grants a declared-forbidden action or resource (absolute guardrail).check-no-public-access— a stateless check that fails if a resource policy (S3/SQS/KMS/Secrets/SNS) would be public.- Delegated administrator — the account (your audit/security account) registered to run org-scoped analyzers instead of the management account.
- Forbidden-actions list — one shared document (priv-esc set + crown-jewel resources) enforced by
check-access-not-grantedin every pipeline. - Privilege-reduction KPI — the
UnusedPermission/UnusedIAMRolefinding count over time (viaget-findings-statistics), trended toward zero.
Next steps
You can now run Access Analyzer as a closed loop — detect, generate, check, enforce, and prove. Build outward:
- Next: IAM Least Privilege with Permission Boundaries — make the boundary the inescapable ceiling that the checks then prove nobody exceeds.
- Related: AWS Organizations, SCPs & Delegated Admin — the org-level enforcement layer that complements CI-level checks.
- Related: Resource Control Policies & Data Perimeters — RCPs and
aws:PrincipalOrgIDto close the external-access exposures findings surface. - Related: CloudWatch & CloudTrail Observability Deep Dive — the trail that powers policy generation and the EventBridge routing for findings.
- Related: IAM Identity Center: Permission Sets & ABAC — least privilege for human access across the same multi-account org.