June 23rd, 2020
Getting Crafty with IAM Conditions
By Stephanie Lingwood

Deny regions, whitelist IPs, restrict the launching of large instances, and more!

If you’ve been writing IAM policy statements for a while, it’s easy to get into the rhythm of the typical statement block. Effect: allow or deny. Action: one of a multitude of potential API operations, lovingly looked up in the IAM reference (because you’re creating a least-privileged policy…right?). Resource: either “all resources”, or specific S3 buckets, Elasticsearch domains, or whatever it is you’re granting permissions for.

But what about those condition blocks? If you’ve been neglecting conditions lately, I’ve rounded up a few easy conditions to fine-tune your IAM policies.

Conditions: A Review

First, let’s do a brief review of how conditions work. The condition block of a statement has at least one condition operator, applied to a particular condition key and value. Let’s look at an example:


“Condition”: # condition block
  “StringEquals”: # operator
    “aws:username” : “janedoe” # condition key/value

Think of it as an “if” statement from any other programming language, but one that’s had the order mixed up. Instead of saying “if aws:username equals janedoe, then do this thing”, we say “if there is equality between the value of aws:username and the string janedoe, then apply this IAM statement”.

Like other programming languages, we get a variety of comparison operators: string equality/inequality, string matching using wildcards, Booleans, number comparison, date comparison, and more. These are all documented in the Comparison Operators section of the IAM policy docs.

Comparison keys also come in various flavors. Some are always available to us, regardless of the kind of resource we’re dealing with. Those are called “global condition context keys,” and they derive their information from the “request context.” Simply put, when a request is made to AWS, certain information about that request is tracked. For example, we know the IP address from which the request was initiated, which region the request was targeting, the principal that made the request, and more. This information is made available to us via these global condition keys.

In addition to the global condition keys, each service also has its own, service-specific condition keys. For example, EC2 has an instance type condition key; RDS has a key that indicates whether the database’s storage is encrypted. Using service keys like this, you can restrict users to only provisioning small instances, require encryption on new databases, and more.

With that as background, let’s get to work! Here are a few ways you can use conditions to secure your environment and make your resource management easier.

Deny Region

If you’re like most companies, you probably have one or two AWS regions that contain all your resources. But what do you do about all those other regions? You definitely don’t want your team creating resources in those regions, because they’re unlikely to be properly monitored, and will lead to costs you can’t track down.

The answer: deny access to those unused regions! There are a couple ways we can do this using conditions. One way is to create a deny-region policy and attach it to every role in an account. If this feels too tedious, or you just want to set it once and forget it, you can apply a deny-region policy as a service control policy (SCP) on your organization. Either way, the logic looks the same.

First, the sample code:


{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyUnusedRegions",
      "Effect": "Deny",
      "NotAction": [
        "artifact:*",
        "aws-portal:*",
        "budgets:*",
        "cloudfront:*",
        "devicefarm:*",
        "directconnect:*",
        "globalaccelerator:*",
        "health:*",
        "iam:*",
        "importexport:*",
        "iot1click:*",
        "organizations:*",
        "route53:*",
        "shield:*",
        "sts:*",
        "support:*",
        "waf:*"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:RequestedRegion": [
            "us-east-2",
            "us-west-2"
          ]
        }
      }
    }
  ]
}

Here, we’re writing a Deny policy; that is, any AWS API call that meets the criteria we’ve specified will be denied. The “NotAction” block acts like an exception; you can read it as “any action BUT these listed,” (more about how we know what goes in that NotAction block in a bit). The “Resource” is a wildcard—this will be applied to all resources.

Finally, we have the “Condition” block. This is where the region restriction comes in. Whenever we make a call to an AWS API, the region we make the call in is made available in the “request context.” Specifically, we get to reference that region in our conditions, using the “aws:RequestedRegion” condition key. The logic above, then, says “if there is not equality between the value of aws:RequestedRegion and the region in the request, then apply this policy.” In this case, if someone is making a request to any other region besides us-east-2 and us-west-2. Since the policy statement being applied is a “Deny,” the cumulative effect is to deny any request if it’s made in any other region than us-east-2 or us-west-2.

