Automating IAM Least Privilege with Access Analysis Tools
Automating IAM Least Privilege with Access Analysis Tools
Least privilege is the most cited IAM security principle and the least consistently implemented. The gap is not a lack of awareness but a lack of tooling. Manually reviewing hundreds of IAM policies against actual usage is impractical. AWS now provides native tools, primarily IAM Access Analyzer, that make automated least-privilege enforcement realistic. This guide covers how to build an automated pipeline that generates, validates, and enforces least-privilege policies using CloudTrail data and Access Analyzer.
IAM Access Analyzer Policy Generation
Generating Policies from Actual Usage
IAM Access Analyzer can generate a least-privilege policy for any IAM role based on its CloudTrail activity over a specified period. The generated policy includes only the actions and resources the role actually used, providing a concrete starting point for right-sizing.
import boto3
import json
import time
from datetime import datetime, timedelta
class LeastPrivilegeAutomation:
def __init__(self):
self.analyzer = boto3.client('accessanalyzer')
self.iam = boto3.client('iam')
self.cloudtrail = boto3.client('cloudtrail')
def generate_policy_for_role(self, role_arn, trail_arn, lookback_days=90):
"""Generate a least-privilege policy based on CloudTrail activity."""
end_time = datetime.utcnow()
start_time = end_time - timedelta(days=lookback_days)
response = self.analyzer.start_policy_generation(
policyGenerationDetails={
'principalArn': role_arn
},
cloudTrailDetails={
'trails': [
{
'cloudTrailArn': trail_arn,
'allRegions': True
}
],
'accessRole': 'arn:aws:iam::123456789012:role/AccessAnalyzerCloudTrailRole',
'startTime': start_time,
'endTime': end_time
}
)
job_id = response['jobId']
print(f"Policy generation started: {job_id}")
# Poll for completion
while True:
status = self.analyzer.get_generated_policy(jobId=job_id)
job_status = status['jobDetails']['status']
if job_status == 'SUCCEEDED':
return status['generatedPolicyResult']['generatedPolicies']
elif job_status == 'FAILED':
raise Exception(f"Policy generation failed: {status['jobDetails'].get('error')}")
time.sleep(30)
def compare_policies(self, role_name, generated_policies):
"""Compare generated policy against current attached policies."""
current_actions = set()
generated_actions = set()
# Get current policy actions
attached = self.iam.list_attached_role_policies(RoleName=role_name)
for policy in attached['AttachedPolicies']:
version = self.iam.get_policy(PolicyArn=policy['PolicyArn'])
doc = self.iam.get_policy_version(
PolicyArn=policy['PolicyArn'],
VersionId=version['Policy']['DefaultVersionId']
)
for stmt in doc['PolicyVersion']['Document'].get('Statement', []):
actions = stmt.get('Action', [])
if isinstance(actions, str):
actions = [actions]
current_actions.update(actions)
# Get generated policy actions
for policy in generated_policies:
doc = json.loads(policy['policy'])
for stmt in doc.get('Statement', []):
actions = stmt.get('Action', [])
if isinstance(actions, str):
actions = [actions]
generated_actions.update(actions)
unused_permissions = current_actions - generated_actions
return {
'role_name': role_name,
'current_permission_count': len(current_actions),
'used_permission_count': len(generated_actions),
'unused_permissions': sorted(unused_permissions),
'reduction_percentage': round(
len(unused_permissions) / max(len(current_actions), 1) * 100, 1
)
}
In practice, most roles use fewer than 30% of their granted permissions. A 70% reduction sounds alarming, but it reflects the reality that most policies are copied from templates or use AWS managed policies that bundle far more permissions than any single workload needs.
CloudTrail-Based Permission Right-Sizing
Querying Actual API Usage
Before removing permissions, verify which actions a role actually invokes. CloudTrail Lake provides SQL-based querying across your organization's CloudTrail events, making it straightforward to build an accurate picture of permission usage.
#!/bin/bash
# Query CloudTrail Lake for actions used by a specific role in the last 90 days
ROLE_NAME="ApplicationBackendRole"
EVENT_DATA_STORE_ID="eds-a1b2c3d4e5f6"
# Start the query
QUERY_ID=$(aws cloudtrail start-query \
--query-statement "
SELECT eventName, eventSource, COUNT(*) as invocation_count
FROM $EVENT_DATA_STORE_ID
WHERE userIdentity.sessionContext.sessionIssuer.userName = '$ROLE_NAME'
AND eventTime > '2025-12-13 00:00:00'
AND eventType = 'AwsApiCall'
AND errorCode IS NULL
GROUP BY eventName, eventSource
ORDER BY invocation_count DESC
" \
--output text --query 'QueryId')
echo "Query started: $QUERY_ID"
# Wait for results
sleep 10
aws cloudtrail get-query-results --query-id "$QUERY_ID" \
--output json | jq '.QueryResultRows[] | {
action: (.[0].Value + ":" + .[1].Value),
count: .[2].Value
}'
# Identify roles with no activity in 90 days (candidates for removal)
INACTIVE_QUERY_ID=$(aws cloudtrail start-query \
--query-statement "
SELECT userIdentity.sessionContext.sessionIssuer.userName as role_name,
MAX(eventTime) as last_used
FROM $EVENT_DATA_STORE_ID
WHERE userIdentity.type = 'AssumedRole'
AND eventType = 'AwsApiCall'
GROUP BY userIdentity.sessionContext.sessionIssuer.userName
HAVING MAX(eventTime) < '2025-12-13 00:00:00'
" \
--output text --query 'QueryId')
echo "Inactive roles query: $INACTIVE_QUERY_ID"
The first query produces a ranked list of API actions the role actually uses. The second identifies roles with no API activity in 90 days, which are candidates for deactivation or removal. Always check both CloudTrail and service-last-accessed data, since some permissions (like iam:PassRole) may not appear as direct API calls in CloudTrail.
CI/CD Policy Validation
Catching Overly Broad Policies Before Deployment
Integrate Access Analyzer's policy validation into your CI/CD pipeline to catch common mistakes like wildcard resources, missing condition keys, or overly permissive actions before they reach production.
def validate_policy_in_pipeline(policy_document):
"""Validate an IAM policy using Access Analyzer before deployment."""
analyzer = boto3.client('accessanalyzer')
response = analyzer.validate_policy(
policyDocument=json.dumps(policy_document),
policyType='IDENTITY_POLICY',
locale='EN'
)
blocking_findings = []
warnings = []
for finding in response['findings']:
detail = {
'type': finding['findingType'],
'message': finding['issueCode'],
'detail': finding['findingDetails'],
'locations': finding.get('locations', [])
}
if finding['findingType'] == 'ERROR':
blocking_findings.append(detail)
elif finding['findingType'] == 'SECURITY_WARNING':
blocking_findings.append(detail)
elif finding['findingType'] == 'WARNING':
warnings.append(detail)
result = {
'valid': len(blocking_findings) == 0,
'blocking_findings': blocking_findings,
'warnings': warnings,
'total_findings': len(response['findings'])
}
if not result['valid']:
print("POLICY VALIDATION FAILED:")
for f in blocking_findings:
print(f" [{f['type']}] {f['message']}: {f['detail']}")
return result
Run this validation as a GitHub Actions step or CodeBuild phase. Fail the pipeline on ERROR and SECURITY_WARNING findings. Treat WARNING findings as informational but track them in a dashboard. Common security warnings include wildcard Resource: * on sensitive actions, missing Condition blocks on sts:AssumeRole, and NotAction usage that inadvertently grants access to new services.
Continuous Compliance Monitoring
Deploy two types of Access Analyzer analyzers: external access analyzers that detect resources shared outside your account or organization, and unused access analyzers that flag roles, access keys, and passwords not used within a defined period. Route all findings to Security Hub for centralized triage. Set unused access thresholds to 90 days for production and 30 days for development accounts, since development roles tend to accumulate permissions faster.
Review Access Analyzer findings weekly. Automate the easy cases (unused access keys older than 180 days can be deactivated automatically) and route complex cases (roles with partial usage) to application owners with context about which permissions are unused and when they were last exercised.
Securing Least Privilege with AccessLens
Native AWS tools provide the raw data for least-privilege analysis, but correlating that data across hundreds of accounts and thousands of roles requires purpose-built tooling. IAM Access Analyzer generates policies one role at a time. CloudTrail queries require knowing which roles to investigate. The gap is visibility across the entire IAM landscape.
AccessLens closes that gap by providing:
- Organization-wide permission analysis that identifies over-provisioned roles across all accounts simultaneously
- Risk-scored findings that prioritize which roles to right-size first based on exposure and blast radius
- Cross-account permission mapping that reveals transitive access paths invisible to single-account tools
- Continuous drift detection that alerts when new permissions are added that violate least-privilege baselines
- Actionable recommendations with specific policy changes to reduce each role to its actual usage
Building a least-privilege program is a continuous process, not a one-time project. Automated tooling makes the difference between aspirational policy and enforced practice.
Accelerate your least-privilege program with AccessLens and get organization-wide IAM visibility that turns access analysis data into actionable permission reductions.