Creating Reusable IAM Polcies

Posted by Levi on Friday, February 12, 2021

With the release of Terraform AWS Provider 3.28.0 the aws_iam_policy_document now supports the merging of other aws_iam_policy_documents via it’s new arguments source_json and source_policy_documents. Let us take a look at two common scenarios I often run into and how this new feature can help.

Multiple Roles needing access to the same policy

I often run into a situation where I have multiple items of the same AWS resource like EC2 or Lambda that all share a common access need like Cloudwatch logs. You could create a policy that is shared among the resources:

data "aws_iam_policy_document" "cloudwatch_logs" {
  statement {
    sid = "CloudWatch"

    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]

    resources = [aws_cloudwatch_log_group.alpha.arn, aws_cloudwatch_log_group.beta.arn]

  }
}

This allows you to create a policy and then attach it to the roles for the alpha and beta Lambda that gives them permission to write to the correct log groups. The downside to this is you either end up with a single logs policy with many log groups defined or you end up with many policies for Cloudwatch that all have a different log group in each.

I tend to stick to the principle of least privilege so I would create multiple policies and then use them inline to the role for each resource:

data "aws_iam_policy_document" "alpha_cloudwatch" {
  statement {
    sid = "CloudWatch"

    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]

    resources = [aws_cloudwatch_log_group.alpha.arn]

  }
}

data "aws_iam_policy_document" "beta_cloudwatch" {
  statement {
    sid = "CloudWatch"

    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]

    resources = [aws_cloudwatch_log_group.beta.arn]

  }
}

Neither of these options is perfect. Creating a separate inline policy document for each resource allowed us to adhere to the principle of least privilege but we also had to keep repeating ourselves. If we need to change the actions in the future then we need to change them in many places in our Terraform configuration.

This is how I would handle the above situation in the future:

data "aws_iam_policy_document" "cloudwatch" {
  statement {
    sid = "CloudWatch"

    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]
  }
}

data "aws_iam_policy_document" "alpha" {
  
  source_json = data.aws_iam_policy_document.cloudwatch.json

  statement {
    sid = "CloudWatch"
    resources = [aws_cloudwatch_log_group.alpha.arn]
  }
}

data "aws_iam_policy_document" "beta" {
  
  source_json = data.aws_iam_policy_document.cloudwatch.json

  statement {
    sid = "CloudWatch"
    resources = [aws_cloudwatch_log_group.beta.arn]
  }
}

The source_json argument allows us to define a “template” that we can then reuse by merging new policies on top of the “template”.

Multiple roles that are similar but each has something unique

Another situation I find myself in is where I have multiple roles that share some policies but each one has something unique that the others shouldn’t have. Sometimes you will see these combined into a single one:

data "aws_iam_policy_document" "crud_lambdas" {
  statement {
    sid = "CloudWatch"

    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]

    resources = [
      aws_cloudwatch_log_group.create.arn,
      aws_cloudwatch_log_group.read.arn,
      aws_cloudwatch_log_group.update.arn,
      aws_cloudwatch_log_group.destroy.arn
    ]
  }

  statement {
    sid = "DynamoDB"

    actions = [
      "dynamodb:Scan",
      "dynamodb:Query",
      "dynamodb:PutItem",
      "dynamodb:DeleteItem",
      "dynamodb:GetItem",
      "dynamodb:UpdateItem"
    ]

    resources = [aws_dynamodb_table.customers.arn]
  }
}

Again we are not following the principle of least privilege and I would not recommend writing policies in this way. The other option was to write them all out =/

data "aws_iam_policy_document" "create_lambda" {
  statement {
    sid = "CloudWatch"

    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]

    resources = [aws_cloudwatch_log_group.create.arn]
  }

  statement {
    sid = "DynamoDB"

    actions = [
      "dynamodb:Scan",
      "dynamodb:Query",
      "dynamodb:PutItem"
    ]

    resources = [aws_dynamodb_table.customers.arn]
  }
}

data "aws_iam_policy_document" "read_lambda" {
  statement {
    sid = "CloudWatch"

    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]

    resources = [aws_cloudwatch_log_group.read.arn]
  }

  statement {
    sid = "DynamoDB"

    actions = [
      "dynamodb:Scan",
      "dynamodb:Query",
      "dynamodb:GetItem"
    ]

    resources = [aws_dynamodb_table.customers.arn]
  }
}

data "aws_iam_policy_document" "update_lambda" {
  statement {
    sid = "CloudWatch"

    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]

    resources = [aws_cloudwatch_log_group.update.arn]
  }

  statement {
    sid = "DynamoDB"

    actions = [
      "dynamodb:Scan",
      "dynamodb:Query",
      "dynamodb:UpdateItem"
    ]

    resources = [aws_dynamodb_table.customers.arn]
  }
}

data "aws_iam_policy_document" "delete_lambda" {
  statement {
    sid = "CloudWatch"

    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]

    resources = [aws_cloudwatch_log_group.delete.arn]
  }

  statement {
    sid = "DynamoDB"

    actions = [
      "dynamodb:Scan",
      "dynamodb:Query",
      "dynamodb:DeleteItem"
    ]

    resources = [aws_dynamodb_table.customers.arn]
  }
}

Well this is better but we still have to repeat ourselves. Let us do it again with source_policy_documents.

data "aws_iam_policy_document" "cloudwatch" {
  statement {
    sid = "CloudWatch"

    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]
  }
}

data "aws_iam_policy_document" "dynamo_common" {
  statement {
    sid = "DynamoDB"

    actions = [
      "dynamodb:Scan",
      "dynamodb:Query"
    ]
  }
}

data "aws_iam_policy_document" "create_lambda" {

  source_policy_documents = [
    aws_iam_policy_document.cloudwatch,
    aws_iam_policy_document.dynamo_common
  ]

  statement {
    sid = "CloudWatch"

    resources = [aws_cloudwatch_log_group.create.arn]
  }

  statement {
    sid = "DynamoDB"

    actions = ["dynamodb:PutItem"]

    resources = [aws_dynamodb_table.customers.arn]
  }
}

data "aws_iam_policy_document" "read_lambda" {

  source_policy_documents = [
    aws_iam_policy_document.cloudwatch,
    aws_iam_policy_document.dynamo_common
  ]

  statement {
    sid = "CloudWatch"

    resources = [aws_cloudwatch_log_group.read.arn]
  }

  statement {
    sid = "DynamoDB"

    actions = ["dynamodb:GetItem"]

    resources = [aws_dynamodb_table.customers.arn]
  }
}

data "aws_iam_policy_document" "update_lambda" {

  source_policy_documents = [
    aws_iam_policy_document.cloudwatch,
    aws_iam_policy_document.dynamo_common
  ]

  statement {
    sid = "CloudWatch"

    resources = [aws_cloudwatch_log_group.update.arn]
  }

  statement {
    sid = "DynamoDB"

    actions = ["dynamodb:UpdateItem"]

    resources = [aws_dynamodb_table.customers.arn]
  }
}

data "aws_iam_policy_document" "delete_lambda" {

  source_policy_documents = [
    aws_iam_policy_document.cloudwatch,
    aws_iam_policy_document.dynamo_common
  ]

  statement {
    sid = "CloudWatch"

    resources = [aws_cloudwatch_log_group.delete.arn]
  }

  statement {
    sid = "DynamoDB"

    actions = ["dynamodb:DeleteItem"]

    resources = [aws_dynamodb_table.customers.arn]
  }
}

These are only simple examples but I hope they illustrate how powerful the new source_json and source_policy_documents arguments can be.