Skip to content
← Writing
Technical guideSecurity / Reliability9 min

Least-privilege IAM you can test in CI

Every policy in a CDK app goes to IAM Access Analyzer on each pull request, so new access or a public bucket fails the build, for $0.014 a run.

By Rahul Ladumor
On this page

A role that can do one more thing than it needs won't break a single test. It ships, and you find out in an audit or an incident. We built a repo for IAM least privilege testing on every pull request. It sends each policy in an AWS Cloud Development Kit (CDK) app to IAM Access Analyzer and fails the build on new access.

It's for developers who write their own AWS Identity and Access Management (IAM) roles in CDK and want continuous integration (CI) to catch the wider grant a reviewer missed. You get the policy extractor, the four checks, the tests, and the gaps we measured in cdk-nag. We haven't deployed either stack, and no real Access Analyzer call has run. Every Access Analyzer PASS and FAIL in our tests is a mocked answer.

Key takeaways

  • Keep a reference policy per role in the repo. CheckNoNewAccess fails when a role can do anything its reference doesn't allow.
  • Resolve every Ref and Fn::GetAtt in the template to a placeholder Amazon Resource Name (ARN) with the right shape, and stop the run on anything you can't resolve.
  • cdk-nag flags s3:*, but it says nothing about a public principal in a bucket policy, or a new action on a path you already acknowledged. Run both tools.
  • A full run makes 12 API calls. AWS bills seven of them at $0.002 each, so a run costs $0.014.
At a glance
Level Intermediate
Time to read 9 minutes
Cost $0 for tests and the dry run. $0.014 per real run (7 billed checks) in us-east-1
Code InfraTales/iam-policy-ci
Tested with Node.js 24, CDK 2.273.0, 57 tests, 15 mutation checks (npm run mutate), cdk-nag 3.0.2
Deployed Not yet. No real Access Analyzer call has run; every verdict in the tests is mocked

Write the role by hand first

The app is small on purpose. One AWS Lambda function exports a day of orders from Amazon DynamoDB to a JSON file in Amazon Simple Storage Service (Amazon S3). CDK's grant* helpers would give it more than it calls. table.grantReadData() adds Scan, GetItem and BatchGetItem, and bucket.grantPut() adds s3:Abort* plus the tagging, legal hold and retention actions.

So we wrote out each statement in infra/lib/export-stack.ts, like this one:

this.role.addToPolicy(
  new iam.PolicyStatement({
    sid: 'QueryOrders',
    actions: ['dynamodb:Query'],
    resources: [this.table.tableArn],
  }),
);

We also skipped the default Lambda role. It attaches the AWS managed AWSLambdaBasicExecutionRole, whose contents aren't in the template, so no check could see them. A test runs the real handler, turns each SDK call into its IAM action, and checks that the role grants those actions, plus its log writes, and nothing else.

Follow a pull request through the check

One GitHub Actions job runs the whole check:

GitHub Actions sends every policy to IAM Access Analyzer via an OIDC role. A failed check fails CI.

The reference policies live in the repo next to the code. GitHub Actions assumes a role with OpenID Connect (OIDC), so no AWS keys sit in GitHub, and any answer other than PASS fails the job.

Turn the CDK template into real policies

Access Analyzer needs a plain policy document, and a synthesized template is full of Ref, Fn::GetAtt and Fn::Join. Our resolver in policy-check/resolve.ts swaps each one for a placeholder of the right shape, in a fixed example account. For example, the export bucket becomes arn:aws:s3:::exportbucket..., named after its logical ID, so a reference policy can match it with exportbucket*.

Any intrinsic it doesn't know throws, because a policy we can't read is a policy we can't check. The resolver also writes the real shape where CDK hides it. A log group's Arn attribute ends in :*, and Access Analyzer gets that wildcard.

The AWS Labs tool cfn-policy-validator does the same job in Python. It gives placeholder resources random names, so a reference policy can't name them, and that's why we wrote our own. The extractor then merges each role's policies, because a role's access is all of its policies together.

Compare roles with Access Analyzer check-no-new-access

CheckNoNewAccess compares two policies and answers PASS or FAIL. The role's merged policy goes in as the new policy, and its reference file goes in as the existing one. This is the reference for the export function:

{
  "Sid": "WriteExports",
  "Effect": "Allow",
  "Action": "s3:PutObject",
  "Resource": "arn:aws:s3:::exportbucket*/exports/*"
}

AWS says a FAIL means the updated policy "might allow new access" compared with the existing one. The check uses automated reasoning, not pattern matching. A FAIL means the new policy might allow new access, not that it does, so expect the odd FAIL that a careful reading says is fine. Change the policy or the reference; don't skip the check.

To widen a role on purpose, change the stack and its reference file in the same pull request. The reviewer sees the new access twice, and CI shows the two agree.

Block escalation and public access too

Three more calls cover what a reference can't. ValidatePolicy checks a policy against "IAM policy grammar and AWS best practices" for free. We fail the build on its ERROR and SECURITY_WARNING findings and print WARNING and SUGGESTION, the same default as the official GitHub Action.