What about that NotAction block? Well, those are the services to which this policy won’t be applied. The reason we need this is that some AWS services are only available “globally”—that is, they don’t maintain an endpoint in each region. This is different from a service like S3, say, that acts like a global service—bucket names have to be globally unique—but that maintains an endpoint in each region. For example, the IAM service only has an endpoint in the `us-east-1` region. Also, some services are very new, and are only available in selected regions. For calls to these services to work, we need to exempt them from our policy.

Restrict instance types

Let’s say that you want to give a team the ability to create EC2 or RDS instances. However, you really don’t want them provisioning huge instances, because they’re expensive (a single p3.16xlarge is almost $18,000 a month!) and your workloads don’t require them. How can you accomplish this? A condition!

Here, we create a policy statement that allows the ec2:RunInstances action (this creates an instance), for any instance name. However, there’s a catch; we only want to allow this action if the instance type ends in .nano, .micro, or .small.

Note, too, the “IfExists” in the condition—i.e. “StringLikeIfExists.” The “IfExists” flag only applies the condition to calls for which the InstanceType property is relevant. This is important because when you run an instance, all manner of things happen under the hood: looking up security groups, finding subnets, and the like. None of those calls have any concept of an InstanceType.

Because of this, if we simply said “StringLike” in our condition, it would look for, and try to enforce, our InstanceType restriction on all of those other calls and our attempt to run the instance would ultimately fail. By saying “StringLikeIfExists,” we tell AWS to enforce the restriction on InstanceTypes, but only when an InstanceType is actually present in the resource we’re talking about.


{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EC2AllowRunSmallerInstances",
      "Effect": "Allow",
      "Action": [
        "ec2:RunInstances"
      ],
      "Resource": "*",
      "Condition": {
        "StringLikeIfExists": {
          "ec2:InstanceType": [
            "*.nano",
            "*.micro",
            "*.small"
          ]
        }
      }
    }
  ]
}

Whitelist IPs

Last but not least, let’s look at another common scenario: restricting API calls (console or CLI) unless they come from a certain IP range. We often do this with cross-account role assumption; this ensures that developers or admins can’t take actions in an account unless they’re, say, in the office or tunneled into your office via VPN.

When you set up cross-account role assumptions, there are two sets of permissions in play. The first set of permissions is the actual role that will be assumed in a given account (e.g. the Admin role in prod, or the Developer role in sandbox.). In this scenario, we don’t need to touch the policies attached to that role.

The second group of permissions are the policies attached to groups in your identity account (your “identity” account is the account where your IAM users are created, or your SSO is connected). Those policies generally only confer one set of privileges: assuming a given role, in a given account. These policies are where we want to add our IP condition.

Let’s take a look at that policy block:


{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyNonWhitelistedIP",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "NotIpAddress": {
          "aws:SourceIp": [
            "1.2.3.4/20",
            "5.6.7.8/24"
          ]
        }
      }
    }
  ]
}

Here, we create a policy statement that denies all access, unless it’s in the list of IPs. The “NotIpAddress” condition will be true (i.e. the statement will enforce the denial of actions) when the call is not made from one of the listed IP address ranges. This gives an added layer of security to your role assumption; only those from your offices, or designated ranges, will be allowed access.

Note: don’t use this condition on roles that are assumed by AWS services (e.g. by Lambda, or an EC2 instance). The AWS API calls from those resources won’t come from your office’s IP range.

Fin

And that’s it! Use these policies as inspiration and see what else you come up with. Only allowing encrypted EBS volumes? Requiring MFA? All this and more is possible when you put some IAM conditions into play.

If you’re finding that your project needs additional AWS expertise, please reach out to us at info@1Strategy.com. We’d love to schedule a call to discuss how 1Strategy can help you get started.