AWS IAM Permission Boundaries Has A Caveat That May Surprise You

Ben Kehoe
5 min readSep 29, 2021

AWS IAM Permission Boundaries Has A Caveat That May Surprise You

Note: this article was originally published on September 1, 2021. It erroneously stated that the resource policy could reference the role, rather than the assumed role session. I removed it pending an update. The confusion, complexity, and poor documentation led me to publish I Trust AWS IAM to Secure My Applications. I Don’t Trust the IAM Docs to Tell Me How. The updated version was published on September 29.

Permissions Boundaries provide security admins a way to strike a balance between granting all the permissions a user might need and only granting them the permissions they are explicitly using. A permission boundary is a policy set on an IAM principal (User or Role), but the permissions granted by that policy are not immediately granted to the principal. Instead, they form the space of possibly allowed permissions. The principal needs a normal policy granting the permission as well to have that permission. If a policy attached to the principal has permissions that are not explicitly allowed by the permissions boundary, that permission is not granted. So the permissions on the principal are the intersection of the two.

You might think this is a great way to specify and observe exactly what an IAM principal can possibly perform, but unfortunately, there’s a serious limitation to this. The first clue to this is the flowchart for (non-cross-account) access policy evaluation from the IAM documentation:

Resource policies can short-circuit the evaluation before the permissions boundary is evaluated

Note that resource policies are evaluated before permissions boundaries, and an Allow in the resource policy would result in the access being granted. This means that even if the action isn’t within the principal’s permissions boundary, a resource policy (in the same account) can potentially unilaterally grant access not allowed by a permissions boundary!

However, the story is a little more complicated. First of all, a Deny always wins, so nothing can override statements with explicit Deny in the permissions boundary. And it doesn’t apply to IAM Users directly.

It turns out that IAM Roles, and particular legacy aspect of IAM Users, have a sort of two-stage evaluation process that allows resource policies to go outside a permissions boundary. We’ll talk about roles first.

We often refer to IAM Roles as “principals” (indeed, I do above). A role ARN can be used in the Principal section of an IAM policy. But in a certain sense, they are not principals. In particular, there are no credentials associated with an IAM Role. You can’t call an API as a role. You have to assume the role, resulting in an assumed role session, which has credentials, to call APIs. It is this assumed role session that is the principal.

Evaluating the policies for an assumed role session is a two-step process. First, the role is evaluated for access, and then the session is evaluated. An therein lies the trick: the permissions boundary only applies to the role, not the session. So if, for a particular API call, the role is evaluated for access and the permissions boundary doesn’t grant access but does not explicitly deny, we haven’t found an Allow but haven’t found a Deny, so evaluation turns to the session. Then, if a resource policy grants permission to the assumed role session, this is not checked against the permissions boundary, and access is granted.

The resource policy can take two forms. Here we use as an example an S3 bucket policy.

The first is to use the assumed role session in the Principal section:

{
"Version": "2012-10-17",
"Statement": [
{
"Principal": "arn:aws:sts::123456789012:assumed-role/MyRole/SessionName",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*"
}
]
}

The second is to use a condition on the aws:PrincipalArn context key to reference the role. This context key is set to the role ARN for assumed role principals. From my testing, this appears to only work when Principal is set to * (or, obviously, the role session name), and not when Principal is set to the AWS account (i.e., {"AWS": "arn:aws:iam::123456789012:root"}).

{
"Version": "2012-10-17",
"Statement": [
{
"Principal": "*",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"StringEquals": {
"aws:PrincipalArn": "arn:aws:iam::123456789012:role/MyRole"
}
}
}
]
}

I mentioned above this applies in an additional scenario. While an IAM User is (unlike an IAM Role itself) a principal, the sts.GetFederationToken API returns credentials for a “federated user” (this is different from the notion of “federated user” when using sts.AssumeRoleWithSAML or sts.AssumeRoleWithWebIdentity, which produce an assumed role session like AssumeRole); this works very much like an assumed role session, and similarly can get around the permissions boundary attached to the user. Note that sts.GetFederationToken predates roles, and roles should be used in preference to it.

This behavior is very counterintuitive, and I view it as a major mistake in the implementation of permissions boundaries, but it’s something we have to live with. So how can we use permissions boundaries to accomplish what we wanted: definitively setting the set of permissions a principal is allowed to have?

The only way to accomplish this is in the first column of the flowchart: an explicit Deny anywhere takes precedence over any Allow. So if a permissions boundary denies all actions that are outside the boundary, a resource policy will not be able to grant it. Instead of laying out what is allowed, lay out what isn’t allowed. Now, the semantics of permissions boundaries mean you still have to specify Allows, but because Deny overrides Allow, you can include broad Allow statements.

Let’s look at an example. Suppose we wanted a permissions boundary that only included S3 access. The direct approach would be the following:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*"
}
]
}

The alternative approach would be:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"NotAction": "s3:*",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "*:*",
"Resource": "*"
}
]
}

This is rather simple, so the Allow statement could instead be only S3 rather than *:* to be more secure, but as the Deny statement got more complex it would become harder and more verbose to keep the Allow side simple.

A final approach is that service control policies can apply to all principals, and so a statement in an SCP, say based on a tag on the principal, could also work to prevent a resource policy from granting access, but space is SCPs is precious and I doubt this is a useful route for most people.

An additional complexity introduced by this is that using NotPrincipal (e.g., with a Deny to exclude all but a specific principal) may not work the way you expect. Setting NotPrincipal to the role ARN will match principals that are not your role ARN, which includes the assumed role session ARN. So while the statement won’t apply to the role, the second step of evaluating the session will match the statement. I think — though I have not verified this — that the opposite, NotPrincipal with the assumed role session ARN will face the same issue.

It’s extremely useful to familiarize yourself with the IAM docs page on policy evaluation and the companion page on cross-account policy evaluation. IAM occasionally has surprises, and the documentation can help you wrap your head around it when it happens.

I have posted code to verify this behavior on GitHub: https://github.com/benkehoe/permissions-boundary-test

As always, if you’ve got questions or comments, you can find me on Twitter.

--

--