Who can do what? Reviewing an account
Sooner or later someone asks: "who in this account can delete production data?", "which keys are older than 90 days?", "does the CI user really need all of that?". The answer is never one command, and getting it right is a large part of a platform team's job. This lesson puts the chapter's tools together into an access review: find the admins, the long-lived credentials, the unused permissions, and shrink them without breaking anything.
Need to know: start from the powerful policies (list-entities-for-policy on AdministratorAccess and friends - remember groups), then credentials (the credential report: keys, their age and last use, passwords without MFA), then service last accessed (which services a principal is allowed but never used), and test every reduction with the simulator before applying it. Prefer roles and Identity Center over users; tag what you cannot remove.
1. Who holds the keys to the kingdom?
AdministratorAccess is rarely the only path to admin rights - IAMFullAccess or an inline "Action": "*" work too, and iam:PassRole plus a service that runs code is admin by another name - but it is where every review starts.
$ aws iam list-entities-for-policy --policy-arn arn:aws:iam::aws:policy/AdministratorAccess
{
"PolicyGroups": [
{
"GroupName": "platform-admins",
"GroupId": "AGPAMCPHTMQCKFKFXGJSH"
}
],
"PolicyUsers": [],
"PolicyRoles": [
{
"RoleName": "AWSReservedSSO_AdministratorAccess_ef134df5b3cd1957",
"RoleId": "AROALPEZPQJYNIVSOJBLP"
}
]
}
$ aws iam get-group --group-name platform-admins --query 'Users[].UserName' --output text
learner
$ aws iam list-policies --scope AWS --only-attached --query 'Policies[].[PolicyName,AttachmentCount]' --output text
AdministratorAccess 2
PowerUserAccess 1
ReadOnlyAccess 1
The policy is attached to a group and a role, not to a user - so a list of users with "AdministratorAccess attached" would be empty and wrong. Expand groups to their members, and treat every role that people can assume as an admin path too (who may assume AWSReservedSSO_AdministratorAccess_... is decided in IAM Identity Center).
$ for r in $(aws iam list-roles --query 'Roles[].RoleName' --output text); do echo "$r: $(aws iam list-attached-role-policies --role-name $r --query 'AttachedPolicies[].PolicyName' --output text)"; done
AWSReservedSSO_AdministratorAccess_ef134df5b3cd1957: AdministratorAccess
AWSReservedSSO_ReadOnlyAccess_a6898676ada22267: ReadOnlyAccess
2. Long-lived credentials
$ aws iam get-account-summary --query 'SummaryMap.{Users:Users,Roles:Roles,MFADevicesInUse:MFADevicesInUse,AccessKeysPerUserQuota:AccessKeysPerUserQuota}'
{
"Users": 2,
"Roles": 2,
"MFADevicesInUse": 0,
"AccessKeysPerUserQuota": 2
}
$ aws iam generate-credential-report --query State --output text
STARTED
$ sleep 6
$ aws iam get-credential-report --query Content --output text | base64 -d | awk -F, 'NR>1 {print $1, "password="$4, "mfa="$8, "key1="$9, "rotated="$10, "used="$11}' | column -t
<root_account> password=not_supported mfa=true key1=false rotated=N/A used=N/A
learner password=true mfa=false key1=true rotated=2026-08-23T20:00:03+00:00 used=2026-09-22T20:00:10+00:00
try-ci password=false mfa=false key1=true rotated=2026-05-05T20:00:03+00:00 used=2026-09-22T18:00:03+00:00
What to look for in every report:
- root with an active key (
access_key_1_active=trueon<root_account>): fix today. - passwords without MFA (
password_enabled=true,mfa_active=false). - keys older than 90 days (
access_key_1_last_rotated), and keys never used or unused for months (access_key_1_last_used_date): deactivate, then delete. - users at all: every IAM user with a password is a person who should be in IAM Identity Center instead; every user with a key is a workload that should be a role.
$ aws iam list-mfa-devices --user-name learner
{
"MFADevices": []
}
$ aws iam get-login-profile --user-name learner
{
"LoginProfile": {
"UserName": "learner",
"CreateDate": "2026-08-23T20:00:03+00:00",
"PasswordResetRequired": false
}
}
3. What is granted but never used?
Service last accessed data shows, for one user, group or role, every service its policies allow and when it last authenticated to each - from CloudTrail-like records IAM keeps for 400 days. "Allowed: 12 services, used: 2" is the shortest argument for a smaller policy.
$ JOB=$(aws iam generate-service-last-accessed-details --arn arn:aws:iam::111122223333:user/try-ci --query JobId --output text)
$ aws iam get-service-last-accessed-details --job-id $JOB --query 'ServicesLastAccessed[].[ServiceNamespace,LastAuthenticated]' --output text
cloudwatch None
logs None
dynamodb None
ec2 None
ecr None
eks None
rds None
s3 2026-09-22T18:00:03+00:00
sns None
sqs None
account None
cloudtrail None
iam None
kms None
lambda None
organizations None
secretsmanager None
sts 2026-08-23T20:00:03+00:00
ssm None
try-ci has PowerUserAccess - almost every service - and in the record only S3 and STS were ever used. IAM Access Analyzer goes further on real accounts: unused access findings (unused roles, keys, passwords, permissions across the organization), policy generation from CloudTrail history, external access findings for resources shared outside the account or organization, and policy validation (aws accessanalyzer validate-policy) that flags wildcards and errors before you save a policy. (Access Analyzer itself is not simulated in this lab.)
4. Shrink it - without an outage
Replace the broad policy with the smallest one that covers the observed use, and prove it first: the simulator with the real actions the workload calls.
$ cat try/ci-min.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "UploadBuilds",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::oncall-lab-builds-111122223333/ci/*"
},
{
"Sid": "ListBuilds",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::oncall-lab-builds-111122223333",
"Condition": {
"StringLike": {
"s3:prefix": [
"ci/*"
]
}
}
},
{
"Sid": "Whoami",
"Effect": "Allow",
"Action": "sts:GetCallerIdentity",
"Resource": "*"
}
]
}
$ aws iam simulate-custom-policy --policy-input-list file://try/ci-min.json --action-names s3:PutObject s3:ListBucket ec2:RunInstances iam:CreateUser --resource-arns arn:aws:s3:::oncall-lab-builds-111122223333/ci/app.tar.gz --query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output text
s3:PutObject allowed
s3:ListBucket implicitDeny
ec2:RunInstances implicitDeny
iam:CreateUser implicitDeny
$ aws iam put-user-policy --user-name try-ci --policy-name ci-min --policy-document file://try/ci-min.json
$ aws iam detach-user-policy --user-name try-ci --policy-arn arn:aws:iam::aws:policy/PowerUserAccess
$ aws iam simulate-principal-policy --policy-source-arn arn:aws:iam::111122223333:user/try-ci --action-names s3:PutObject ec2:RunInstances --resource-arns arn:aws:s3:::oncall-lab-builds-111122223333/ci/app.tar.gz --query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output text
s3:PutObject allowed
ec2:RunInstances implicitDeny
Two details in that output. s3:ListBucket came back implicitDeny in the first simulation because the resource given was an object ARN: ListBucket is checked against the bucket ARN (arn:aws:s3:::oncall-lab-builds-111122223333), so test each action with the resource it really uses. And sts:GetCallerIdentity is in the policy for readability only - AWS answers it for any valid credentials, even with every permission denied, which is exactly why it is the "whoami" of the CLI.
Order matters: add the new policy first, then remove the broad one, so there is never a moment with too little. Keep the old version (a managed policy version, or the old document in the change ticket) to roll back if a nightly job turns out to need one more action.
5. Scaling it: tags and ABAC
Per-team policies multiply: ten teams, ten near-identical policies. ABAC (attribute-based access control) writes the rule once with tags:
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "*",
"Condition": {"StringEquals": {"aws:ResourceTag/team": "${aws:PrincipalTag/team}"}}
}
"Allow if the resource's team tag equals the caller's team tag." New team, new resources: no policy change, only tags. It works where the service supports aws:ResourceTag for that action (check the service authorization reference), and it turns tagging into a security control: whoever may change tags may change access, so iam:TagUser / iam:TagRole and resource tagging permissions need the same care as policy changes.
$ aws iam list-user-tags --user-name learner
{
"Tags": [
{
"Key": "team",
"Value": "platform"
}
],
"IsTruncated": false
}
The review, as a checklist
| Question | Command |
|---|---|
| Who has admin? | list-entities-for-policy on admin-grade policies; expand groups; roles people can assume |
| Root safe? | credential report <root_account> row: no keys, MFA on |
| Old or unused keys? | credential report, get-access-key-last-used |
| People with passwords but no MFA? | credential report, list-mfa-devices |
| Unused permissions? | generate-service-last-accessed-details / Access Analyzer unused access |
| Public or shared resources? | get-bucket-policy-status, account BPA, Access Analyzer external access |
| Who changed what? | CloudTrail lookup-events (us-east-1 for IAM) |
| Will the smaller policy work? | simulate-principal-policy / simulate-custom-policy before the change |
In an interview: "How would you reduce the permissions of a service that has PowerUserAccess?" - "Find what it actually uses - service last accessed data, CloudTrail, or Access Analyzer's policy generation - write a policy for those actions and resources, test it with the policy simulator against the calls the service makes, attach it, then detach the broad policy, keeping a rollback path. Then watch the logs for AccessDenied for a full cycle of its jobs."
You can now: find every path to admin in an account, read the credential report for the risky rows, use service last accessed data to find unused permissions, shrink a policy safely with the simulator, and explain ABAC with tags.