AWS Builder Center
Least-privilege IAM you can test in CI

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.

Principal Cloud & AI Platform Architect | 5x AWS Community Builder (Serverless)
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:
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:
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:
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 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> .
  1. Copy the role ARN from the stack and run  gh variable set POLICY_CHECK_ROLE_ARN --body <arn> .
  1. 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 .
  1. 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?
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article