Which Way to Write IAM Policy Documents in Terraform

There are many ways to write IAM policy documents in terraform. In this article, we’ll cover each of them and explain why we use it or why we don’t.

For each pattern, we’ll create an example policy using the last statement of this AWS example. It’s a good test case because it references both an S3 bucket name and an IAM user name, which we’ll handle differently.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "s3:*",
            "Resource": [
                "arn:aws:s3:::bucket-name/home/${aws:username}",
                "arn:aws:s3:::bucket-name/home/${aws:username}/*"
            ]
        }
    ]
}

Table of Contents

Inline jsonencode() Function

This is what we use. You’ll also see it in HashiCorp examples.

resource "aws_s3_bucket" "test" {
  bucket_prefix = "test"
  acl           = "private"
}

resource "aws_iam_policy" "jsonencode" {
  name = "jsonencode"
  path = "/"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "s3:*",
        ]
        Effect = "Allow"
        Resource = [
          "${aws_s3_bucket.test.arn}/home/$${aws:username}",
          "${aws_s3_bucket.test.arn}/home/$${aws:username}/*"
        ]
      },
    ]
  })
}
  • ${aws_s3_bucket.test.arn} interpolates the ARN of the bucket we’re granting access to.
  • $${aws:username} escapes interpolation to render a literal ${aws:username} string. ${aws:username} is an AWS IAM policy variable. IAM’s policy variable syntax collides with terraform’s string interpolation syntax. We have to escape it, otherwise terraform expects a variable named aws:username.
  • If you need it, the policy JSON can be referenced with aws_iam_policy.jsonencode.policy (not shown here).

Why we like this pattern:

  • It declares everything in one resource.
  • The policy is written in HCL. Terraform handles the conversion to JSON.
  • There are no extra lines or files like there are in the following patterns. It only requires the lines to declare the resource and the lines that will go into the policy.

aws_iam_policy_document Data Source

The next-best option is the aws_iam_policy_document data source. It’s 95% as good as jsonencode().

resource "aws_s3_bucket" "test" {
  bucket_prefix = "test"
  acl           = "private"
}

data "aws_iam_policy_document" "test" {
  statement {
    actions = [
      "s3:*",
    ]
    resources = [
      "${aws_s3_bucket.test.arn}/home/&{aws:username}",
      "${aws_s3_bucket.test.arn}/home/&{aws:username}/*",
    ]
  }
}

resource "aws_iam_policy" "aws_iam_policy_document" {
  name = "aws_iam_policy_document"
  path = "/"

  policy = data.aws_iam_policy_document.test.json
}
  • The bucket interpolation works the same as in the jsonencode() pattern above.
  • &{aws:username} is an alternate way to escape interpolation that’s specific to this resource. See note in the resource docs. Like above, it renders a literal ${aws:username} string. You can still use $${} interpolation in these resources. The &{} syntax is just another option.

Why we think this is only 95% as good as jsonencode():

  • It requires two resources instead of one.
  • It requires several more lines of code.
  • The different options for escaping interpolation can get mixed together in one declaration, which makes for messy code.
  • The alternate interpolation escape syntax is specific to this resource. If it’s used as a reference when writing other code, it can cause surprises.

These aren’t big problems. We’ve used this resource plenty of times without issues. It’s a fine way to render policies, we just think the jsonencode() pattern is a little cleaner.

Template File

Instead of writing the policy directly in one of your .tf files, you can put them in .tpl template files and render them later with templatefile(). If you don’t need any variables, you could use file() instead of templatefile().

First, you need a template. We’ll call ours test_policy_jsonencode.tpl.

${jsonencode(
  {
    Version = "2012-10-17",
    Statement = [
      {
        Effect = "Allow",
        Action = "s3:*",
        Resource = [
          "${bucket}/home/$${aws:username}",
          "${bucket}/home/$${aws:username}/*"
        ]
      }
    ]
  }
)}

Then, you can render the template into your resources.

resource "aws_s3_bucket" "test" {
  bucket_prefix = "test"
  acl           = "private"
}

resource "aws_iam_policy" "template_file_jsonencode" {
  name = "template_file_jsonencode"
  path = "/"

  policy = templatefile(
    "${path.module}/test_policy_jsonencode.tpl",
    { bucket = aws_s3_bucket.test.arn }
  )
}
  • The interpolation and escape syntax is the same as in the jsonencode() example above.
  • The jsonencode() call wrapped around the contents of the .tpl file allows us to write HCL instead of JSON.
  • You could write a .tpl file containing raw json instead of using jsonencode() around HCL, but then you’d be mixing another language into your module. We recommend standardizing on HCL and letting terraform convert to JSON.
  • templatefile() requires you to explicitly pass every variable you want to interpolate in the .tpl file, like bucket in this example.

Why we don’t use this pattern:

  • It splits the policy declaration across two files. We find this makes modules harder to read.
  • It requires two variable references for every interpolation. One to pass it through to the template, and another to resolve it into the policy. These are tedious to maintain.

In the past, we used these for long policies to help keep our .tf files short. Today, we use the jsonencode() pattern and declare long aws_iam_policy resources in dedicated .tf files. That keeps the policy separate but avoids the overhead of passing through variables.

Heredoc Multi-Line String

You can use heredoc multi-line strings to construct JSON. The HashiCorp docs specifically say not to do this. Because they do, we won’t include an example of using them to construct policy JSON. If you have policies rendered in blocks like this:

<<EOT
{
    "Version": "2012-10-17",
    ...
}
EOT

We recommend replacing them with the jsonencode() pattern.

Happy automating!

Operating Ops

Need more than just this article? We’re available to consult.

You might also want to check out these related articles: