AWS IAM least privilege with Access Analyzer
Generate tight IAM policies from real CloudTrail activity and find resources shared outside your account.
The reason "Action": "s3:" survives three years in a CI role is not laziness. Nobody knows which of the two hundred S3 actions the pipeline calls, guessing wrong breaks a deploy at the worst time, and there is no cheap way to find out. IAM Access Analyzer removes the finding out* part. It can read CloudTrail for a principal and draft a policy containing only the actions and resources that principal actually invoked, it can list permissions a role has not used in a set number of days, and it can find resource policies that grant access from outside the account.
None of those three outputs is a finished policy. Generated policies describe history rather than intent and contain no conditions; unused-access findings say what to remove but not what the role will need next quarter; external-access findings need a human to decide whether the vendor account should still be there. The value is that every review starts from evidence instead of from a wildcard.
The CloudTrail window is the sensitive parameter: a quiet week under-generates, a peak week over-fits, and the maximum is 90 days.
Two analyzers, two questions
An analyzer of type ACCOUNT (or ORGANIZATION, from the delegated security account) answers the outside-in question: which S3 buckets, roles, KMS keys, queues and so on can be reached from outside the zone of trust. An analyzer of type ACCOUNT_UNUSED_ACCESS answers the inside-out question: which roles, users and permissions have not been used for unusedAccessAge days, between 1 and 365. External-access analysis is regional, so it goes in every region with resources; unused-access analysis looks at IAM, which is global. One of each per account is the usual shape.
aws accessanalyzer create-analyzer --analyzer-name external --type ACCOUNTarn:aws:access-analyzer:eu-west-1:111122223333:analyzer/externalaws accessanalyzer create-analyzer --analyzer-name unused --type ACCOUNT_UNUSED_ACCESS \ --configuration '{"unusedAccess":{"unusedAccessAge":90}}'arn:aws:access-analyzer:eu-west-1:111122223333:analyzer/unusedaws accessanalyzer list-analyzers --query "analyzers[].[name,type,status]" --output textexternal ACCOUNT ACTIVEunused ACCOUNT_UNUSED_ACCESS ACTIVEGenerate a policy from what the role really called
Policy generation needs three inputs, and examples that show only the first do not run: the principal ARN, the CloudTrail trail to read (with the regions or allRegions), and a service role that Access Analyzer assumes to read that trail. The startTime opens the window and endTime defaults to now; the window cannot exceed 90 days. The job runs asynchronously, and get-generated-policy returns the draft together with the list of services the trail recorded for the principal.
{"accessRole": "arn:aws:iam::111122223333:role/service-role/AccessAnalyzerMonitorServiceRole","startTime": "2026-07-15T00:00:00Z","trails": [{ "cloudTrailArn": "arn:aws:cloudtrail:eu-west-1:111122223333:trail/org-trail", "allRegions": true }]}
aws accessanalyzer start-policy-generation \ --policy-generation-details '{"principalArn":"arn:aws:iam::111122223333:role/ci-deploy"}' \ --cloud-trail-details file://trail.json{ "jobId": "2b57a4e8-6a1d-4f0e-9c2a-8c1f3f0f7a11" }aws accessanalyzer get-generated-policy --job-id 2b57a4e8-… \ --query "generatedPolicyResult.generatedPolicies[0].policy" --output text | jq ".Statement[].Action"["s3:GetObject","s3:PutObject","s3:ListBucket","ecr:GetAuthorizationToken","ecr:BatchGetImage","sts:GetCallerIdentity"]was s3:* and ecr:* on *; the trail shows six actions over 59 daysRead the draft with the window in mind. An action the role calls once a year (a certificate renewal, a quarterly export) will be missing if the window did not include it, and an action a colleague ran as the role during an incident will be present although it is not part of the job. The draft also arrives with Resource entries that are as specific as CloudTrail could make them, which for some services is *; those are the lines to narrow by hand.
What the generator will not add
Conditions. A generated statement says the role wrote objects to a bucket; it does not say the writes must be encrypted, must arrive over TLS, or must come from the VPC endpoint. Those are policy decisions, and they go in when the draft is reviewed. The same review is the moment to split read from write into separate statements: a condition on the encryption header only makes sense on PutObject, and a statement that applies it to GetObject as well denies every read, because the key is absent from read requests. Separate statements also let a later Deny target one without the other. Any role a human can assume gets a permissions boundary in the same pass.
[{"Sid": "ReadArtifacts","Effect": "Allow","Action": "s3:GetObject","Resource": "arn:aws:s3:::acme-app-artifacts/*","Condition": {"Bool": { "aws:SecureTransport": "true" },"StringEquals": { "aws:SourceVpce": "vpce-0a1b2c3d4e5f67890" }}},{"Sid": "WriteArtifactsEncrypted","Effect": "Allow","Action": "s3:PutObject","Resource": "arn:aws:s3:::acme-app-artifacts/*","Condition": {"Bool": { "aws:SecureTransport": "true" },"StringEquals": { "s3:x-amz-server-side-encryption": "aws:kms" }}}]
Findings: what is reachable from outside
External-access findings are the backup bucket a vendor was given read access to in 2023, the role whose trust policy names an account that no longer belongs to you, the KMS key with a grant to a principal nobody recognises. Each finding names the resource, the external principal and the actions. Resolving one means changing the resource policy (S3 bucket controls covers the bucket side); archiving one is only appropriate when the access is intended and documented, because an archived finding is the one nobody looks at again.
aws accessanalyzer list-findings-v2 --analyzer-arn arn:aws:access-analyzer:eu-west-1:111122223333:analyzer/external \ --filter '{"status":{"eq":["ACTIVE"]}}' --query "findings[].[resourceType,resource]" --output textAWS::S3::Bucket arn:aws:s3:::acme-backupsAWS::IAM::Role arn:aws:iam::111122223333:role/deployaws accessanalyzer get-finding-v2 --analyzer-arn … --id 6c1e…principal: {"AWS":"999988887777"} action: ["s3:GetObject","s3:ListBucket"] isPublic: falsethe bucket policy still names the old vendor account: remove the statement, the finding resolves on the next scanFor CI roles the generation input is only as good as the identity: a pipeline that authenticates with a static IAM user key produces a trail under that user, not under the role you want to tighten. Moving the pipeline to OIDC federation first means the generated policy describes the federated role's real behaviour. What keeps a tightened policy tight afterwards is organisational: an SCP that stops anyone re-attaching AdministratorAccess, a permissions boundary on the roles that can edit policies, and the unused-access analyzer running against the result.