Review a Generated IAM Policy for Least Privilege

Network switch with rows of blue ethernet cables plugged into its ports

Photo by Kirill Sh on Unsplash

To review a generated IAM policy for least privilege, check it in five passes: list the API calls the code actually makes, replace every wildcard action with those exact actions, scope each Resource to specific ARNs, add conditions where a key narrows access, then run IAM Access Analyzer policy validation and test the role with the policy simulator before deploying.

Generated policies are a good start and a bad finish. Whether it came from an AI assistant, a policy generator or a teammate’s copy-paste, the first draft usually grants Resource: "*" and a few service:* actions, because that’s what makes the code run on the first try. This checklist shows how to review a generated IAM policy for least privilege, using a real Lambda function as the example, and how to prove the tightened policy still works.

Start from what the code actually calls

A policy can only be least privilege relative to something, and that something is the code. Here is the handler for an orders-api Lambda function written with the AWS SDK for JavaScript v3:

handler.ts

import { DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand } from "@aws-sdk/client-dynamodb";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";

const ddb = new DynamoDBClient({});
const s3 = new S3Client({});
const sqs = new SQSClient({});

interface OrderEvent {
  orderId: string;
  customerId: string;
}

export const handler = async (event: OrderEvent): Promise<{ status: string }> => {
  const existing = await ddb.send(new GetItemCommand({
    TableName: "orders",
    Key: { pk: { S: event.orderId }, sk: { S: "ORDER" } },
  }));
  if (existing.Item) return { status: "duplicate" };

  const history = await ddb.send(new QueryCommand({
    TableName: "orders",
    IndexName: "by-customer",
    KeyConditionExpression: "customerId = :c",
    ExpressionAttributeValues: { ":c": { S: event.customerId } },
  }));

  await ddb.send(new PutItemCommand({
    TableName: "orders",
    Item: { pk: { S: event.orderId }, sk: { S: "ORDER" }, customerId: { S: event.customerId } },
  }));

  await s3.send(new PutObjectCommand({
    Bucket: "acme-invoices-123456789012",
    Key: `invoices/${event.orderId}.json`,
    Body: JSON.stringify({ orderId: event.orderId, previousOrders: history.Count ?? 0 }),
  }));

  await sqs.send(new SendMessageCommand({
    QueueUrl: "https://sqs.us-east-1.amazonaws.com/123456789012/order-events",
    MessageBody: JSON.stringify({ type: "OrderCreated", orderId: event.orderId }),
  }));

  return { status: "created" };
};

Write the calls down as a table of action and resource. This is the spec the policy must match, nothing more:

