OnCallReady

Lesson 35.33 · AWS I: CLI, IAM, S3 & KMS · 18 min read

Who can do what? Reviewing an account

In plain words

Imagine checking who has keys to a big office building once a year. You do not only look at the key cabinet: some people got copies directly, some got them through a team, some made their own copy years ago and forgot. You also check for keys nobody has used in months, and for people who have a key to every room but only ever open the kitchen.

The review ends by swapping big keys for small ones, testing that each small key opens the doors that person needs, before taking the big key back.

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:

$ 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

QuestionCommand
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.

Why it helps

Access reviews are a regular part of platform work in regulated companies (banks especially), and "least privilege" is in every security standard. Doing one well needs every tool from this chapter at once: policy attachments and inline policies, the credential report, service last accessed data, groups and the simulator.

It is also where outages hide: taking a permission away from a nightly job is a 2 am page if you did not check what the job really calls. The add, simulate, detach, run order is the habit that makes shrinking permissions safe.

Commands in this lesson

aws sleep cat

FAQ

Why is list-entities-for-policy not enough to find admins?

It only finds attachments of that one managed policy. Inline policies with "Action": "*" do not show up, nor do other admin-grade policies (IAMFullAccess can grant itself anything), roles people can assume, or indirect paths like iam:PassRole with a service that runs code. Read inline policies and trust policies too.

What is service last accessed data?

IAM's record of which services a user, group, role or policy is allowed to use and when it last authenticated to each, kept for 400 days (action-level detail for some services, such as S3). generate-service-last-accessed-details starts a job and get-service-last-accessed-details reads it. "Allowed 19, used 2" is the evidence for a smaller policy.

What does IAM Access Analyzer add?

External access findings (resources shared outside your account or organization), unused access findings (unused roles, keys, passwords and permissions across the organization), policy generation from CloudTrail history, and policy validation and checks before you save a policy. It turns the manual review of this lesson into continuous findings.

Why remove a person from a group instead of detaching a policy from them?

Because people should get permissions only through groups (or Identity Center permission sets). Moving someone between groups is one command each way, leaves nothing personal behind, and the next review sees the same shape for everyone. A policy attached to one person is exactly what reviews flag.

What is ABAC?

Attribute-based access control: policies compare tags, for example allow when the resource's team tag equals the caller's team tag (aws:ResourceTag and aws:PrincipalTag). New teams need tags, not new policies. It only works for actions that support those condition keys, and tagging becomes a security control that must itself be restricted.

In an interview Mid

How would you reduce the permissions of a service that has PowerUserAccess?

Find what it really uses: service last accessed data (aws iam generate-service-last-accessed-details), CloudTrail events, or IAM Access Analyzer policy generation. Write a policy for exactly those actions on those resources (S3 prefixes, the queue ARN). Test it with aws iam simulate-custom-policy against the calls the service makes, attach the new policy first, then detach PowerUserAccess, run the workload, and watch for AccessDenied through a full cycle of its jobs, keeping the old policy for a rollback.

Also asked: How do you find every principal with admin rights in an account? · What do you look for in the IAM credential report? · What is ABAC, and when would you use it?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.