CheckAccessNotGranted asks whether a role can call anything on a never-grant list, such as iam:PassRole or s3:PutBucketPolicy. One call takes at most 100 actions, so the script splits longer lists. CheckNoPublicAccess reads the bucket policy and both trust policies.

Our bucket policy has "Principal": {"AWS": "*"} in the Deny statement CDK adds to require Transport Layer Security (TLS). A grep for public principals would flag it. But a Deny grants nothing, so we expect CheckNoPublicAccess to pass this policy, though no real call has confirmed that yet.

Prove that s3:* and a public bucket fail

No local engine exists for Access Analyzer, so the tests mock it with aws-sdk-client-mock. They prove what we send and how we react to each answer. This test in test/policy/cli.test.ts adds s3:* to the role:

const broken = synthApp(({ exportStack }) =>
  exportStack.role.addToPolicy(
    new iam.PolicyStatement({ sid: 'Oops', actions: ['s3:*'], resources: [exportStack.bucket.arnForObjects('*')] }),
  ),
);
// ...
aa.on(CheckNoNewAccessCommand).callsFake((input: { newPolicyDocument: string }) =>
  input.newPolicyDocument.includes('"s3:*"')
    ? { result: 'FAIL', message: 'test answer: new access', reasons: [{ statementId: 'Oops', description: 'test answer' }] }
    : PASS,
);
const { code, out } = await run(broken.dir);
expect(code).toBe(1);
expect(out).toMatch(/FAIL CheckNoNewAccess IamPolicyCi-Export\/ExportFunctionRole/);

The new statement reaches CheckNoNewAccess, and a FAIL exits with code 1. That FAIL comes from our mock, not from AWS. A second test adds an Allow s3:GetObject for AnyPrincipal to the bucket policy. It checks that the policy reaches CheckNoPublicAccess as AWS::S3::Bucket and that a FAIL fails the build.

Other tests cover the edges: an answer with no result fails, an API error exits with code 2, and a role with no reference fails even in a dry run. npm run mutate breaks 15 safeguards one at a time, and CI runs it on every pull request. Its first run found a safeguard no test covered, so we added the test.

Know what cdk-nag misses

cdk-nag runs at synth with no AWS account, and it costs nothing. It checks known bad patterns, so we measured it against each change in the tests:

