Reading AWS IAM policies from first principles
/ 6 min read
Table of Contents
I wrote this post because I struggled to understand what I actually had access to in AWS, or which service could access what, especially in a complex environment where IAM was not managed by us. Most of the time, I was reading policies written by someone else, and trying to work out what they allowed.
So this post goes back to the first principles: how AWS decides whether a request is allowed, and how to read a policy one statement at a time.
What a request looks like to AWS
Every API call is evaluated as a request with a few parts:
- the principal making it (a user, a role session, or an AWS service),
- the action (
s3:GetObject,kms:Decrypt, …), - the resource it applies to, identified by its ARN,
- and some context, like the source IP, the time, or whether MFA was used.
A policy statement matches a request when its Action, Resource (and
Principal, for policies attached to a resource) match, and all its
Conditions are true. Reading a policy is mostly about working out which
requests each statement matches.
How AWS decides
Everything else follows from these rules:
- Everything is denied by default. A request needs an
Allowfrom somewhere. - An explicit
Denythat matches the request always wins, wherever it comes from. - Within one account, an
Allowin either the identity-based policy (attached to the user or role) or the resource-based policy (like an S3 bucket policy) is enough. KMS keys and role trust policies are exceptions: the key policy (or the role’s trust policy) has to allow it. For KMS, the key policy can also delegate to IAM policies, but without that, an IAM policy alone has no effect. - Across accounts, both sides must allow it: the caller’s identity policy and the resource policy in the other account.
- Service control policies (SCPs) at the AWS Organizations level, permissions boundaries and session policies don’t grant anything. They set a maximum: the request has to be allowed by them as well.
Rule 5 is the one that matters when IAM is managed by another team. Your role can have a policy that clearly allows an action, and the request can still be denied by an SCP that you can’t see from your account.
Reading a policy statement by statement
Let’s take a simple identity-based policy:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:PutObject", "s3:GetObject"], "Resource": "arn:aws:s3:::example-bucket/*" }, { "Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::example-bucket/*" } ]}For each statement, the questions are: which actions, on which resources, under which conditions?
The first statement allows reading and writing objects in example-bucket.
arn:aws:s3:::example-bucket/* matches the objects, not the bucket itself, so
it doesn’t allow s3:ListBucket. Listing the bucket would need
arn:aws:s3:::example-bucket (without /*) as a resource.
The second statement denies deleting objects. Because of rule 2, even if
another policy attached to the same role allows s3:*, deleting objects in
this bucket stays denied.
A policy that is easy to misread
This one restricts access to an IP range:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "ListAndGetS3", "Effect": "Allow", "Action": ["s3:ListBucket", "s3:GetObject"], "Resource": [ "arn:aws:s3:::example-bucket", "arn:aws:s3:::example-bucket/*" ] }, { "Sid": "RestrictIP", "Effect": "Deny", "Action": "s3:*", "Resource": "arn:aws:s3:::example-bucket/*", "Condition": { "NotIpAddress": { "aws:SourceIp": "203.0.113.0/24" } } } ]}At first sight, it reads as “allow listing and reading, but deny everything
from outside 203.0.113.0/24”. Let’s go statement by statement instead.
The deny’s Action is s3:*, but its Resource is only the objects
(example-bucket/*). s3:ListBucket applies to the bucket
(example-bucket), so the deny never matches it. The result: reading objects
is restricted to the IP range, but listing the bucket works from anywhere.
To restrict both, the deny needs both resources:
"Resource": [ "arn:aws:s3:::example-bucket", "arn:aws:s3:::example-bucket/*"]There is a second catch with aws:SourceIp. Requests that an AWS service
makes on your behalf (for example Athena reading the bucket for you) don’t come
from your IP range, so this deny blocks them too. The AWS documentation uses
the aws:ViaAWSService condition key to exclude those calls:
"Condition": { "NotIpAddress": { "aws:SourceIp": "203.0.113.0/24" }, "Bool": { "aws:ViaAWSService": "false" }}All the conditions in a statement must be true for it to match, so calls made by AWS services on your behalf are no longer denied.
Requests through a VPC endpoint are a different case: they don’t have an
aws:SourceIp at all, so this deny still blocks them. For those, the AWS
documentation recommends VPC-specific keys like aws:VpcSourceIp or
aws:SourceVpce.
Which service can access what
Resource-based policies are where services get access. Here is the kind of bucket policy that lets CloudTrail write its logs into a bucket:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "cloudtrail.amazonaws.com" }, "Action": "s3:PutObject", "Resource": "arn:aws:s3:::example-trail-logs/AWSLogs/111122223333/*", "Condition": { "StringEquals": { "s3:x-amz-acl": "bucket-owner-full-control", "aws:SourceArn": "arn:aws:cloudtrail:us-east-1:111122223333:trail/main" } } } ]}Reading it the same way: the principal is the CloudTrail service, it can only
write objects, only under AWSLogs/111122223333/, and only when the request
comes from the trail main in account 111122223333.
The aws:SourceArn condition matters: without it, any CloudTrail trail, in
any account, could write to this bucket, since they all use the same service
principal. When a policy grants something to a service principal, look for
aws:SourceArn or aws:SourceAccount to know who that service is acting for.
Finding out what you have access to
When IAM is managed by someone else, these are the first things to check.
Who am I? The answer is often an assumed role, not a user:
aws sts get-caller-identityWhich policies are attached to that role (managed and inline):
aws iam list-attached-role-policies --role-name MyRoleaws iam list-role-policies --role-name MyRoleAnd the content of a managed policy, for its current version:
aws iam get-policy --policy-arn arn:aws:iam::111122223333:policy/MyPolicyaws iam get-policy-version --policy-arn arn:aws:iam::111122223333:policy/MyPolicy --version-id v3To test a specific request without making it, the policy simulator can be used from the CLI (or in the console):
aws iam simulate-principal-policy \ --policy-source-arn arn:aws:iam::111122223333:role/MyRole \ --action-names s3:GetObject \ --resource-arns arn:aws:s3:::example-bucket/report.csvIt only evaluates the policies it is given or can read, though. For example, it can’t simulate resource-based policies (like a bucket policy) when the source is a role, so “allowed” in the simulator is not a guarantee.
Some of these calls may be denied too, if the team managing IAM doesn’t allow
reading IAM. In that case, the AccessDenied messages themselves help: many
services now say which type of policy denied the request, for example “with an
explicit deny in a service control policy”, which at least tells you who to
ask.
Additional resources
- Policy evaluation logic, the full version of the rules above.
- Actions, resources, and condition keys for AWS services,
to check which resource type an action applies to (like
s3:ListBucketon the bucket). - The IAM policy simulator.