naftuli.wtf An urban mystic, pining for conifers in a jungle of concrete and steel.

Getting IAM Right

IAM IRL

IAM is Amazon’s primary access control system for managing permissions to access AWS APIs. It’s extremely flexible, but also has the distinct property of giving one enough rope with which to hang oneself. It’s hard to get right, easy to get wrong, but there are a good set of guidelines that can be used to make it manageable and reusable.

IAM Objects

AWS provides a number of different primitives for managing access:

  • Policies which define a set of rules to allow access to specific API operations.
  • Users which use access keys for programmatic access and/or a login profile for granting login access to the AWS web console.
  • Groups which consist of zero or more users.
  • Roles which can be assumed by users or by other services; provided that the permissions exist, a Lambda function can be given the right to assume a role, which grants the permissions associated with that role with the execution runtime of the Lambda function.
  • Instance Profiles which bind a role to a given EC2 instance, one instance profile must contain exactly one role.

Policies can be attached to arbitrary users, groups, and roles. There’s also a concept of inline policies, which live embedded on the object they are attached to.

For IAM users, permissions can be obtained in a few different ways, in no particular order:

  • Inline user policies.
  • Independent policies which are attached directly to the user.
  • Inline group policies for a group which the user is a member of.
  • Independent policies which are attached directly to a group that the user is a member of.
  • If the user assumes a role:
    • Inline role policies.
    • Independent policies which are attached to the role.

The Rules

The basic rules that I’ve come to use when working with IAM boil down to the following:

  • Do
    • Create independent policy objects which have their own lifecycle.
    • Attach independent policy objects to groups and roles.
    • If a user exists, ensure they exist within at least one group.
    • Use an intelligent naming system (and optionally, paths) for all IAM objects.
  • Don’t
    • Don’t ever use inline policies.
    • Don’t attach independent policy objects to users directly.

I have found these guidelines to dramatically reduce the complexity of working with IAM and to increase simplicity for reasoning about how permissions apply for a given user, group, or role.

Let’s work through a real-world example for some of my own private infrastructure.

IAM Structuring

The specific example we will work through is a static site using S3 as a backend and CloudFront as a CDN/frontend to objects living in the S3 bucket. I’ll use Terraform to provide these examples.

The first thing that we need is an independent policy object which provides the access rights necessary to deploy to Vör, a largely inconsequential and unrealized personal project. Let’s declare our policy object:

resource "aws_iam_policy" "deployment" {
  name = "deployment-policy@vor.naftuli.wtf"
  path = "/vor.naftuli.wtf/"
  description = "Policy to allow deployments to the vor.naftuli.wtf site."

  policy = "${data.template_file.deployment_policy.rendered}"
}

The actual underlying policy is not important to our example, but suffice it to say that it provides access to S3 and CloudFront for uploading artifacts and triggering CloudFront invalidations to purge the CloudFront cache.

Next, let’s define a group and attach the policy to it:

resource "aws_iam_group" "deployment" {
  name = "deployment@vor.naftuli.wtf"
  path = "/vor.naftuli.wtf/"
}

resource "aws_iam_group_policy_attachment" "deployment" {
  group      = "${aws_iam_group.deployment.name}"
  policy_arn = "${aws_iam_policy.deployment.arn}"
}

We’ve now defined a group and attached the policy defined above to our group. Next up is to declare a service user for our deployment tool to be able to publish deployments:

resource "aws_iam_user" "circleci" {
  name = "circleci@vor.naftuli.wtf"
  path = "/vor.naftuli.wtf/"
}

resource "aws_iam_group_membership" "deployment" {
  name  = "${aws_iam_group.deployment.name}-membership"
  group = "${aws_iam_group.deployment.name}"
  users = [
    "${aws_iam_user.circleci.name}"
  ]
}

Finally, we’ve completed our relatively simple example with a user and we have added that user to the group defined above. Reasoning about our permissions is super straightforward:

  • The user is circleci@vor.naftuli.wtf.
  • We didn’t use any inline policies on the user, so we don’t need to consider that.
  • We didn’t attach any independent policies to the user, so this is another thing not to consider.
  • We haven’t defined any roles here for the user to assume, so we can forget about role permissions.
  • The user exists as a member of the deployment@vor.naftuli.wtf group.
  • We didn’t use any inline policies on the group, so we don’t need to reason about that.
  • We did attach one independent policy to the group.
  • To understand the access granted to circleci@vor.naftuli.wtf, we must therefore only consider the fact of group membership and the single independent policy attached to that group: deployment-policy@vor.naftuli.wtf.

To extend our example, we can add a role that a Lambda function can assume during execution.

resource "aws_iam_role" "lambda" {
  name = "lambda-deployer@vor.naftuli.wtf"
  description = "Lambda runtime role for deployments to vor.naftuli.wtf."
  assume_role_policy = "${file("${path.module}/policies/lambda-assume-role.json")}"
}

resource "aws_iam_role_policy_attachment" "lambda" {
  role       = "${aws_iam_role.lambda.name}"
  policy_arn = "${aws_iam_policy.deployment.arn}"
}

Again, we’re ignoring the actual substance of the policy and the role assumption policy, as this article is mainly about how to structure and organize IAM.

Let’s walk through our reasoning about the permissions of the Lambda function at runtime:

  • The role is lambda-deployer@vor.naftuli.wtf.
  • We didn’t use any inline policies on the role, so we don’t need to consider that.
  • The role has one independent policy attachhed to it, deployment-policy@vor.naftuli.wtf.
  • To understand the access granted to lambda-deployer@vor.naftuli.wtf, we must therefore only consider the independent policy attached to that role: deployment-policy@vor.naftuli.wtf.

Organizing IAM like this has some extremely helpful results:

  • By not using inline policies, it is far easier to reason about permissions.
  • By only using independent policy objects, access can be shared between users (as a result of their group membership), groups, and roles transparently.
  • Deleting users is now extremely straightforward: if attached or inline policies exist on the user, these must be manually removed before deletion.

If this weren’t a personal project, I could add an entire department of users to the deployment group, and they would all inherit the same permissions based on the group. We haven’t included another optimization above, but roles can likewise be shared via a broader role assumption policy, making it easy to use the same role for EC2 instances and Lambda without any actual policy changes.

Conclusion

I’ve used a very similar organizational system at the past three organizations that I’ve worked at to great success. IAM can often be a very tricky beast to wrangle with, so explicitly opting out of certain features allows us to simplify our structure, taking a lot of guesswork out of the equation.

I’ve found this organizational strategy around IAM extremely helpful, and hope that this is useful to others as well.