skip to content
InfluentCoder
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:

  1. Everything is denied by default. A request needs an Allow from somewhere.
  2. An explicit Deny that matches the request always wins, wherever it comes from.
  3. Within one account, an Allow in 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.
  4. Across accounts, both sides must allow it: the caller’s identity policy and the resource policy in the other account.
  5. 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:

Terminal window
aws sts get-caller-identity

Which policies are attached to that role (managed and inline):

Terminal window
aws iam list-attached-role-policies --role-name MyRole
aws iam list-role-policies --role-name MyRole

And the content of a managed policy, for its current version:

Terminal window
aws iam get-policy --policy-arn arn:aws:iam::111122223333:policy/MyPolicy
aws iam get-policy-version --policy-arn arn:aws:iam::111122223333:policy/MyPolicy --version-id v3

To test a specific request without making it, the policy simulator can be used from the CLI (or in the console):

Terminal window
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.csv

It 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