Terraform: Optional Nested Dynamic Blocks

Written by Luke Arntz on January 19, 2024
[ terraform ] [ optional ] [ dynamic ] [ aws ] [ iam ] [ s3 ]

Contents

Overview

Recently, I was creating a terraform module that creates an S3 bucket. An S3 bucket can only have one aws_s3_bucket_policy, and the module needed to include a default policy to force TLS v1.2. I also needed to be able to pass additional policy statements to the module which may or may not include a condition. Unfortunately, there is not an obvious way to include the condition only when it is part of the policy.

Our goal is to include a nested dynamic "condition" block if a condition element is part of the policy, and exclude the block if there is no condition element.

If you know of a better, or alternative, way to accomplish this please let me know on Mastodon @larntz@discuss.systems.

AWS IAM Policies

Require TLS v1.2

The way to ensure a bucket can only be accessed with TLS >=v1.2 is with the following policy (in HCL, not JSON). Notice we the condition block checking the value of s3:TlsVersion.

locals {
  s3_tls_statements = tolist([
    {
      sid       = "TLSv1.2"
      effect    = "Deny"
      actions   = ["s3:*"]
      resources = [aws_s3_bucket.this.arn, "${aws_s3_bucket.this.arn}/*"]
      principals = {
        type        = "*"
        identifiers = ["*"]
      }
      condition = {
        test     = "NumericLessThan"
        variable = "s3:TlsVersion"
        values   = ["1.2"]
      }
    }
  ])
}

Additional Policies

This module has the requirement that consuming modules should be able to specify additional S3 bucket policy statements that should be concatenated to create the final bucket policy. To facilitate this there is an s3_policy_statements input variable.

For example, if we wanted to grant bucket access from another account we’d need to add a policy similar to this one:

s3_policy_statements = [
    {
      sid    = "CrossAccountAccess"
      effect = "Allow"
      principals = {
        type = "AWS"
        identifiers = ["112233445566"]
      }
      actions = [
        "s3:GetObject",
        "s3:ListBucket"
      ]
      resources = [
        module.shared_bucket.bucket_arn,
        "${module.shared_bucket.bucket_arn}/*",
      ]
    }
]

As you can see, the policy statement above does not contain the condition block and that is where things get a little tricky.

Terraform Policy Document

To create an aws_iam_policy_document that includes an arbitrary number of policy statements we can use this code. Because we don’t know how many statements the policy will have we must use a dynamic "statement" block for_each statement.

data "aws_iam_policy_document" "this" {
  count = var.force_tls && var.attach_additional_policy_statements ? 1 : 0
  dynamic "statement" {
    for_each = concat(local.s3_tls_statements, var.s3_policy_statements)
    content {
      sid       = statement.value["sid"]
      effect    = statement.value["effect"]
      actions   = statement.value["actions"]
      resources = statement.value["resources"]
      principals {
        type        = statement.value["principals"]["type"]
        identifiers = statement.value["principals"]["identifiers"]
      }
      condition {
        test     = statement.value["condition"]["test"]
        variable = statement.value["condition"]["variable"]
        values   = statement.value["condition"]["values"]
      }
    }
  }
}

There are two problems here. First, the variable statement.value["condition"] may be null if the policy statement passed to the module does not include a condition block. Second, even if we check for null the condition block cannot be empty.

A conditional condition Block

So how can we include a condition block only when satement.value["condition"] is not null? We can create a nested dynamic "condition" block using for_each and if statements to include the block only if the policy statement includes a condition element.

dynamic "condition" {
    for_each = {
      for c in statement.value : "c" => c...
      if statement.value.condition != null
    }
<snip>
}

What we’re doing here is saying we want to add this dynamic block for_each times. Inside, the for_each block we are checking if statement.value.condition != null, and if this is true create the dynamic condition block. If it’s false don’t create the block.

There is one more part of the snippet above that may not be obvious. We need to activate grouping mode using the ellipsis (c...), because the results of the for statement will not produce unique keys.

The full aws_iam_policy_document:

data "aws_iam_policy_document" "combined" {
  count = var.force_tls && var.attach_additional_policy_statements ? 1 : 0
  dynamic "statement" {
    for_each = concat(local.s3_tls_statements, var.s3_policy_statements)
    content {
      sid       = statement.value["sid"]
      effect    = statement.value["effect"]
      actions   = statement.value["actions"]
      resources = statement.value["resources"]
      principals {
        type        = statement.value["principals"]["type"]
        identifiers = statement.value["principals"]["identifiers"]
      }
      dynamic "condition" {
        for_each = {
          for c in statement.value : "c" => c...
          if statement.value.condition != null
        }
        content {
          test     = statement.value["condition"]["test"]
          variable = statement.value["condition"]["variable"]
          values   = statement.value["condition"]["values"]
        }
      }
    }
  }
}

Complex Conditions

This works great as long as we only have a single condition operator with a single context key. To be thorough we will want to handle more complex condition elements.

Type Validation and Optional Values

While not directly related to the dynamic block variable type validation is an important best practice. You want to ensure any values passed to the module are of the type you expect to avoid failures. In this case the S3 bucket policy statements are an object that may or may not contain a condition.

When defining the s3_policy_statements type we need to let Terraform know the condition attribute is optional or we’ll get plan errors when trying to pass a statement that does not include a condition.

The way to do that is to set condition equal to an optional object like this: condition = optional(object({.

This is the full type constraint for policy statements:

variable "s3_policy_statements" {
  description = "Additional s3 bucket policy to be attached to the bucket.
  type = list(object({
    sid       = string
    effect    = string
    actions   = list(string)
    resources = list(string)
    principals = object({
      type        = string
      identifiers = list(string)
    })
    condition = optional(object({
      test     = string
      variable = string
      values   = list(string)
    }))
  }))
}

References

Related Articles

Top