
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.
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.
CheckNoNewAccessfails when a role can do anything its reference doesn't allow.
- Resolve every
RefandFn::GetAttin 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:TypeScript
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:

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:JSON
{"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:TypeScript
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:TypeScript
// 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:
text
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:
Bash
npm cinpm 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:- Deploy the CI role with
npx cdk deploy IamPolicyCi-CiRole -c githubRepo=<owner>/<name>.
- Copy the role ARN from the stack and run
gh variable set POLICY_CHECK_ROLE_ARN --body <arn>.
- Open a pull request that adds a public statement to the bucket policy. The
checkjob passes it, so theaccess-analyzerjob should printFAIL CheckNoPublicAccess.
- For
s3:*, add the statement on your machine and runnpm run synth, which writes the templates even when cdk-nag fails. Then runnpm run policy-checkwith 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?
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article