The chapter every later chapter assumes. Users, roles, policies, STS - and the single biggest mental shift coming from AAD: roles aren't users with hats, they're identities anyone allowed can wear. If something later breaks with "access denied", odds are this chapter has the answer.
AAD treats users, groups, and service principals as variations on one theme: "an identity with permissions attached". IAM splits that into two fundamentally different things - users (long-lived) and roles (assumed temporarily) - and adds resource-based policies (permissions attached to the resource, not the identity). The model is more flexible, but more pieces means more places for confusion. We'll build the picture one layer at a time.
| Concept | Azure / AAD | AWS / IAM |
|---|---|---|
| Human user | AAD user | IAM Identity Center user (preferred) or IAM User (legacy) |
| Group | AAD security group | IAM Group (only contains users, not roles) |
| App/workload identity | Service Principal / Managed Identity | IAM Role (assumed by services) |
| Long-lived secret | SP client secret | IAM User access key (avoid) |
| Permissions | RBAC role (built-in or custom) assigned at a scope | IAM Policy (JSON) attached to identity or resource |
| Built-in roles | Owner, Contributor, Reader, ~70 others | AWS-managed policies: AdministratorAccess, ReadOnlyAccess, ~700 others |
| Permission scope | Mgmt group / sub / RG / resource | Account-wide by default; narrowed in the policy itself via Resource ARNs / Conditions |
| Resource-attached perms | rare - mostly Key Vault access policies, storage SAS | Resource-based policies (S3 bucket policy, KMS key policy, SQS, Lambda, etc.) - first-class |
| Federation | AAD is the IDP by default | External IDP (Entra, Okta, Google) feeds Identity Center; or OIDC for workloads |
| Workload identity for k8s | Azure AD Workload Identity (AKS) | IRSA - IAM Roles for Service Accounts (EKS) |
| Audit trail | Activity Log + AAD sign-in logs | CloudTrail (all API calls, all services) |
| "Lock down even admins" | Azure Policy + management lock | SCPs (Service Control Policies) + permission boundaries |
AssumeRole* API call goes through STS. The service itself is free - you pay only for what the tokens then go and DO.s3:GetObject, ec2:RunInstances. Wildcards work (s3:*) but expand at evaluation time, not at paste time - so a policy you wrote three years ago automatically picks up new actions a service adds. Sometimes good, sometimes very not good."*" means "any resource of any type that this action could apply to" - which is a much broader claim than it looks.Every IAM evaluation answers one question: "Can principal perform action on resource, under these conditions?"
Principal field - the principal is implicit (it's whoever the policy is attached to). Resource-based policies do include a Principal field, because the resource needs to specify who's allowed in. We'll see both shortly.
Long-lived identity. Has a name (alice), optional password (for console), optional access key (for CLI). Permissions attached directly.
Closest Azure analog: an AAD user with assigned RBAC roles.
Modern AWS guidance: avoid IAM Users for humans. Use Identity Center.
Identity with no credentials of its own. Has a name (EC2-S3-Reader), an "anyone allowed to wear me" rule (trust policy), and a permissions policy. Assumed temporarily via STS.
Closest Azure analog: Managed Identity, but more general - roles can be assumed by users, services, federated identities, or other AWS accounts.
A container for users only. Attach policies to the group, all member users inherit them. Cannot contain roles. Cannot be a principal in a policy.
Closest Azure analog: AAD security group. Same purpose - bulk permission assignment.
"How does my code on a VM/container get credentials to talk to other services?" is where the Azure-AWS gap is most visible.
# 1. Enable managed identity on the VM
az vm identity assign \
--name myvm \
--resource-group rg-app
# 2. Grant it permissions
az role assignment create \
--assignee-object-id <principal-id> \
--role "Storage Blob Data Reader" \
--scope "/subscriptions/.../mystg"
# 3. Code on the VM "just works"
# SDK auto-discovers credentials
# from IMDS endpoint
# 1. Create a role that EC2 can assume
aws iam create-role \
--role-name MyAppRole \
--assume-role-policy-document file://trust.json
# 2. Attach permissions
aws iam attach-role-policy \
--role-name MyAppRole \
--policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
# 3. Attach role to instance via instance profile
aws ec2 associate-iam-instance-profile \
--instance-id i-1234 \
--iam-instance-profile Name=MyAppRole
# Code "just works" - SDK pulls creds
# from IMDSv2 endpoint http://169.254.169.254
Conceptually identical end-state, but AWS makes the role visible as a thing you can reuse. The same MyAppRole can be assumed by ten different EC2 instances, by Lambda functions, or by your laptop (if you allow it in the trust policy).
resource "azurerm_user_assigned_identity" "app" {
name = "app-mi"
resource_group_name = "rg-app"
location = "eastus"
}
resource "azurerm_role_assignment" "app_storage" {
scope = azurerm_storage_account.s.id
role_definition_name = "Storage Blob Data Reader"
principal_id = azurerm_user_assigned_identity.app.principal_id
}
resource "aws_iam_role" "app" {
name = "app-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "app_s3" {
role = aws_iam_role.app.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
}
Principal: { Service: "ec2.amazonaws.com" } clause is saying "EC2 itself is allowed to assume this role on behalf of an instance it owns." This is the explicit mechanism. In Azure you just check a checkbox; in AWS you write the trust contract. More verbose, more flexible.
This is the concept that has no direct AAD analog. AAD permissions are almost always attached to the identity. AWS lets you attach permissions to either side - and many resources can be granted access without touching the requester's identity at all.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::reports-bucket",
"arn:aws:s3:::reports-bucket/*"
]
}]
}
Attached to alice (user) or EC2-S3-Reader (role). It grants alice the ability to read from that specific bucket.
"Version": "2012-10-17" at the top of every IAM policy? That's not a versioning system for your policy - it's the schema version of the policy language. AWS has only ever shipped two versions: 2008-10-17 (no variables, no per-resource conditions) and 2012-10-17 (everything we use today). The string has stayed frozen for 13+ years because changing it would break every policy on Earth. Always paste the current value; AWS treats anything else as the older schema.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::222222222222:user/alice" },
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::reports-bucket",
"arn:aws:s3:::reports-bucket/*"
]
}]
}
Note the new Principal field. The bucket itself maintains a list of who's allowed in. This makes cross-account access possible without sharing credentials - account A's bucket can list account B's role as a principal, and IAM resolves it without anyone needing dual identities.
| Service | Resource policy supported? | Used for |
|---|---|---|
| S3 (buckets) | ✓ yes - bucket policy | Cross-account access, public read, signed URLs |
| KMS (keys) | ✓ yes - key policy | Required! Key policy controls access, not user policies alone |
| SQS, SNS, EventBridge | ✓ yes | Cross-account event delivery |
| Lambda | ✓ yes - resource policy | Allowing API Gateway, S3 events to invoke the function |
| Secrets Manager | ✓ yes | Cross-account secret sharing |
| IAM Roles | ✓ yes - the trust policy | Who's allowed to assume the role |
| EC2, RDS, DynamoDB | ✗ no | Identity policies only |
A team adopts "least privilege" zealously. They provision a separate customer-managed KMS key (CMK) per microservice (say 50 services) so each KMS key policy can list only that service's role. They also enable S3 default-encryption with the per-service CMK on every bucket. Three months later, the KMS bill is several hundred dollars per month. The team is confused - they were following best practice. What's happening?
1. The flat $1/key/month. 50 CMKs × $1 = $50/month baseline, before any actual usage. Tiny per-key, real in aggregate.
2. KMS API calls. Every S3 read/write of a CMK-encrypted object triggers a KMS Decrypt or GenerateDataKey call. At $0.03 per 10K calls, a service doing 10M S3 ops/month spends $30/service/month on KMS alone. Across 50 services that's $1,500. Even with S3 Bucket Keys caching (which reduces but doesn't eliminate calls), bills routinely land $400-$1,500/month.
Fix: use AWS-managed keys (free, automatic rotation) as the default for at-rest encryption. Reserve CMKs for the narrow cases where you actually need them: explicit key rotation control, cross-account encryption, or compliance requirements. Don't conflate IAM least-privilege (a permissioning concept) with per-service KMS keys (an unnecessary cost multiplier).
If you want the audit benefits of CMKs without the call costs, enable S3 Bucket Keys. It uses one envelope key per bucket per day instead of one per object - usually cuts KMS calls by 99%.
sts:AssumeRole
Every IAM role has two policies hanging off it:
What can the role do? (Once assumed.) Standard identity-based policy - Allow s3:GetObject on bucket X.
Who can assume the role? A resource-based policy attached to the role itself. Lists allowed principals.
Code Spaces was a small source-code-hosting startup, like a competitor to early GitHub. They ran their entire stack on a single AWS account using root credentials for admin access. No MFA on the root user. Their backups lived in the same account.
An attacker phished the root credentials, logged into the AWS console, and began deleting resources. When the Code Spaces team noticed and tried to log in to stop them, the attacker demanded a ransom. The team refused. The attacker deleted every EC2 instance, every S3 bucket, every RDS database, and every backup snapshot - all in the same account, all under the same credentials.
Code Spaces closed within 24 hours. Customers lost their repos. The company never recovered.
Lessons: (1) Root credentials get a hardware MFA key and live in a safe, not on a laptop. (2) Backups live in a different account in a different region with different IAM. (3) Use SCPs at the org level to make destructive actions impossible even with valid credentials. We cover the SCP setup in chapter 12.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111111111111:root" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "shared-secret-for-confused-deputy" },
"Bool": { "aws:MultiFactorAuthPresent": "true" }
}
}]
}
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}]
}
A teammate set up an IAM role for a Lambda function. The Lambda's execution role has the right permissions policy (allows s3:GetObject). But when the function runs, every S3 call returns AccessDenied. They paste their trust policy below. What's wrong?
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principle": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
"Principle" should be "Principal".
IAM's JSON parser doesn't reject unknown keys - it ignores them. So the trust policy has no valid Principal, which means nothing can assume the role. Lambda tries, gets denied, and the function runs with no AWS credentials at all. Every API call fails with AccessDenied.
This typo is so common that AWS now warns about it in the IAM console's policy validator - but the API still accepts it silently. Always run aws iam simulate-principal-policy or test the role with aws sts assume-role before declaring victory.
# Either via profile (we saw this in chapter 1):
aws s3 ls --profile prod-deploy # silently runs sts:AssumeRole
# Or explicitly:
aws sts assume-role \
--role-arn arn:aws:iam::333333333333:role/DeployerRole \
--role-session-name "sunilt-cli-session"
# Returns AccessKeyId, SecretAccessKey, SessionToken, Expiration
# Export these as env vars and you're now acting as the role
Every AWS API call goes through the same decision tree. Memorize it - 90% of "why is access denied?" investigations end here.
| Rule | Means |
|---|---|
| Default deny | If nothing explicitly allows the action, it's denied. You must add an Allow somewhere. |
| Explicit deny wins | One Effect: Deny in any applicable policy overrides any number of Allows. |
| Identity OR resource is enough | For same-account requests, an Allow in either side is sufficient. Cross-account requests need both. |
| Permission boundary caps | If a boundary exists on the identity, the effective permissions are the intersection of policy and boundary. |
| SCPs are hard guardrails | If your org's SCP denies it, no one in your account can do it - not even the root user. |
A permission boundary is a cap on what an identity can do, regardless of its attached policies. Common use case: a developer can create IAM roles for their app, but the roles they create can never exceed the boundary's allow list. This is how you safely delegate IAM-creation rights.
Chapter 10 deep-dives EKS. But it's worth previewing the workload-identity pattern now because it's the cleanest example of how IAM roles + OIDC trust come together.
Goal: create an IAM role that your current user/SSO session is allowed to assume. Then assume it and verify you got new credentials. End-to-end demo of the AssumeRole flow.
Step 1. Get your current caller ARN (we'll trust this principal in the role).
aws sts get-caller-identity
# Note the "Arn" value. Example: arn:aws:sts::123456789012:assumed-role/DeveloperAccess/sunilt
Step 2. Write a trust policy that allows that ARN to assume a new role.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com/AWSReservedSSO_DeveloperAccess_*" },
"Action": "sts:AssumeRole"
}]
}
Step 3. Create the role, attach a read-only policy.
aws iam create-role \
--role-name PlaygroundReader \
--assume-role-policy-document file://trust.json
aws iam attach-role-policy \
--role-name PlaygroundReader \
--policy-arn arn:aws:iam::aws:policy/ReadOnlyAccess
Step 4. Assume the role.
aws sts assume-role \
--role-arn arn:aws:iam::123456789012:role/PlaygroundReader \
--role-session-name lab-session
# Exports the temp creds:
export AWS_ACCESS_KEY_ID=ASIA...
export AWS_SECRET_ACCESS_KEY=...
export AWS_SESSION_TOKEN=...
# Re-check identity - you're now the role
aws sts get-caller-identity
# Arn: arn:aws:sts::123456789012:assumed-role/PlaygroundReader/lab-session
Step 5. Clean up (no orphan IAM resources).
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
aws iam detach-role-policy --role-name PlaygroundReader \
--policy-arn arn:aws:iam::aws:policy/ReadOnlyAccess
aws iam delete-role --role-name PlaygroundReader
What you learned: roles are not magic. They're identities you can build, trust to whoever you want, give permissions, and step into. The same flow underpins EC2 instance profiles, Lambda execution roles, IRSA, and cross-account access.
IAM is the chapter that pays back. Sit with each one before revealing.
1. An IAM Group can contain...
2. To grant a role in account A the ability to read an S3 bucket in account B, what's the minimum you need?
s3:GetObject on the bucket ARN. The bucket in B must have a bucket policy listing the role's ARN as a Principal. Either side alone is not enough - this is the "both must say yes" rule for cross-account.
3. What's the difference between a trust policy and a resource-based policy?
sts:AssumeRole on it. The name is historical - AWS uses "trust policy" specifically for roles because it sounds clearer than "the resource-based policy attached to this role".
4. A user has an identity policy that allows "*:*" (everything). You then attach a permission boundary that allows only "s3:Get*". What can the user do?
s3:Get* actions - effective permissions are the intersection.s3:Get* plus anything else if MFA is enabled.s3:GetObject, s3:GetObjectVersion, etc - but not s3:PutObject (in identity but not boundary) and not ec2:* (same).
5. An admin runs aws kms decrypt on a KMS key. They have the AWS-managed AdministratorAccess policy attached. The call returns AccessDenied. What's the most likely cause?
AdministratorAccess doesn't grant access to a key unless the key's own resource policy (the "key policy") allows it. Most other resources fall back to the identity policy if no resource policy exists, but KMS keys always have a key policy and it's authoritative. Region mismatches also happen but they raise NotFoundException, not AccessDenied.
Principal field. The terminology can confuse - a "trust policy" is just a resource-based policy attached to an IAM role specifically. Same JSON, different name.
"Principal": { "AWS": "arn:aws:iam::ACCT:root" } doesn't mean "root user". It means "any IAM principal in account ACCT that has an applicable identity policy". The :root there refers to the account root, used as a shorthand for "trust this account's IAM to govern access". Trips up almost everyone.
ReadOnlyAccess is an account-wide managed policy; narrowing it requires writing a custom policy with explicit Resource ARNs. Plan to write more JSON than you did in Azure.
aws:PrincipalOrgID condition is your friend for cross-account work. Instead of listing each account ARN in a resource policy, you can say "any principal in my Organization" with one condition. Discover this early; it saves significant policy bloat.
Picking up from chapter 1, where you set up the compass CLI profile. In this chapter's slice we create the IAM role that gfn-reports will eventually use. The role starts empty - no permissions yet. Future chapters add S3, DynamoDB, SQS, and KMS permissions as those services come online.
gfn-reports-role
Why now: gfn-reports will run as a Lambda in chapter 4. Lambdas need an execution role before deployment. We'll prepare that scaffolding now and keep adding permissions chapter by chapter.
cat > /tmp/compass-trust.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
EOF
# Create the role - no permissions attached yet
aws iam create-role \
--role-name gfn-reports-role \
--assume-role-policy-document file:///tmp/compass-trust.json \
--description "Execution role for the gfn-reports service (Project Compass)" \
--tags Key=Project,Value=Compass Key=ManagedBy,Value=learn-aws \
--profile compass
# Attach AWS-managed basic Lambda logging policy
# (chapter 9 will swap this for a tighter custom one)
aws iam attach-role-policy \
--role-name gfn-reports-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole \
--profile compass
# Verify
aws iam get-role --role-name gfn-reports-role --profile compass
aws iam list-attached-role-policies --role-name gfn-reports-role --profile compass
The role now exists. It trusts the Lambda service, can write CloudWatch logs, and is tagged so we can find it later. We deliberately did not attach AdministratorAccess - chapter 3 onward will add only what's needed, when it's needed.
aws iam delete-role --role-name gfn-reports-role after detaching all policies - we'll walk that in chapter 12.
sts:AssumeRole.| Trap | Fix |
|---|---|
| "Access denied" on a service I "should" have access to | Run get-caller-identity. Check both identity policy and resource policy. Check for explicit Denys. |
| "My role can't assume another role" | Role chaining needs the target trust policy to list your role as a principal, AND your role's identity policy to allow sts:AssumeRole. |
| "KMS decrypt fails" | Check the key policy, not just the user policy. Both must allow. |
| "My pod can't access S3" | You probably need IRSA. Chapter 10 walks the setup. |