Efficient Implementation of AWS Service Control Policies With Terraform

Gerald Bachlmayr
4 min readMay 18, 2022

--

Service Control Policies

AWS Service Control Policies (SCPs) help us to establish preventative guardrails. They make sure that particular system changes cannot happen, no matter if intentionally or by accident. For example, an SCP can make sure that unauthorised individuals are not allowed to delete existing log files. By putting those guardrails in place we reduce the risk for our organisation and also protect individuals from human errors.

Larger cloud deployments typically include a multi-account setup. AWS Organizations is an out-of-the-box solution to structure and manage multiple accounts. AWS Organizations has a feature called Organizational Units (OUs). OUs allow us to easily group AWS accounts by business domains, geo-locations, or the environment purpose, such as Development, Testing or Production.

There will be cases where we are prepared to accept more risk for one environment - e.g. manual deployments of instances are allowed in Development, but Production requires standardised CI/CD pipelines. There will also be cases where we want the same rules in place for several OUs. However, we don’t want to write the same statements over and over again and maintain them in multiple places.

In this blog post we will go through an approach that makes it easy to implement SCP statements for several OUs without code duplication. This will reduce our development, testing and management effort.

How Does it Work?

One SCP can include multiple statements. Each statement describes the following:

  • An effect - allow or deny
  • A resource that is addressed - e.g. CloudTrail logging trails
  • Actions: e.g. delete the log trail
  • Optional: conditions - e.g. Deny, except to a particular IAM role or user group
  • Optional: Sid - the statement ID, which is a logical description what the statement is doing

The example below shows a statement in JSON format as we can see it in the AWS Console.

{
"Sid": "",
"Effect": "Deny",
"Action": [
"cloudtrail:UpdateTrail",
"cloudtrail:StopLogging",
"cloudtrail:PutEventSelectors",
"cloudtrail:DeleteTrail"
],
"Resource": "arn:aws:cloudtrail:*:*:trail/*",
"Condition": {
"ForAnyValue:ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/Deploy"
}
}
}

What we want to achieve is having one SCP per OU and each SCP will have several statements. To avoid code duplication we will create a Terraform module with dynamic statements.

The Terraform Module

The module uses the Terraform data construct “aws_iam_policy_document”. By doing that, we generate one new SCP document every time the module is called.

data "aws_iam_policy_document" "scp_policy" {
}

Within the data section we define all the SCP statements. This dynamic statement produces the same JSON output as shown above.

dynamic "statement" {
for_each = local.deny_cloudtrail_changes_statement
content {
effect = "Deny"
actions = [
"cloudtrail:DeleteTrail",
"cloudtrail:UpdateTrail",
"cloudtrail:PutEventSelectors",
"cloudtrail:StopLogging"
]
resources = ["arn:aws:cloudtrail:*:*:trail/*"]
condition {
test = "ForAnyValue:ArnNotLike"
variable = "aws:PrincipalArn"
values = [
"arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/Deploy"
]
}
}
}

The for_each statement will make sure the statement is only included if the source (the code that calls the module) sets the variable for the according rule to true.

If the variable value is not set to true, then the for_each statement will return an array containing only one empty string. In that case the statement will not be included in the SCP document. If the value is true that statement will be included. This behaviour is achieved by a local value within the Terraform module:

locals {
deny_cloudtrail_changes_statement = var.deny_cloudtrail_changes ? [""] : []
... ... ...
}

Referencing the Terraform Module

If we want to include the statement in the SCP, we set the statement variable to true when calling the module.

module "scp-dev" {
source = "../"
targets = toset([var.ou_targets.development])
name = "development"
deny_root_account_access = true
deny_password_policy_changes = true
deny_config_changes = true
deny_cloudtrail_changes = true
}

This way we can easily re-use our statements across multiple OUs and generate one consolidated SCP per OU. The entire source code is available on GitHub (see below).

Key Takeaways and Further Things to Consider

By utilising Terraform modules and dynamic statements we can achieve a very flexible solution that also scales for large enterprises with a complex OU structure.

If we want to expand on the described example there are a couple of considerations before using it in Production:

  1. Version control:
    We might want to add version management to the SCPs so that we can test the latest version in Development before deploying it to Production. This can be easily achieved by using GIT version tags and referencing the version when calling the module.
  2. Technical constraints of SCPs:
    The maximum size of an SCP is 5,120 bytes. Statement IDs can be used for a meaningful description for each statement. However, they are optional and count towards the size limit and can be skipped. If we hit the size limit we can also split the SCP. Currently it is possible to use up to 5 SCPs per OU.
  3. Change management:
    It is good practice to communicate SCP changes to users to make sure they are aware of upcoming changes.

Source Code

https://github.com/gezza-b/aws-scp-terraform-module

--

--