SDK command IAM action Resource ARN
GetItemCommand dynamodb:GetItem …:table/orders
QueryCommand on an index dynamodb:Query …:table/orders/index/by-customer
PutItemCommand dynamodb:PutItem …:table/orders
PutObjectCommand s3:PutObject arn:aws:s3:::acme-invoices-123456789012/invoices/*
SendMessageCommand sqs:SendMessage …:order-events

If you want a first draft from code in seconds, the free IAM policy generator for TypeScript code drafts a policy from code like this; there are matching generators for boto3 Python code and AWS SDK for Go code. Their output, like any generated policy, needs the review below.

A typical generated policy, and what’s wrong with it

draft-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "dynamodb:*",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "sqs:SendMessage",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "*"
    }
  ]
}

It works, which is the problem. dynamodb:* on * lets this function drop every table in the account. The S3 statement can read any bucket the account can reach. And iam:PassRole on * lets the function hand any role to a service, a known privilege escalation path, for an action the code never uses.

How to review a generated IAM policy for least privilege, pass by pass

  1. Remove wildcard actions from the IAM policyReplace dynamodb:* with GetItem, PutItem and Query. Delete actions the code doesn’t call, such as iam:PassRole and s3:ListBucket here.
  2. Scope the AWS IAM policy to resource ARNsSwap every "*" for the exact table, index, bucket prefix and queue ARNs. Include the account ID and region.
  3. Add conditions where a key narrows accessUse condition keys to restrict what the ARN alone can’t, such as S3 prefixes on ListBucket or kms:ViaService on KMS calls.
  4. Add what the runtime needsA Lambda role also needs to write its own logs. Grant that to the function’s log group, not logs:*.
  5. Validate and testRun Access Analyzer validation, simulate the role, then invoke the function and read its logs.
orders-api-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "OrdersTable",
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem", "dynamodb:PutItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/orders"
    },
    {
      "Sid": "OrdersByCustomerIndex",
      "Effect": "Allow",
      "Action": "dynamodb:Query",
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/orders/index/by-customer"
    },
    {
      "Sid": "WriteInvoices",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::acme-invoices-123456789012/invoices/*"
    },
    {
      "Sid": "PublishOrderEvents",
      "Effect": "Allow",
      "Action": "sqs:SendMessage",
      "Resource": "arn:aws:sqs:us-east-1:123456789012:order-events"
    },
    {
      "Sid": "FunctionLogs",
      "Effect": "Allow",
      "Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/orders-api:*"
    }
  ]
}

Two details trip people up. S3 object actions take the object ARN (bucket/prefix/*) while bucket actions like s3:ListBucket take the bucket ARN. And a DynamoDB query on a secondary index needs the index ARN; the table ARN alone returns AccessDeniedException. If the log group isn’t created ahead of time by your infrastructure code, also allow logs:CreateLogGroup.

When do conditions earn their place?

Add a condition when the resource ARN can’t express the limit. If the function later needs to list invoices, grant s3:ListBucket on the bucket ARN with a StringLike condition on s3:prefix of invoices/*. If it decrypts with a customer managed KMS key, scope kms:Decrypt to the key ARN and add kms:ViaService so the key only works through S3 in your region. Don’t add conditions for decoration; each one is something the next reviewer has to understand.

Validate the IAM policy with Access Analyzer

IAM Access Analyzer checks a policy against IAM grammar and AWS best practices and returns four kinds of findings: errors, security warnings, warnings and suggestions. The AWS guide to validating policies with IAM Access Analyzer covers the console flow; the CLI version fits a CI job:

Terminal

aws accessanalyzer validate-policy \
  --policy-type IDENTITY_POLICY \
  --policy-document file://draft-policy.json \
  --query "findings[].[findingType,issueCode]" --output table

aws accessanalyzer validate-policy \
  --policy-type IDENTITY_POLICY \
  --policy-document file://orders-api-policy.json \
  --query "findings[].[findingType,issueCode]" --output table

On the draft, expect a SECURITY_WARNING with issue code PASS_ROLE_WITH_STAR_IN_RESOURCE. Typos such as a misspelled action come back as ERROR with INVALID_ACTION. The reviewed policy should return an empty list.

Be clear about the limits: validation doesn’t know what your code calls, so it won’t flag dynamodb:* on its own. To enforce a rule like “this role can never delete tables”, add a custom check that fails the build:

Terminal

aws accessanalyzer check-access-not-granted \
  --policy-type IDENTITY_POLICY \
  --policy-document file://orders-api-policy.json \
  --access actions="dynamodb:DeleteTable","dynamodb:DeleteItem","s3:DeleteObject"

A PASS result means none of the listed actions is granted. Custom policy checks are billed per check, unlike basic validation.

Test IAM permissions for a Lambda function

You can’t assume a Lambda execution role from your laptop, because its trust policy only allows lambda.amazonaws.com. Use the policy simulator against the role instead. It evaluates the policies attached to the role for the actions and resources you name:

Terminal

aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/orders-api-lambda \
  --action-names dynamodb:GetItem dynamodb:PutItem dynamodb:DeleteItem \
  --resource-arns arn:aws:dynamodb:us-east-1:123456789012:table/orders \
  --query "EvaluationResults[].[EvalActionName,EvalDecision]" --output table

You want allowed for GetItem and PutItem and implicitDeny for DeleteItem. Test the negatives on purpose; a policy that allows everything passes every positive test. The simulator only includes a bucket policy or other resource-based policy if you pass it with --resource-policy.

Then run the real thing and search the function’s logs for denials:

Terminal

aws lambda invoke --function-name orders-api \
  --cli-binary-format raw-in-base64-out \
  --payload '{"orderId":"o-1001","customerId":"c-42"}' response.json

aws logs tail /aws/lambda/orders-api --since 10m --filter-pattern AccessDenied

If a call is denied, read the error message before widening anything; the step-by-step method to troubleshoot AWS IAM access denied errors tells you which policy type blocked it. For errors that aren’t permission related, see how to investigate Lambda errors with CloudWatch. And if you’re invoking from your own code rather than the CLI, the example to invoke a Lambda function with AWS SDK v3 in TypeScript includes the caller’s permissions.

Common mistakes in least-privilege reviews

  • Scoping actions but leaving Resource: "*". s3:PutObject on every bucket is still broad. The policy for the script to detect and stop underutilized EC2 instances by CPU shows the fix: read and write in separate statements, with ec2:StopInstances scoped to one region’s instance ARNs.
  • Using NotAction to be clever. An Allow with NotAction grants every action you didn’t list, including ones AWS adds later.
  • Forgetting environments. A policy with the production account ID and table name breaks in staging. Parameterize ARNs in your infrastructure code instead of loosening them.
  • Never revisiting. After a few weeks, run aws iam generate-service-last-accessed-details --arn <role-arn> --granularity ACTION_LEVEL and remove anything unused.

Where ChatWithCloud fits

The IAM generators produce a draft from code; they don’t see your account, so they can’t know your real ARNs or which calls run in production. Code is sent only to produce the result and isn’t stored, as described in what happens to code pasted into an AI converter. The ChatWithCloud CLI can then help you analyze your AWS security posture with an AI CLI, for example by asking which roles have wildcard policies. It runs changes without a confirmation step, so connect ChatWithCloud to a read-only AWS profile for this kind of review, and read the ChatWithCloud security and data handling model. The same review applies to the role that runs Terraform when you import CloudFormation resources into Terraform.

Frequently asked questions

Does IAM Access Analyzer tell me if a policy is least privilege?

Not by itself. Validation catches errors and specific risky patterns. It can’t compare the policy with what your code calls; that part of the review is yours. Custom checks like check-access-not-granted enforce rules you define.

Should a Lambda role ever have Resource “*”?

Only for actions that don’t support resource-level permissions, such as some List and Describe calls. The Service Authorization Reference lists which actions accept an ARN.

How do I test IAM permissions for a Lambda function without deploying?

Use aws iam simulate-custom-policy with the policy JSON before it’s attached, or simulate-principal-policy against the existing role. Then confirm with a real invocation.

Can AWS generate a least-privilege policy from real usage?

IAM Access Analyzer can generate a policy from CloudTrail activity for a role. Treat that output as another draft and review it with the same passes.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud