>samit_hota
Back to research
Cloud Security

Stop Guessing IAM: Building AWS Least-Privilege Policies from CloudTrail History

Samit Hota·
#aws#iam#cloud-security#least-privilege

“We’ll clean up the IAM policy before production” is the single biggest lie in cloud engineering. It almost never happens. What actually happens is someone attaches s3:* or AdministratorAccess to get an application working on Friday, and six months later that same role is still running in production because everyone is too afraid of breaking the pipeline to touch it.

Manual IAM policy authoring is fundamentally flawed because human beings are terrible at predicting which internal API actions an AWS SDK, terraform provider, or framework will invoke. You explicitly grant s3:GetObject, but forget s3:ListBucket. You add dynamodb:PutItem, but miss kms:GenerateDataKey when server-side encryption with customer managed keys is toggled on.

The solution isn’t to guess better—it’s to stop guessing entirely. AWS IAM Access Analyzer includes a policy generation engine that reads historical API calls from CloudTrail and emits a scoped policy containing only the actions your role actually executed during a given window. Here is how to execute this process end-to-end.

Step 1: Establish the Capture Baseline

Policy generation depends on CloudTrail management events logged to an S3 bucket. If you don’t already have a multi-region organizational trail or account-level trail logging to S3, Access Analyzer won’t have the event data it needs.

Before triggering tests, verify your trail configuration and obtain the Trail ARN:

aws cloudtrail describe-trails \
  --query 'trailList[*].[Name, TrailARN, S3BucketName]' \
  --output table

Next, deploy your application component in a sandbox or staging environment. Assign it a temporary execution role that has sufficient permissions to run without failing—a scoped-broad role (e.g., PowerUserAccess or a service-specific wildcard like sqs:* and s3:*). The goal here is to let the application run completely unimpeded while CloudTrail records every API call it makes.

Note the ARN of the role you are testing: arn:aws:iam::123456789012:role/AppStagingRole.

Step 2: Execute Workload Integration Tests

Run your application’s full suite of integration tests, end-to-end workflows, and edge-case execution paths. If this is a backend API service, hit every endpoint. If it’s a batch processor, run jobs against test payloads that trigger all conditional execution branches.

Record the start and end timestamps (in UTC, ISO-8601 format) of this test run. CloudTrail management events typically take 5 to 15 minutes to deliver to your S3 bucket. Wait at least 15 minutes after your test execution finishes before proceeding to ensure all API events are indexed.

# Set your window variables (UTC)
START_TIME="2026-03-29T10:00:00Z"
END_TIME="2026-03-29T11:00:00Z"

Step 3: Trigger Policy Generation via AWS CLI

Call the Access Analyzer start-policy-generation API, pointing it at the IAM role ARN, the target CloudTrail ARN, and the exact time window of your test run.

aws accessanalyzer start-policy-generation \
  --policy-generation-details '{
    "principalArn": "arn:aws:iam::123456789012:role/AppStagingRole"
  }' \
  --cloud-trail-details '{
    "trails": [{
      "trailArn": "arn:aws:cloudtrail:us-east-1:123456789012:trail/main-organization-trail",
      "regions": ["us-east-1"]
    }],
    "startTime": "'"${START_TIME}"'",
    "endTime": "'"${END_TIME}"'"
  }'

This returns a JSON object containing a jobId. Save this value:

{
    "jobId": "9f32371b-314d-4467-892f-1100234a5eab"
}

Poll the status of the job using get-generated-policy:

aws accessanalyzer get-generated-policy \
  --job-id "9f32371b-314d-4467-892f-1100234a5eab" \
  --query 'jobDetails.status'

Wait until the status shifts from IN_PROGRESS to SUCCEEDED.

Step 4: Extract and Inspect the Generated Policy

Once the job finishes, retrieve the raw policy payload:

aws accessanalyzer get-generated-policy \
  --job-id "9f32371b-314d-4467-892f-1100234a5eab" \
  --query 'generatedPolicyResult.generatedPolicies[0].policy' \
  --output text > generated-policy.json

Open generated-policy.json. Access Analyzer generates a valid IAM policy document containing exact API actions mapped from the recorded CloudTrail events.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ReadWriteS3Objects",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObject"
            ],
            "Resource": "arn:aws:s3:::${BucketName}/${ObjectName}"
        }
    ]
}

Notice how Access Analyzer handles resources: it correctly identifies the exact actions invoked (s3:GetObject, s3:PutObject), but replaces specific resource paths with variable placeholders like ${BucketName} when it cannot deterministically map the resource ARN.

Step 5: Replace Placeholders and Enforce Resource Limits

Access Analyzer solves the action selection problem, but you still must solve the resource scoping problem. A policy generated from CloudTrail that leaves ${BucketName} as a wildcard or unmapped parameter is still incomplete.

Replace the placeholders with concrete Infrastructure-as-Code references or strict resource ARNs. For example, if using Terraform, convert the output into a dynamic policy document:

data "aws_iam_policy_document" "app_production" {
  statement {
    sid    = "ReadWriteS3Objects"
    effect = "Allow"
    actions = [
      "s3:GetObject",
      "s3:PutObject"
    ]
    resources = [
      "${aws_s3_bucket.app_data.arn}/*"
    ]
  }
}

Before replacing the over-privileged role in production, validate the newly generated policy using IAM Access Analyzer’s static policy validation API:

aws accessanalyzer validate-policy \
  --policy-document file://generated-policy.json \
  --policy-type IDENTITY_POLICY

Ensure this returns no ERROR or SECURITY_WARNING level findings.

Where This Strategy Breaks (And How to Handle It)

While policy generation from usage history is vastly superior to manual guessing, you must account for two key limitations:

  1. Unexercised Code Paths: If an error-handling routine or disaster-recovery sync task isn’t triggered during your integration test run, CloudTrail won’t record those API calls. Your policy will miss them, and those operations will fail in production. To mitigate this, combine policy generation with automated code coverage analysis to ensure your integration tests hit >90% of execution paths.
  2. Data-Plane Event Exclusions: By default, CloudTrail trails capture Management Events (control plane), but not Data Events (e.g., S3 object-level reads/writes or DynamoDB item-level operations) unless explicitly configured. Ensure your CloudTrail trail includes data event logging for the specific services your workload uses, or Access Analyzer will only record control-plane actions like s3:ListAllMyBuckets.

Stop hand-crafting IAM policies based on guesswork and stackoverflow snippets. Establish a repeatable flow: grant broad access in isolated staging, run automated integration tests, extract the CloudTrail delta via Access Analyzer, scope the resource ARNs, and deploy the resulting policy into production.

Want a second set of eyes on your security posture?

Let's talk about where your real exposure is.

Book an advisory call