The role nobody dares to shrink
Every AWS account older than a year has them: roles with AdministratorAccess attached "temporarily" in 2023, a CI role with s3:* and ec2:* because the pipeline failed once and someone widened it at midnight, a Lambda execution role that can iam:PassRole on anything. Everyone agrees they should be narrower. Nobody touches them, because the person who knows what the role actually needs left, and the cost of breaking a production deploy is higher than the cost of leaving it wide open. So the findings sit in Security Hub with a red badge, quarter after quarter.
This post builds an IAM right-sizing agent that does the scary part with evidence instead of guesswork. It reads Access Analyzer's unused-permission findings, pulls IAM's action-level last-accessed data for the role, drafts a narrower policy, proves with CheckNoNewAccess that the draft grants nothing the old policy did not, and opens a Terraform pull request with the reasoning attached. Its own credentials are read-only on IAM. The only thing it can write is a git branch. After merge, it watches CloudTrail for AccessDenied from that role and opens a revert PR if the shrink broke something.
It is the IAM sibling of the AWS cost anomaly agent: read a lot, explain clearly, change nothing without a human. If you have already done this exercise for Kubernetes ServiceAccounts, the shape will feel familiar. IAM just has better telemetry.
What the agent gets to read
Three read tools plus one write tool that only touches git. As with every ops agent on this site, each tool returns a compact typed summary rather than raw API JSON, so the model reasons over labeled rows instead of pagination tokens.
TOOLS = [
{
"name": "list_unused_permissions",
"description": "Access Analyzer unused-access findings for one IAM role: "
"each service namespace with unused actions and the last time "
"anything in that service was called. Tracking window is 90 days.",
"input_schema": {
"type": "object",
"properties": {"role_arn": {"type": "string"}},
"required": ["role_arn"],
},
},
{
"name": "get_last_accessed",
"description": "IAM action-level last-accessed report for a role. Returns per "
"service: last call time, and per tracked action: last call time and "
"region. Also returns evidence_days (how long the role has existed, "
"capped at 400) and which services only have service-level data.",
"input_schema": {
"type": "object",
"properties": {"role_arn": {"type": "string"}},
"required": ["role_arn"],
},
},
{
"name": "get_role_policies",
"description": "All inline and attached identity policies for a role, as "
"documents, plus the role's trust policy and tags.",
"input_schema": {
"type": "object",
"properties": {"role_arn": {"type": "string"}},
"required": ["role_arn"],
},
},
{
"name": "open_policy_pr",
"description": "Open a pull request replacing the role's policy in Terraform. "
"Rejects the PR unless check_no_new_access passed.",
"input_schema": {
"type": "object",
"properties": {
"role_name": {"type": "string"},
"new_policy_document": {"type": "string"},
"summary_markdown": {"type": "string"},
},
"required": ["role_name", "new_policy_document", "summary_markdown"],
},
},
]
Note what's missing: put_role_policy, attach_role_policy, create_policy_version. The agent never modifies IAM. It modifies a branch, and the same review path as every other infra change decides whether IAM changes.
Step 1: A read-only identity for the agent
The agent's own policy is the real guardrail. This one reads Access Analyzer and IAM metadata and explicitly denies every IAM write, so that even a wildcard granted by mistake later cannot become a privilege-escalation path:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadAccessAnalyzer",
"Effect": "Allow",
"Action": [
"access-analyzer:ListAnalyzers",
"access-analyzer:ListFindingsV2",
"access-analyzer:GetFindingV2",
"access-analyzer:ValidatePolicy",
"access-analyzer:CheckNoNewAccess"
],
"Resource": "*"
},
{
"Sid": "ReadIamMetadata",
"Effect": "Allow",
"Action": [
"iam:GetRole",
"iam:ListRolePolicies",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:ListRoleTags",
"iam:GenerateServiceLastAccessedDetails",
"iam:GetServiceLastAccessedDetails"
],
"Resource": "*"
},
{
"Sid": "NeverWriteIam",
"Effect": "Deny",
"Action": [
"iam:Put*", "iam:Attach*", "iam:Detach*", "iam:Create*",
"iam:Delete*", "iam:Update*", "iam:PassRole"
],
"Resource": "*"
}
]
}
The explicit Deny matters more here than in most agent roles. An agent whose job is rewriting IAM policies is one bad tool call away from rewriting its own policy. A Deny statement wins over any Allow that gets added to this role in the future, by anyone, for any reason. Verify the boundary before trusting it:
# Should succeed
aws iam generate-service-last-accessed-details \
--arn arn:aws:iam::123456789012:role/ci-deploy --granularity ACTION_LEVEL
# Should fail with AccessDenied — this is the whole point
aws iam put-role-policy --role-name ci-deploy --policy-name x \
--policy-document '{"Version":"2012-10-17","Statement":[]}'
The git write goes through a GitHub App token scoped to one repository with contents and pull_requests permission, delivered the way any agent secret should be: injected at runtime, short-lived, never in an environment file.
Step 2: The tools, with the API quirks absorbed
IAM's last-accessed API is asynchronous and inconsistent across services. Both quirks belong in the wrapper, not in the prompt.
import boto3, time
from datetime import datetime, timezone
iam = boto3.client("iam")
aa = boto3.client("accessanalyzer")
def get_last_accessed(role_arn: str) -> dict:
job = iam.generate_service_last_accessed_details(
Arn=role_arn, Granularity="ACTION_LEVEL")["JobId"]
for _ in range(30): # ~60s ceiling, then give up loudly
resp = iam.get_service_last_accessed_details(JobId=job)
if resp["JobStatus"] == "COMPLETED":
break
if resp["JobStatus"] == "FAILED":
return {"error": resp.get("Error", {}).get("Message", "job failed")}
time.sleep(2)
else:
return {"error": "last-accessed job did not complete in 60s"}
created = iam.get_role(RoleName=role_arn.split("/")[-1])["Role"]["CreateDate"]
evidence_days = min(400, (datetime.now(timezone.utc) - created).days)
services, service_level_only = [], []
for svc in resp["ServicesLastAccessed"]:
row = {
"namespace": svc["ServiceNamespace"],
"last_used": svc.get("LastAuthenticated"),
"actions": [
{"action": a["ActionName"],
"last_used": a.get("LastAccessedTime"),
"region": a.get("LastAccessedRegion")}
for a in svc.get("TrackedActionsLastAccessed", [])
],
}
if not row["actions"]:
service_level_only.append(svc["ServiceNamespace"])
services.append(row)
return {"role_arn": role_arn, "evidence_days": evidence_days,
"services": services, "service_level_only": service_level_only}
Two fields do most of the safety work. evidence_days tells the model how much history exists: a role created five weeks ago has five weeks of evidence, and "unused for 35 days" is not a reason to remove anything. service_level_only lists services where IAM only reports that the service was used, not which actions. For those the agent may drop the whole service if it is entirely unused, but must never prune individual actions inside it, because it cannot see them.
The Access Analyzer side is simpler. You need an analyzer of type ACCOUNT_UNUSED_ACCESS (it is billed per IAM role or user analyzed per month, so scope it to one account before enabling it organization-wide):
def list_unused_permissions(role_arn: str) -> dict:
analyzer = next(a["arn"] for a in aa.list_analyzers(type="ACCOUNT_UNUSED_ACCESS")["analyzers"])
findings = aa.list_findings_v2(
analyzerArn=analyzer,
filter={"resource": {"eq": [role_arn]},
"findingType": {"eq": ["UnusedPermission"]},
"status": {"eq": ["ACTIVE"]}},
)["findings"]
out = []
for f in findings:
detail = aa.get_finding_v2(analyzerArn=analyzer, id=f["id"])
for d in detail["findingDetails"]:
u = d.get("unusedPermissionDetails")
if u:
out.append({"service": u["serviceNamespace"],
"last_used": u.get("lastAccessed"),
"unused_actions": [a["action"] for a in u.get("actions", [])]})
return {"role_arn": role_arn, "unused": out, "window_days": 90}
Two sources that mostly agree is the point. Access Analyzer's window is 90 days; IAM last-accessed reaches back 400. When they disagree, the longer record wins, and the disagreement itself goes into the PR description.
Step 3: The rules the model must follow
The system prompt is short and mostly about what not to do. These are the failure modes I hit on real accounts; each rule exists because of one.
You right-size one IAM role at a time. Produce a narrower policy document and a
justification. Rules, in priority order:
1. Never remove an action with less than 180 evidence_days behind it. Report it
as "insufficient evidence" instead. Quarterly and yearly jobs exist.
2. Never touch Deny statements, Condition blocks, or the trust policy.
3. Never remove individual actions from a service listed in service_level_only.
Drop the service entirely only if it has no last_used at all.
4. Never narrow a Resource from "*" to specific ARNs. Last-accessed data does not
say which resources were touched. Flag it as a follow-up.
5. If the role has tag iam-rightsizing=exempt, or its name contains
"break-glass", stop and report why.
6. Every removed action must cite its evidence: last_used date or "never".
7. If unsure, keep the permission. A wide role is a known risk; a broken
deploy at 02:00 is a new one.
Rule 1 is the one people argue with. Annual license renewals, disaster-recovery drills, end-of-quarter reporting Lambdas: all of them look "unused" for months and then matter enormously. Half a year of silence is the shortest window I am comfortable removing on, and the post-merge watchdog in Step 5 is the backstop for the rest.
The agent's output is structured, so the PR body and the guardrail both consume it without parsing prose:
{
"role_name": "ci-deploy",
"removed": [
{"action": "ec2:TerminateInstances", "evidence": "never in 400 days"},
{"action": "s3:DeleteBucket", "evidence": "never in 400 days"}
],
"kept_low_confidence": [
{"action": "rds:CreateDBSnapshot", "reason": "last used 171 days ago, under 180"}
],
"follow_ups": ["Resource is * on s3 statement; narrow to deploy bucket ARNs"],
"confidence": "high"
}
Step 4: Prove the new policy is strictly narrower
This is the step that turns "the model thinks this is safe" into "AWS confirms this is safe." Access Analyzer's custom policy checks can compare two policy documents and return PASS only if the new one grants no access the old one did not. The open_policy_pr tool refuses to open anything unless this passes:
def _guard_no_new_access(new_doc: str, old_doc: str) -> None:
v = aa.validate_policy(policyDocument=new_doc, policyType="IDENTITY_POLICY")
errors = [f for f in v["findings"] if f["findingType"] == "ERROR"]
if errors:
raise ValueError(f"policy invalid: {errors[0]['findingDetails']}")
r = aa.check_no_new_access(newPolicyDocument=new_doc,
existingPolicyDocument=old_doc,
policyType="IDENTITY_POLICY")
if r["result"] != "PASS":
raise ValueError(f"new policy grants new access: {r.get('message')}")
Two things this catches that a prompt cannot. A model that "simplifies" three specific actions into s3:Get* has widened the policy, and the check fails. A model that drops a Condition block while removing an action has widened it too, and the check fails. The same call is worth running in CI on every hand-written IAM change, right next to the Terraform plan review agent:
aws accessanalyzer check-no-new-access \
--new-policy-document file://new.json \
--existing-policy-document file://old.json \
--policy-type IDENTITY_POLICY --query result
The PR itself is ordinary: the agent edits the aws_iam_policy_document in Terraform, commits to a branch named iam-rightsize/ci-deploy, and the body contains the removed list with evidence, the low-confidence list, the follow-ups, and the raw check-no-new-access output. CI runs terraform plan. A human merges. The agent has done its job the moment the PR exists.
Step 5: Watch for AccessDenied after merge
Shrinking a role is only safe if you find out quickly when you were wrong. After merge, a scheduled job queries the CloudTrail log group in CloudWatch Logs Insights for denials by that role:
fields @timestamp, eventSource, eventName, errorCode, errorMessage
| filter errorCode in ["AccessDenied", "AccessDeniedException", "UnauthorizedOperation"]
| filter userIdentity.sessionContext.sessionIssuer.arn like /role\/ci-deploy$/
| stats count(*) as denials by eventSource, eventName
| sort denials desc
Any non-zero result inside the first 14 days triggers a revert PR that restores the removed action, labeled with the denied eventName and the CloudTrail event ID. It does not restore the whole old policy, because the evidence is about one action. Fourteen days is deliberately longer than a sprint, so a release cadence of once a week gets two chances to trip it. Roles that only run quarterly need a longer watch, and that is the honest reason rule 1 keeps a 180-day floor.
What this agent will not do, and why
It does not narrow resources. Last-accessed data is per action, not per ARN. Turning "Resource": "*" into a list of bucket ARNs needs CloudTrail event resources, and often needs a human who knows the bucket naming scheme. The agent flags it and stops.
It does not touch cross-account trust. A wrong AssumeRole trust edit locks out a whole account, and there is no CheckNoNewAccess equivalent that reasons about your organization's SCPs. Trust policies stay hand-edited and reviewed.
It does not run on everything. Start with CI and deploy roles, which are used on a schedule and leave dense evidence. Leave human roles, break-glass roles, and anything assumed by an external SaaS for the second pass.
It can be wrong at the edges. IAM's action-level tracking only covers a subset of services; for the rest you get service-level data, which is why rule 3 exists. And any drift between the Terraform and the live role, the kind a drift detection agent catches, means the PR diff will not match what the check compared. Run drift detection first.
The measurable outcome on the accounts where I have run this: the roles that had been "temporarily" over-permissive for years got PRs with evidence nobody could argue with, and most merged within a week. Not because an LLM was clever, but because the boring parts, polling an async API, cross-checking two data sources, and proving the diff is monotonic, finally got done by something that does not get bored.