Change cdk-nag (AWS Solutions) Our policy check
Add s3:* on the bucket Fails, AwsSolutions-IAM5 Fails the offline action check, and reaches CheckNoNewAccess
Add a public principal to the bucket policy Passes Reaches CheckNoPublicAccess
Add s3:DeleteObject on exports/* Passes, wildcard already acknowledged Fails the offline action check, and reaches CheckNoNewAccess
Log group ARN ending in :* Passes, sees only a token Sends the real wildcard
Attach AWSLambdaBasicExecutionRole Fails, AwsSolutions-IAM4 Fails the managed policy allow list

Our first CI setup had a gap here. The access-analyzer job waited for the check job, so cdk-nag stopped the s3:* change before Access Analyzer saw it. Now the job runs on its own and synthesizes with -c nag=false. cdk-nag still fails check, and Access Analyzer still gets the policy.

The dry run also compares each role's actions with its reference file, with no AWS call. A fork without the AWS role still fails on s3:*. That check reads actions only, so resources stay with Access Analyzer. We tried the public bucket statement on a scratch copy, and it passed cdk-nag, the tests and the dry run. Access Analyzer is the only gate for that one.

The third row is the trap. We acknowledged exports/* because the function writes one object per day. After that, cdk-nag said nothing about a new action on the same path. Keep cdk-nag for the free, fast checks, such as TLS and managed policies, and let Access Analyzer judge what a role can do.

What we got wrong first: the test template

Our first template tests checked a different template from the one cdk synth writes. The test helper built a bare App() without the feature flags in cdk.json. The CI role's actions came out in a different order, and a template test failed against the real synth.

A policy test proves nothing if it runs on a template that CI doesn't deploy. The fix in test/support/synth.ts loads the same context:

// Same feature flags as `cdk synth`, so tests see the template CI deploys.
const context = (JSON.parse(readFileSync(join(__dirname, '..', '..', 'cdk.json'), 'utf8')) as { context: Record<string, unknown> }).context;
const app = new App({ outdir: mkdtempSync(join(tmpdir(), 'iam-policy-ci-')), context });

Every policy test now runs on the same template as CI.

Run the real check from GitHub Actions

The IamPolicyCi-CiRole stack creates the role GitHub assumes. Its trust policy requires aud to be sts.amazonaws.com, and sub to match main or a pull request of one repository. IAM now refuses a GitHub trust policy without a sub condition, or with one that's only a wildcard. A loose value such as repo:my-org/* still passes that check, so name the repository. Sessions last one hour at most.

The role can call the four Access Analyzer actions. Those actions have no resource types, so "Resource": "*" is the only value IAM accepts. Until you set the POLICY_CHECK_ROLE_ARN repository variable, the job does a dry run. Pull requests from forks get the dry run too, because GitHub gives them no OIDC token.

The dry run on our machine printed this summary:

Found 2 principals with identity policies and 3 resource policies in 2 stacks.
Dry run: 12 API calls planned, 7 of them billed custom checks.
6 passed, 0 failed, 7 billed custom checks.

The six local results are the reference, action and managed policy checks, three per role, which need no AWS call.

Work out the cost

Custom policy checks cost $0.002 per call in us-east-1, from the AWS Price List API, and ValidatePolicy costs nothing. One run against this app makes these calls:

API Calls per run Price Per run
ValidatePolicy 5 No charge $0
CheckNoNewAccess 2 $0.002 $0.004
CheckAccessNotGranted 2 $0.002 $0.004
CheckNoPublicAccess 3 $0.002 $0.006
Total 12 $0.014

At 80 runs a month, about 20 pull requests with three pushes each plus merges to main, that's $1.12. The price list has one entry for all custom checks, so we assume the three cost the same. The count grows by two checks per role and one per resource policy. The unused access analyzer, at $0.20 per role or user per month, is a good next step, because it tells you what to remove from a reference.

When this isn't the right fit

  • Your roles use AWS managed policies. Their contents aren't in the template, so the check fails them until you write the permissions out.
  • A role gets policies from several stacks. The tool sees one template at a time and fails the run.
  • You rely on service control policies or permission boundaries. The checks read only the policies you send.
  • You need real resource names in the verdict. Placeholders stand in for them.

Try it yourself

You need Node.js 24. These commands need no AWS account:

npm ci
npm run check    # types, lint, 56 tests, synth with cdk-nag, dry run

To see real verdicts, run npx cdk bootstrap in an AWS account you control, then follow these steps:

  1. Deploy the CI role with npx cdk deploy IamPolicyCi-CiRole -c githubRepo=<owner>/<name>.
  2. Copy the role ARN from the stack and run gh variable set POLICY_CHECK_ROLE_ARN --body <arn>.
  3. Open a pull request that adds a public statement to the bucket policy. The check job passes it, so the access-analyzer job should print FAIL CheckNoPublicAccess.
  4. For s3:*, add the statement on your machine and run npm run synth, which writes the templates even when cdk-nag fails. Then run npm run policy-check with credentials allowed to call the four actions.

The workflow runs on pull requests and pushes to main, and the role trusts only those, so a plain branch push won't reach the check. We haven't done any of this yet, so tell us what you see.

Clean up

Run npx cdk destroy IamPolicyCi-CiRole, then remove the variable with gh variable delete POLICY_CHECK_ROLE_ARN. Did you deploy the export app as well? Then npm run destroy removes both stacks. CloudFormation keeps the export bucket, so empty and delete it in the S3 console.

Tell us about the widest grant you've seen in a role that passed review. Would a reference policy have caught it?

Sources

Pages were read on 2026-10-28.

AWS pricing pages

  • AWS IAM Access Analyzer pricing: custom policy checks at $0.002 per API call using automated reasoning, policy validation at no additional charge, unused access at $0.20 per IAM role or user per month

AWS documentation

Tools and code

This article first appeared on AWS Builder Center. The code is on GitHub at InfraTales/iam-policy-ci.

Next step

Have the same problem on your stack?

Send the architecture, AWS bill concern, deploy pain, or GenAI reliability issue. I'll find the first real bottleneck and propose a small, reversible fix.

FAQ

How do you test least-privilege IAM in CI?

Keep a reference policy per role in the repo. CI sends each role's merged policy to IAM Access Analyzer's CheckNoNewAccess, which fails when the role can do anything its reference doesn't allow. A failed check fails the build.

How much does an Access Analyzer check cost per CI run?

A full run makes 12 API calls. AWS bills seven of them at $0.002 each, so a run costs $0.014 in us-east-1. The tests and the dry run cost nothing.

Isn't cdk-nag enough to catch over-broad IAM?

No. cdk-nag flags s3:*, but it says nothing about a public principal in a bucket policy, or a new action on a path you already acknowledged. Run both tools.

How do I run Access Analyzer checks on CDK in GitHub Actions?

Synthesize the app, turn each template's Ref, Fn::GetAtt and Fn::Join into plain policy documents, and send them to Access Analyzer from one GitHub Actions job. The job assumes an AWS role with OpenID Connect, so no AWS keys sit in GitHub, and any answer other than PASS fails it. Run it on its own with -c nag=false, so cdk-nag can't stop a change before Access Analyzer sees it.

Related reading

#security-best-practices#iam#aws-cdk#ci-cd#best-practices
Rahul Ladumor

Rahul Ladumor

Principal Cloud & AI Platform Architect. AWS Professional certified, 5x AWS Community Builder. I work with teams that have real users, real AWS bills, and real production pressure.

About Rahul →

Scroll or pinch to zoom, drag to move100%