A while ago AWS introduced Permission Boundaries. You may have seen them when creating an IAM user or role through the console, and ignored them. That’s OK, since permission boundaries have no effect if they’re not configured. You may also have read about them and learned that your effective IAM permissions are the intersection between your permission boundaries and your IAM policies. So, if you don’t give too many permissions away in your IAM policies, permission boundaries don’t really add any value. Right?

Right. If you are creating IAM users that are used as functional users, meaning that the AK/SK of that user is used in some sort of external application, then permission boundaries don’t add any value. And even if you create IAM users that represent real human beings, in a lot of cases permission boundaries don’t add value either. So for the majority of us, they can safely be ignored. Just set up your IAM policies properly, applying the principle of least privilege, and you’ll be fine.

But there is an exception to this. And this revolves around two keywords: inheritance and privilege escalation.

Here’s the scenario. You’ve got an AWS account in which your developers are active. Those developers do not get administrative privileges, but have their IAM permissions scoped so they can do their development work and nothing else. Maybe, just for arguments sake, you don’t want them to start ordering Snowcones. But it’s a hot day and your developers are looking for ways to order snowcones anyway. So they start looking for a “privilege escalation”: Something that gives them additional permissions and does allow them to buy that refreshing snowcone anyway.

And developers have the perfect vehicle for this: IAM Roles. All forms of compute in AWS (EC2, ECS and Lambda) can have a role associated with them. If you then attach the right permission to the role, the role then allows your code to perform AWS API calls without the need to bring explicit credentials inside your code. This role model is used by all code that does API calls and lives inside AWS. And your developers are the ones that set this up. So a developer needs to have the permissions to create roles and attach policies to it.

And that’s where things get out of hand: The developer creates a role, with a policy that does allow the ordering of snowcones. And he writes some Lambda code to order the snowcone, and associates the role with this Lambda. By running the Lambda script, the developer is now able to order that refreshing snowcone. That’s what “privilege escalation” is about.

It is perfectly possible to tighten your security using just regular IAM polices. If all your developer IAM statements are in one policy, you can come up with some additional lines that only allow a developer to create a role that has the same policy attached. But IAM policies are additive, so this quickly becomes a nightmare if the statements are spread across multiple policies.

Permission Boundaries however are not additive. Instead, it’s the intersection between your Permission Boundary and all your IAM policies that determine your actual privileges. So it’s much easier to limit privileges with permission boundaries. Here’s how: You set up a permission boundary for the user, and then this boundary enforces that the permission boundary itself is inherited by any IAM user or role that the user creates. And even if the freshly created user or role creates another user or role in turn, the inheritance still holds. So any restriction that is applied in the permission boundary will continue to apply, and privilege escalation can’t happen anymore.

Permission Boundary basics

A Permission Boundary is just a regular IAM policy. You can therefore edit them in the familiar IAM policy editor, and they can be found in the console under Policies. Once you’ve got a permission boundary created, you can then associate it with a user. And then two things happen.

First, a Deny always trumps an Allow. So if there’s an applicable Deny rule either in your Permission Boundary, or in an IAM policy, the action is denied. Easy.

Second, an API call is only allowed if it is allowed by your Permission Boundary AND by an IAM policy. That’s what AWS means when they talk about the “intersection” between Permission Boundaries and IAM policies. If the Permission Boundary doesn’t allow a particular action you can add any IAM policy you want, but the action still won’t be allowed. And the reverse is true as well: Even though an action may be allowed by your Permission Boundary, it still requires an Allow rule in an IAM policy for the action to succeed.

So a permission boundary that allows everything, like the one below, makes absolutely zero difference compared to the situation where Permission Boundaries were not used at all. The IAM user or role is still limited by what is allowed according to the regular IAM policies. But it forms the basis of the discussion in this blog post.

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

So to start with Permission Boundaries, you start by creating a policy as above, and adding this as a Permission Boundary to the IAM user accounts that are used by your developers. And then you need to think about what you really want your developers to do or not.

Usually, the very first thing you will want to do is prevent privilege escalation, by forcing inheritance of this Permission Boundary onto any IAM user or role that our developers create.

The next thing is to prevent our users from performing certain tasks. Two approaches here. You can “whitelist” the tasks that they are allowed to perform, and deny everything else, or you can “blacklist” certain tasks but allow everything else (subject to IAM policies).

Setting up the scaffolding

Let’s go ahead and build this. First, go to the IAM console and create a new policy like I showed you above, that allows everything. Save the policy under a meaningful name, and make note of the ARN of the policy. You’ll need this ARN later on. In the examples below I’m going to use the ARN arn:aws:iam::123456789012:policy/MyPermissionsBoundary.

For testing purposes, also create a separate IAM user, assign the AdministratorAccess IAM policy and assign the permissions boundary. Open an Incognito Browser Window and login as this IAM user so you can quickly test the effect. With the PB as above, and the AdministratorAccess IAM policy this test user can do anything. Which is not what we want.

So go back to the Permission Boundary policy document and add the following statement somewhere in the list of statements.

{
  "Sid": "NoBoundaryPolicyEdit",
  "Effect": "Deny",
  "Action": [
    "iam:CreatePolicyVersion",
    "iam:DeletePolicy",
    "iam:DeletePolicyVersion",
    "iam:SetDefaultPolicyVersion"
  ],
  "Resource": [
    "arn:aws:iam::123456789012:policy/MyPermissionsBoundary"
  ]
}

(Note that you will have to replace the ARN with your own policy ARN.)

