Identities & Policies
Create IAM users, groups, roles, and policies, trace the evaluation engine and see explicit Deny win every time.
Prerequisites
Mental Model: IAM in One Diagram
Before touching any commands, read this. Every section in this lab maps back to one of these boxes.
┌──────────────────────────────┐ ┌──────────────────────────────────────┐ ┌────────────────────────────────┐
│ Identities (WHO) │ │ Policies (WHAT) │ │ Evaluation (DECISION) │
│ │ │ │ │ │
│ ┌──────────────────────────┐ │ │ ┌──────────────────────────────────┐ │ │ ┌────────────────────────┐ │
│ │ IAM Role │ │ has │ │ Identity-based Policy │ │ │ │ Explicit DENY │ │
│ │ Assumed, temporary creds │ │ ──► │ │ Attached to user / group / role │ │ ──► │ │ Always wins │ │
│ └────────────┬─────────────┘ │ │ └────────────────┬─────────────────┘ │ │ └───────────┬────────────┘ │
│ │ trusts │ │ ┌───────┼───────┐ │ │ │ overrides │
│ v │ │ v v v │ │ v │
│ ┌──────────────────────────┐ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ ┌────────────────────────┐ │
│ │ Service Principal │ │ │ │ Inline │ │Customer │ │ AWS │ │ │ │ Explicit ALLOW │ │
│ │ e.g. ec2.amazonaws.com │ │ │ │ Policy │ │ Managed │ │ Managed │ │ │ │ Required to proceed │ │
│ └──────────────────────────┘ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ └───────────┬────────────┘ │
│ │ │ │ │ │ │
│ ┌──────────────────────────┐ │ │ │ │ v │
│ │ IAM User │ │ │ │ │ ┌────────────────────────┐ │ │
│ │ Long-lived access key │ │ │ │ │ │ Implicit DENY │ │
│ └──────────────────────────┘ │ │ ┌──────────────────────────────────┐ │ │ │ Default if no ALLOW │ │
│ │ │ │ Resource-based Policy │ │ │ └────────────────────────┘ │
│ ┌──────────────────────────┐ │ │ │ Attached to resource (S3, SQS…) │ │ │ │
│ │ IAM Group │ │ │ └──────────────────────────────────┘ │ │ │
│ │ Collection of users │ │ │ │ │ │
│ └──────────────────────────┘ │ │ │ │ │
└──────────────────────────────┘ └──────────────────────────────────────┘ └────────────────────────────────┘Identities: Users, Groups, Roles
Goal
Create one of each identity type. Understand the fundamental difference between a User (long-lived credentials, a person) and a Role (assumed, temporary credentials, a workload). Groups are just a policy attachment shortcut, they don't represent a principal.
Estimated time: 1 hour
IAM Users
What's happening here
An IAM user is a long-lived identity with static credentials, an access key ID and secret that don't expire unless you rotate or delete them. This makes users appropriate for human engineers using the CLI with MFA, and inappropriate for applications or services (which should use roles). Creating a user does nothing by itself, a user with no policies attached has zero permissions anywhere. The create-login-profile step creates a console password separate from the programmatic access key.
# Create an IAM user
export IAM_USER="${LAB_PREFIX}-developer"
aws iam create-user --user-name $IAM_USER | jq '.User | {UserName, UserId, Arn, CreateDate}'
# Inspect the user, note: no policies, no groups, no permissions yet
aws iam get-user --user-name $IAM_USER | jq '.User | {UserName, Arn, CreateDate}'
# Check what policies are attached, should be empty
aws iam list-attached-user-policies --user-name $IAM_USER | jq '.AttachedPolicies'
aws iam list-user-policies --user-name $IAM_USER | jq '.PolicyNames'
# Create an access key for the user (programmatic access)
aws iam create-access-key --user-name $IAM_USER | jq '.AccessKey | {
UserName,
AccessKeyId,
Status,
note: "SecretAccessKey shown once, would be saved here in real usage"
}'Arn format is arn:aws:iam::{account}:user/{username}. Note there's no region. IAM is global. The user has no permissions. An access key exists but calling any AWS API with it right now returns AccessDenied.
# Verify the key exists and is Active
aws iam list-access-keys --user-name $IAM_USER | jq '.AccessKeyMetadata[] | {AccessKeyId, Status, CreateDate}'Checkpoint: user created, access key active, zero policies attached.
IAM Groups
What's happening here
A group is a container for users, it lets you attach policies once and have them apply to all members. Groups are not principals: you cannot assume a group, you cannot reference a group in a resource-based policy's Principal field, and a group cannot assume a role. Groups solely exist to make policy attachment manageable at scale. A user inherits all policies from every group they belong to, plus their own directly attached policies, all evaluated together.
# Create a group
export IAM_GROUP="${LAB_PREFIX}-developers"
aws iam create-group --group-name $IAM_GROUP | jq '.Group | {GroupName, GroupId, Arn}'
# Add the user to the group
aws iam add-user-to-group \
--group-name $IAM_GROUP \
--user-name $IAM_USER
# Confirm membership
aws iam get-group --group-name $IAM_GROUP | jq '{
group: .Group.GroupName,
members: [.Users[].UserName]
}'
# A user can belong to multiple groups
# Groups still have no policies attached, member user still has zero effective permissions
aws iam list-group-policies --group-name $IAM_GROUP | jq '.PolicyNames'
aws iam list-attached-group-policies --group-name $IAM_GROUP | jq '.AttachedPolicies'the group ARN format is arn:aws:iam::{account}:group/{groupname}. Groups themselves have no permissions, they're just a shortcut for attaching policies to multiple users at once.
IAM Roles
What's happening here
A role is fundamentally different from a user. It has no static credentials. Instead, a principal (a user, a service, another account) assumes the role via sts:AssumeRole: receives temporary credentials valid for 15 minutes to 12 hours, and uses those to make API calls. When the session expires, the credentials stop working and must be re-assumed. A role has two policy attachments: a trust policy (who is allowed to assume it) and permission policies (what the assumed session can do). The trust policy is mandatory, a role with an empty trust policy cannot be assumed by anyone.
# Create a role, must provide a trust policy at creation time
# This role trusts EC2 to assume it (for an instance profile / application role)
cat > /tmp/trust-ec2.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "ec2.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}
EOF
export IAM_ROLE="${LAB_PREFIX}-app-role"
aws iam create-role \
--role-name $IAM_ROLE \
--assume-role-policy-document file:///tmp/trust-ec2.json \
--description "Lab role trusted by EC2" | jq '.Role | {RoleName, RoleId, Arn, AssumeRolePolicyDocument}'
# Inspect the role, note the trust policy
aws iam get-role --role-name $IAM_ROLE | jq '.Role | {
RoleName,
Arn,
trust: .AssumeRolePolicyDocument
}'
# Role also has zero permission policies initially
aws iam list-attached-role-policies --role-name $IAM_ROLE | jq '.AttachedPolicies'the role ARN is arn:aws:iam::{account}:role/{rolename}. The trust policy says EC2 can assume this role, but the role has no permissions yet, so assuming it would give you a valid session with zero access.
Checkpoint: user, group, and role all created. All have zero effective permissions.
Break It: Groups Are Not Principals
# Try to create a role that trusts a group, this is not valid
cat > /tmp/trust-group-attempt.json << EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::${AWS_ACCOUNT}:group/${IAM_GROUP}"},
"Action": "sts:AssumeRole"
}]
}
EOF
aws iam create-role \
--role-name "${LAB_PREFIX}-bad-role" \
--assume-role-policy-document file:///tmp/trust-group-attempt.json 2>&1 | head -8MalformedPolicyDocument: groups cannot be principals in trust policies (or any IAM Principal field). Only users, roles, services, and accounts can be principals. This is a common mistake when people try to grant an entire team access to assume a role.
Policies: Inline, Managed, AWS-Managed
Goal
Write a policy from scratch and understand its structure. Attach an AWS-managed policy, a customer-managed policy, and an inline policy. Understand when to use each type and why inline policies are usually the wrong choice.
Estimated time: 1–2 hours
Anatomy of a policy
What's happening here
Every IAM policy is a JSON document. The structure is always:
Version: always"2012-10-17"(there was a 2008 version; always use 2012)Statement: a list of one or more permission statements- Each statement has:
Effect(Allow/Deny),Action(what API calls),Resource(what ARNs), and optionallyCondition
Actions are in the format service:ApiCall: e.g. s3:GetObject, ec2:DescribeInstances, iam:CreateRole. Wildcards work: s3:* means all S3 actions, s3:Get* means all S3 Get actions. Resources are ARNs or "*" (meaning all resources of the implied type). A statement with "Resource": "*" applies to all resources globally.
# Dissect an AWS-managed policy to understand the structure
aws iam get-policy \
--policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess | jq '.Policy | {PolicyName, Arn, Description, AttachmentCount}'
# Get the actual policy document
DEFAULT_VERSION=$(aws iam get-policy \
--policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess \
--query 'Policy.DefaultVersionId' --output text)
aws iam get-policy-version \
--policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess \
--version-id $DEFAULT_VERSION | jq '.PolicyVersion.Document'AmazonS3ReadOnlyAccess allows s3:Get* and s3:List* on "Resource": "*". The wildcard on resource means it applies to every S3 bucket in every account. AWS-managed policies tend to be broad, they're convenient but rarely least-privilege.
Customer-managed policies
What's happening here
A customer-managed policy lives in your account under arn:aws:iam::{account}:policy/{name}. You own it, you version it, and you can attach it to multiple identities simultaneously. Every time you update it, IAM creates a new version (max 5 versions retained). You set one version as the default. The key advantage over inline policies: a managed policy is reusable and auditable independently of the identities it's attached to. You can see all its attachments, compare versions, and remove it from all principals in one detach operation.
# Write a scoped policy: read-only access to a specific S3 prefix
cat > /tmp/billing-reader-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListBillingBucket",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::${LAB_PREFIX}-billing",
"Condition": {
"StringLike": {"s3:prefix": ["invoices/*", "reports/*"]}
}
},
{
"Sid": "ReadBillingObjects",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": [
"arn:aws:s3:::${LAB_PREFIX}-billing/invoices/*",
"arn:aws:s3:::${LAB_PREFIX}-billing/reports/*"
]
}
]
}
EOF
export MANAGED_POLICY_ARN=$(aws iam create-policy \
--policy-name "${LAB_PREFIX}-billing-reader" \
--policy-document file:///tmp/billing-reader-policy.json \
--description "Read-only access to billing invoices and reports" \
--query 'Policy.Arn' --output text)
echo "Policy ARN: $MANAGED_POLICY_ARN"
# Inspect it
aws iam get-policy --policy-arn $MANAGED_POLICY_ARN | jq '.Policy | {PolicyName, Arn, VersionId: .DefaultVersionId, AttachmentCount}'# Attach to the group (all group members inherit this)
aws iam attach-group-policy \
--group-name $IAM_GROUP \
--policy-arn $MANAGED_POLICY_ARN
# Also attach directly to the role
aws iam attach-role-policy \
--role-name $IAM_ROLE \
--policy-arn $MANAGED_POLICY_ARN
# Confirm attachment count updated
aws iam get-policy --policy-arn $MANAGED_POLICY_ARN | jq '.Policy.AttachmentCount'
# See all entities this policy is attached to
aws iam list-entities-for-policy --policy-arn $MANAGED_POLICY_ARN | jq '{
groups: [.PolicyGroups[].GroupName],
users: [.PolicyUsers[].UserName],
roles: [.PolicyRoles[].RoleName]
}'AttachmentCount is now 2. The policy is attached to the group (which the user is in) AND the role. This is the managed policy advantage, one policy document, multiple principals.
Checkpoint: policy created and attached to both group and role.
Policy versioning
What's happening here
Every create-policy-version call creates a new version. IAM keeps up to 5 versions. The default version is what all attached identities use. You can roll back to any retained version by changing the default. This makes managed policies auditable (you can see what changed and when. Inline policies have no versioning) when you update them, the old document is gone.
# Update the policy, add SQS read access
cat > /tmp/billing-reader-v2.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListBillingBucket",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::${LAB_PREFIX}-billing",
"Condition": {
"StringLike": {"s3:prefix": ["invoices/*", "reports/*"]}
}
},
{
"Sid": "ReadBillingObjects",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": [
"arn:aws:s3:::${LAB_PREFIX}-billing/invoices/*",
"arn:aws:s3:::${LAB_PREFIX}-billing/reports/*"
]
},
{
"Sid": "ReadBillingQueue",
"Effect": "Allow",
"Action": ["sqs:ReceiveMessage", "sqs:GetQueueAttributes"],
"Resource": "arn:aws:sqs:*:${AWS_ACCOUNT}:${LAB_PREFIX}-billing-*"
}
]
}
EOF
aws iam create-policy-version \
--policy-arn $MANAGED_POLICY_ARN \
--policy-document file:///tmp/billing-reader-v2.json \
--set-as-default
# List all versions
aws iam list-policy-versions \
--policy-arn $MANAGED_POLICY_ARN | jq '.Versions[] | {VersionId, IsDefaultVersion, CreateDate}'# Roll back to v1 (simulate a bad policy rollback)
aws iam set-default-policy-version \
--policy-arn $MANAGED_POLICY_ARN \
--version-id v1
# Confirm the default is now v1 again
aws iam get-policy \
--policy-arn $MANAGED_POLICY_ARN | jq '.Policy.DefaultVersionId'
# Restore v2 as default
aws iam set-default-policy-version \
--policy-arn $MANAGED_POLICY_ARN \
--version-id v2Inline policies
What's happening here
An inline policy is embedded directly inside a single user, group, or role. It cannot be reused, it has no ARN, it has no version history, and it is deleted automatically when the identity is deleted. The only legitimate use case for inline policies is when you want a strict 1:1 binding between a permission and a principal, e.g. a break-glass emergency deny on a specific role that must be manually removed from that role. For everything else, use managed policies.
# Attach an inline policy directly to the role
# (This is an emergency break-glass deny, inline because it MUST live and die with the role)
cat > /tmp/deny-prod-delete.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "DenyAllProdDeletes",
"Effect": "Deny",
"Action": [
"s3:DeleteObject",
"s3:DeleteBucket",
"dynamodb:DeleteTable",
"rds:DeleteDBInstance"
],
"Resource": "*",
"Condition": {
"StringEquals": {"aws:ResourceTag/env": "prod"}
}
}]
}
EOF
aws iam put-role-policy \
--role-name $IAM_ROLE \
--policy-name "deny-prod-destructive" \
--policy-document file:///tmp/deny-prod-delete.json
# List all policies on the role, note two types appear separately
echo "=== Managed policies ==="
aws iam list-attached-role-policies --role-name $IAM_ROLE | jq '[.AttachedPolicies[].PolicyName]'
echo "=== Inline policies ==="
aws iam list-role-policies --role-name $IAM_ROLE | jq '.PolicyNames'
# Retrieve the inline policy document
aws iam get-role-policy \
--role-name $IAM_ROLE \
--policy-name "deny-prod-destructive" | jq '.PolicyDocument'managed and inline policies appear through different API calls (list-attached-role-policies vs list-role-policies). This is a common auditing gotcha, tooling that only checks attached managed policies will miss inline policies entirely.
Checkpoint: role has one managed policy (attached) and one inline policy.
Break It: Policy Version Limit
# Create 3 more versions to hit the 5-version limit
for i in 3 4 5; do
cat > /tmp/v${i}.json << EOF
{
"Version": "2012-10-17",
"Statement": [{"Sid": "Version${i}", "Effect": "Allow", "Action": "s3:ListAllMyBuckets", "Resource": "*"}]
}
EOF
aws iam create-policy-version \
--policy-arn $MANAGED_POLICY_ARN \
--policy-document file:///tmp/v${i}.json \
--set-as-default
echo "Created version $i"
done
# Now try to create a 6th version, should fail
aws iam create-policy-version \
--policy-arn $MANAGED_POLICY_ARN \
--policy-document file:///tmp/billing-reader-policy.json 2>&1 | head -5LimitExceeded: max 5 versions. Before creating a new one, delete a non-default old version: aws iam delete-policy-version --policy-arn $MANAGED_POLICY_ARN --version-id v3. This limit is why some teams automate version cleanup in their CI pipeline.
# Clean up excess versions, delete non-default old versions to get back to 2
for VID in v3 v4 v5; do
aws iam delete-policy-version \
--policy-arn $MANAGED_POLICY_ARN \
--version-id $VID && echo "Deleted $VID"
done
# Restore v2 as the default
aws iam set-default-policy-version --policy-arn $MANAGED_POLICY_ARN --version-id v2The Evaluation Engine: How IAM Decides Allow or Deny
Goal
Understand the exact decision logic IAM runs on every API call. Run iam:SimulatePrincipalPolicy to test policies without real API calls. Observe how implicit deny, explicit allow, and explicit deny interact.