How do I pass a configuration block to a variable?

We have a very common pattern used in our company when creating AWS roles. For each role we create we’ll want to associate a policy with it.

The four resource types that we use are

  • aws_iam_role
  • aws_iam_policy_document
  • aws_iam_policy
  • aws_iam_policy_attachment

We also create them for multiple environments so we use for_each chaining to link them all together. It looks something like this:

resource "aws_iam_role" "role" {
  for_each = toset(["dev", "qa", "live"], )
  name = "role-name-${each.key}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Sid    = ""
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      },
    ]
  })
}

data "aws_iam_policy_document" "doc" {
  for_each = aws_iam_role.role
  statement {
    sid = "1"

    actions = [
      "s3:GetObject",
      "s3:ListBucket"
    ]

    resources = [
      "arn:aws:s3:::bucket-name",
      "arn:aws:s3:::bucket-name/*"
    ]
  }

  statement {
    actions = [
      "s3:ListAllMyBuckets",
      "s3:GetBucketLocation"
    ]

    resources = [
      "arn:aws:s3:::*",
    ]
  }
}

resource "aws_iam_policy" "policy" {
  for_each = data.aws_iam_policy_document.doc
  name     = aws_iam_role.role[each.key].name
  path     = "/"
  policy   = each.value.json
}

resource "aws_iam_policy_attachment" "attachment" {
  for_each   = aws_iam_policy.policy
  name       = each.value.name
  policy_arn = each.value.arn
  roles = [
    aws_iam_role.role[each.key].name
  ]
}

This is such a common pattern that I’d like to abstract it into a module. The only things that will change each time the module is instantiated are:

  • role name
  • the list in the for_each
  • policy statements

so those will be module variables. The role name and list are easy to create variables for but I haven’t a clue how to pass multiple statements (which are represented as configuration blocks). Is it even possible?

Perhaps my variable needs to accept a list of objects and use dynamic blocks inside my module to create a statement for each object. I’d prefer to just pass the statements to my module though, is it possible?

Hi @jamiekt,

The idea of nested blocks in Terraform is something specific to resources and other provider-based concepts, and is a syntax idea rather than a value idea, so you can’t pass blocks around “as values” in the way I think you are imagining.

The approach you mentioned at the end of your comment, of using a collection of objects as your variable type and then using it with a dynamic block to generate statement blocks, is the closest you can get because dynamic is the bridge between the world of values and the world of nested block syntax. It deals with the fact that providers have a number of expectations about blocks that don’t apply to values, such as that a block cannot itself be “unknown” (only the arguments inside it).

However, if your intention is to expose the full power of the AWS IAM policy language for statements then you might choose to skip using aws_iam_policy_document altogether and instead construct your policy by directly jsonencodeing a data structure whose shape matches the policy language.

You could then expose from your module an input variable with type = any that you just assign verbatim into the Statement attribute of the object you will JSON-encode, allowing the user of your module to write any kind of statement they can find in the AWS documentation, without first needing to adapt it to the schema used by the Terraform AWS provider in that data source.

This sort of verbatim assignment of an arbitrary value is what type = any was designed for, representing that your module makes no assumptions whatsoever about the structure and it will instead be handled by either the AWS provider or by the remote AWS IAM API.

The aws_iam_policy_document data source can be useful when hand-writing policies because it gives stronger feedback about the expected structure in tools like the Terraform language server, but really that data source only exists because it predates Terraform having a general jsonencode function. It was added as an alternative to generating policy documents by string concatenation, rather than to using jsonencode. I personally think it’s fine to skip it in situations like this where it’s getting in the way rather than helping.

Oh, one more thing!

Even if you use type = any, you can still potentially be more “picky” about what you accept by writing validation blocks for the input variable.

When you declare type = any you will need to write your validation conditions a little more carefully to deal with the fact that the given value can be literally of any type, but with some cautious expression writing you can still give users of your module some guidance on high-level, broad requirements, such as that the given value ought to be something that would encode as a JSON array.

1 Like

Thx Mart. I’m quite a fan of aws_iam_policy_document so I’m going to persevere for now. I’ve fashioned a type declaration for a variable to pass a list of statements:

variable "statements" {
  type = list(object({
    sid           = optional(string)
    effect        = optional(string, "Allow")
    actions       = optional(list(string))
    not_actions   = optional(list(string))
    resources     = optional(list(string))
    not_resources = optional(list(string))
    principals = optional(list(object({
      type        = string
      identifiers = list(string)
    })))
    not_principals = optional(list(object({
      type        = string
      identifiers = list(string)
    })))
    conditions = optional(list(object({
      test     = string
      variable = string
      values   = list(string)
    })))
  }))
  description = "The list of statements to add to the policy."
}

and am now going crazy with dynamic blocks inside aws_iam_policy_document to get it working. I’ll report back if I get anything useful, although I am intrigued by the idea of using jsonencode() so I’ll explore that too so I can compare and contrast.

such as that the given value ought to be something that would encode as a JSON array.

I assume can(jsonencode(var.policy_json)) would achieve that.

I’ve decided to not go with this approach of using dynamic blocks. The reason is that all of the attributes of a policy statement are optional, as indicated in my variable declaration:

variable "statements" {
  type = list(object({
    sid           = optional(string)
    effect        = optional(string, "Allow")
    actions       = optional(list(string))
    not_actions   = optional(list(string))
    resources     = optional(list(string))
    not_resources = optional(list(string))
    principals = optional(list(object({
      type        = string
      identifiers = list(string)
    })))
    not_principals = optional(list(object({
      type        = string
      identifiers = list(string)
    })))
    conditions = optional(list(object({
      test     = string
      variable = string
      values   = list(string)
    })))
  }))
  description = "The list of statements to add to the policy."
}

The problem with that though is… how do I conditionally include an attribute of the dynamic block?

For example, my dynamic block was like this:

data "aws_iam_policy_document" "this" {
  for_each = module.role
  dynamic "statement" {
    for_each = var.statements
    content {
      sid = statement.value.sid
      effect = statement.value.effect
      etc...

but statement.value.sid may not exist, in which case I wouldn’t want the sid attribute in the content of my dynamic block. I could not figure out a way of including an attribute if and only if some condition is met.

Hence I’m going to go with the jsonencode() approach suggested by @apparentlymart .

While I think the choice of using jsonencode is reasonable and I’m not trying to talk you out of it :smile: I don’t think your question about optional attributes should be a blocker if that was the only reason you went this way.

From a provider’s perspective, an omitted argument is indistinguishable from an argument set to null. The provider literally cannot distinguish those situations because they are serialized in exactly the same way in the wire protocol.

An optional attribute in a variable type constraint causes the attribute to be set to null if you don’t provide an explicit default, so you can unconditionally assign an object attribute to each argument and let Terraform worry about propagating those null values so that the provider will see certain arguments as unset.

ah good to know, thanks Martin.

In the end I’ve ditched this whole exercise anyway :slight_smile: The problem as I described it above was contrived, the actual problem was more complicated and it transpired that putting stuff into a module in the way that I was trying to do it didn’t make sense (for reasons I won’t go into here). Nevertheless I’ve learnt a lot from this thread, so thank you.

Incidentally, I’ve concluded that I prefer to use aws_iam_policy_document versus jsonencode(). The reason is that I like to add commentary to AWS policy statements - using aws_iam_policy_document enables comments to be placed at the pertinent parts of the statement and syntax highlighting will emphasize that commentary.