This statement denies the editing and deleting of your Permissions Boundary. Remember, a Deny always trumps an Allow. So even if another IAM policy allows editing, editing is still denied.

(Oh, and before you ask: IAM policies cannot be edited in place. Rather, you always create a new version of a policy, and make that new version the default. You can also delete versions, make another policy version the default and delete the whole policy altogether. That’s why these API calls are listed.)

Go ahead and test this. Logon to your test user and see if you can modify the Permission Boundary policy. It should not work.

The next step is to ensure inheritance of the policy. What we’re trying to achieve is the following:

  • Any user or role that is created, needs to have this boundary policy attached. In other words, we deny API calls that create users or roles without this PB.
  • You cannot attach another boundary policy to an existing user or role. In other words, we deny API calls that try to put a new PB on a user or role unless the PB is this PB.
  • You cannot delete this boundary policy from an existing user or role

Here we go:

{
  "Sid": "DenyCreateOrChangeUserWithoutBoundary",
  "Effect": "Deny",
  "Action": [
    "iam:CreateUser",
    "iam:CreateRole",
    "iam:PutUserPermissionsBoundary",
    "iam:PutRolePermissionsBoundary"
  ],
  "Resource": "",
  "Condition": {
    "StringNotEquals": {
      "iam:PermissionsBoundary": "arn:aws:iam::123456789012:policy/MyPermissionsBoundary"
    }
  }
},
{ 
  "Sid": "DenyRemovalOfBoundary",
  "Effect": "Deny",
  "Action": [
    "iam:DeleteUserPermissionsBoundary",
    "iam:DeleteRolePermissionsBoundary"
  ],
  "Resource": [ "*" ],
  "Condition": {
    "StringEquals": {
      "iam:PermissionsBoundary": "arn:aws:iam::123456789012:policy/MyPermissionsBoundary"
    }
  }
}

Your Permissions Boundary should now have four statements: “NoBoundaryPolicyEdit”, “DenyCreateOrChangeUserWithoutBoundary”, “DenyRemovalOfBoundary” and “AllowEverythingElse”. That puts the scaffolding in place, and prevents the privilege escalation.

Go ahead and try out the following actions while being logged in as the IAM test user. They should all be denied:

  • Change or delete the Permission Boundary document
  • Change or delete the Permissions Boundary that is associated with your own IAM user account, or with another IAM user or role
  • Create an IAM user or role without any Permission Boundary
  • Create an IAM user or role with a different Permission Boundary attached

Then, as your IAM test user, create another IAM user or role, with this Permission Boundary attached. Login as this newly created IAM user, or assume this newly created IAM role. Perform the same tests again. They should again be denied.

Making it useful

So far we’ve prevented privilege escalation by enforcing inheritance of the Permissions Boundary. But our PB still contains this “AllowEverythingElse” statement, so – subject to IAM policies – our developers and the entities created by them can still do everything else. So we now need to restrict them further.

The blacklist approach

With blacklisting you deny your developers, and the entities they create, from performing certain API calls, but allow everything else. If all you want is that developers don’t order snowcones, you simply leave the “AllowEverythingElse” statement in place, and add the following:

{
  "Sid": "DenyOrderingSnowdevices",
  "Effect": "Deny",
  "Action": "snowball:*",
  "Resource": "*"
}

Remember, a “deny” always trumps an “allow”. So this deny statement prevents your developers, and the entities that they create, from ordering any snow family device, regardless of other privileges that they might have picked up elsewhere.

Blacklisting is easy to start with, but typically doesn’t work in the long run. If AWS comes up with new services that you don’t want your developers to (ab)use, you will need to add them to your PB.

The whitelist approach

With whitelisting you need to create a list of allowed AWS services or API calls, and only allow these. It’s a lot more work, but more secure in the long run. In this case, you remove the “AllowEverythingElse” statement from your PB, and instead add something like this:

{
  "Effect": "Allow",
  "Action": [ 
    "ec2:*",
    "ecs:*",
    "lambda:*",
    "iam:*",
    "cloudwatch:*", 
    "s3:*",
    "codecommit:*"
  ],
  "Resource": "*"
}

Note that this policy is far from complete. You’ll have to make an inventory of what services your developers are really using, and add all legitimate services to the list. I would not be surprised if you end up with two or three dozen services in the end.

Taking it a step further

So far we have forced inheritance on our developers: We gave the developers a Permission Boundary and forced them to apply the same Permission Boundary onto all entities that they created. But that’s not the only option.

You can also setup multiple permission boundaries. Maybe one for end users, and maybe one for your IAM administrators. You then setup the IAM administrators PB on all IAM administrators, and this PB forces the IAM administrators to only create users and roles that have the end user PB attached to them. And the end user PB then does not allow IAM actions at all. This is useful if you want to delegate the creation of IAM users to non-administrators, but want to keep control over what they can and cannot do, and prevent privilege escalation.

Permission Policies and CloudFormation

One last thought to share. Your developers will likely have a DevOps culture, and will want to create the whole application infrastructure as code (IaaS). So their final deliverable is probably a CloudFormation template that builds everything. Not just the Lambda, but also the role and associated policies. This CloudFormation template is subject to the same Permission Boundary limitations, so the role that is listed in the CF template needs to have the PB attached.

But in your production environment you may not run the risk of privilege escalation. And even if you do and if you use Permission Boundaries to mitigate this risk, the Permission Boundary in your production environment may be called differently. So make sure you use some form of abstraction here, like Parameters or Mappings, or maybe even Conditional statements, so that the CloudFormation template works in both the production and the development environment.