DEV Community

Cover image for AWS Control Tower Proactive Controls for Terraform: A Proof of Concept
Anthony Wat for AWS Community Builders

Posted on • Originally published at blog.avangards.io

AWS Control Tower Proactive Controls for Terraform: A Proof of Concept

Introduction

As a Terraform advocate and an AWS consultant who builds many landing zones, AWS Control Tower has always been one of my favorite AWS services. Beyond its common use cases, such as account provisioning with Account Factory Customization (AFC) and Account Factory for Terraform (AFT), I am always on the lookout for opportunities to bring the two technologies closer together.

During landing zone design workshops, when walking customers through Control Tower controls, I often found myself unable to recommend proactive controls because many organizations prefer using Terraform over CloudFormation for infrastructure as code (IaC). To fully leverage everything Control Tower has to offer, wouldn’t it be nice if proactive controls worked with other IaC tools, including Terraform?

Through research, I learned that proactive controls are implemented as CloudFormation Hooks and can target resources created via the Cloud Control API. Having worked with the Terraform AWS Cloud Control (CC) Provider, I began to wonder whether proactive controls could evaluate Terraform resources created through this provider. This question became the experiment that is the subject of this blog post.

Let’s start with a quick explanation of what proactive controls are.

What Are AWS Control Tower Proactive Controls?

Proactive controls are pre-built compliance rules that evaluate AWS resources before deployment via CloudFormation stack operations, preventing non-compliant resources from being created or updated. AWS Control Tower provides more than 200 controls covering a wide range of AWS services and compliance frameworks.

An example of a proactive control is [CT.EC2.PR.7] Require an Amazon EBS volume resource to be encrypted at rest when defined by means of the AWS::EC2::Instance BlockDeviceMappings property or AWS::EC2::Volume resource type. As the name suggests, this control prevents an EC2 instance from being created or updated if it specifies an unencrypted EBS volume.

For the full list of proactive controls, you can either view them on the Control Catalog page in the AWS Control Tower console or refer to the Proactive control section in the AWS Control Tower Control Reference Guide.

Testing Proactive Controls with Terraform (Unsuccessfully)

Since proactive controls are implemented using CloudFormation Hooks, I initially assumed they would evaluate all Hook targets, particularly resources supported by the Cloud Control API. Because the Terraform AWS Cloud Control (CC) Provider is implemented using the Cloud Control API (as opposed to the standard AWS API used by the original Terraform AWS Provider), I expected proactive controls to apply there as well.

Although it is still uncommon for organizations to fully adopt the Terraform AWS CC Provider, I wanted to determine whether proactive controls could be used with Terraform to enforce compliance.

As a quick test, I used the following Terraform configuration to create an EC2 instance with an unencrypted EBS volume using the Terraform AWS CC Provider, expecting it to fail:

variable "subnet_id" {
    type = string
}

data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

# EC2 instance with unencrypted EBS volume
resource "awscc_ec2_instance" "this" {
  image_id      = data.aws_ami.al2023.id
  instance_type = "t3.micro"
  subnet_id     = var.subnet_id

  block_device_mappings = [
    {
      device_name = "/dev/xvda"
      ebs = {
        volume_size = 20
        volume_type = "gp3"
        encrypted   = false  # Explicitly unencrypted
        delete_on_termination = true
      }
    }
  ]

  tags = [
    {
      key   = "Name"
      value = "unencrypted-vol-test"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

However, terraform apply ran successfully and the EC2 instance was created:

$ terraform apply
data.aws_ami.al2023: Reading...
data.aws_ami.al2023: Read complete after 0s [id=ami-0f3caa1cf4417e51b]

Terraform used the selected providers to generate the following execution plan.       
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # awscc_ec2_instance.this will be created
  + resource "awscc_ec2_instance" "this" {
      + additional_info                      = (known after apply)
      + affinity                             = (known after apply)
      + availability_zone                    = (known after apply)
      + block_device_mappings                = [
          + {
              + device_name  = "/dev/xvda"
              + ebs          = {
                  + delete_on_termination = true
                  + encrypted             = false
                  + iops                  = (known after apply)
                  + kms_key_id            = (known after apply)
                  + snapshot_id           = (known after apply)
                  + volume_size           = 20
                  + volume_type           = "gp3"
                }
              + no_device    = (known after apply)
              + virtual_name = (known after apply)
            },
        ]
      + cpu_options                          = (known after apply)
      + credit_specification                 = (known after apply)
      + disable_api_termination              = (known after apply)
      + ebs_optimized                        = (known after apply)
      + elastic_gpu_specifications           = (known after apply)
      + elastic_inference_accelerators       = (known after apply)
      + enclave_options                      = (known after apply)
      + hibernation_options                  = (known after apply)
      + host_id                              = (known after apply)
      + host_resource_group_arn              = (known after apply)
      + iam_instance_profile                 = (known after apply)
      + id                                   = (known after apply)
      + image_id                             = "ami-0f3caa1cf4417e51b"
      + instance_id                          = (known after apply)
      + instance_initiated_shutdown_behavior = (known after apply)
      + instance_type                        = "t3.micro"
      + ipv_6_address_count                  = (known after apply)
      + ipv_6_addresses                      = (known after apply)
      + kernel_id                            = (known after apply)
      + key_name                             = (known after apply)
      + launch_template                      = (known after apply)
      + license_specifications               = (known after apply)
      + metadata_options                     = (known after apply)
      + monitoring                           = (known after apply)
      + network_interfaces                   = (known after apply)
      + placement_group_name                 = (known after apply)
      + private_dns_name                     = (known after apply)
      + private_dns_name_options             = (known after apply)
      + private_ip                           = (known after apply)
      + private_ip_address                   = (known after apply)
      + propagate_tags_to_volume_on_creation = (known after apply)
      + public_dns_name                      = (known after apply)
      + public_ip                            = (known after apply)
      + ramdisk_id                           = (known after apply)
      + security_group_ids                   = (known after apply)
      + security_groups                      = (known after apply)
      + source_dest_check                    = (known after apply)
      + ssm_associations                     = (known after apply)
      + state                                = (known after apply)
      + subnet_id                            = "subnet-0a0bb7e920672c803"
      + tags                                 = [
          + {
              + key   = "Name"
              + value = "unencrypted-vol-test"
            },
        ]
      + tenancy                              = (known after apply)
      + user_data                            = (known after apply)
      + volumes                              = (known after apply)
      + vpc_id                               = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

awscc_ec2_instance.this: Creating...
awscc_ec2_instance.this: Still creating... [00m10s elapsed]
awscc_ec2_instance.this: Creation complete after 16s [id=i-0963bfcf44274c8d9]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

$
Enter fullscreen mode Exit fullscreen mode

So how did the EC2 instance manage to be created?

Investigating Why Proactive Controls Don’t Work with Terraform

To investigate the issue, I examined the Hook in the CloudFormation console. Looking at the Hook named AWS::ControlTower::Hook, I noticed that its Targets field was set to None.

AWS::ControlTower::Hook listed as without a target

This seemed odd, as I expected at least one target to be listed. Upon reviewing the Hook details, I observed that the Hook targets included only CloudFormation resources, not the Cloud Control API:

AWS::ControlTower::Hook details show only CF resource as a target

Assuming the Hook details reflect the actual configuration, this implies that proactive controls validate only CloudFormation resources, not resources provisioned through the Cloud Control API (and therefore not those created via the Terraform AWS CC Provider). To confirm this behavior, I opened an AWS Support case.

The AWS support engineer explained that proactive controls are implemented using a special Hook type called Controls (Managed Hooks), which supports only CloudFormation resources as targets. To extend proactive controls to other targets, each control must be re-implemented as a custom Hook.

Although I submitted a feature request to the AWS Control Tower team to expand proactive controls to additional targets, I decided to proceed with a workaround, even if it required additional effort.

Replicating Proactive Controls to Target the Cloud Control API with Lambda Hooks

The AWS support engineer initially suggested re-implementing each proactive control using Lambda Hooks. While this approach would require significant effort, I was provided with the following Python code used for the CT.EC2.PR.7 control:

import json
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    '''
    CloudFormation Hook Handler for EBS Encryption Validation
    Validates that EC2 instances have encrypted EBS volumes
    '''

    logger.info(f"Received full event: {json.dumps(event, indent=2)}")

    # Extract request details from Cloud Control API event structure
    request_data = event.get('requestData', {})
    resource_type = request_data.get('targetName')
    target_model = request_data.get('targetModel', {})
    resource_properties = target_model.get('resourceProperties', {}) 

    logger.info(f"Extracted - Type: {resource_type}, Properties keys: {list(resource_properties.keys())}")

    # Only validate EC2 instances
    if not resource_type or resource_type != 'AWS::EC2::Instance':
        logger.info(f"Skipping validation - resource type '{resource_type}' is not EC2 Instance")
        return {
            'hookStatus': 'SUCCESS',
            'message': f'Resource type {resource_type} not applicable for this hook'
        }

    # Validation logic
    validation_result = validate_ebs_encryption(resource_properties)

    if validation_result['compliant']:
        return {
            'hookStatus': 'SUCCESS',
            'message': 'EC2 instance has encrypted EBS volumes'
        }
    else:
        return {
            'hookStatus': 'FAILED',
            'errorCode': 'NonCompliant',
            'message': validation_result['message']
        }

def validate_ebs_encryption(properties):
    '''
    Validates that all EBS volumes are encrypted
    '''

    # Check BlockDeviceMappings
    block_device_mappings = properties.get('BlockDeviceMappings', [])

    if not block_device_mappings:
        return {
            'compliant': False,
            'message': 'No BlockDeviceMappings specified. Ensure the AMI uses encrypted volumes or specify encrypted BlockDeviceMappings.'
        }

    # Validate each block device mapping
    for idx, mapping in enumerate(block_device_mappings):
        ebs = mapping.get('Ebs', {})

        if ebs:
            encrypted = ebs.get('Encrypted', False)

            if not encrypted:
                return {
                    'compliant': False,
                    'message': f'BlockDeviceMapping at index {idx} has an unencrypted EBS volume. Set Encrypted to true.'
                }

    return {
        'compliant': True,
        'message': 'All EBS volumes are encrypted'
    }
Enter fullscreen mode Exit fullscreen mode

After creating the Lambda function with the provided code, I created a Lambda Hook as per screenshots below. The key configuration details were:

  • Hook targets should include at least Cloud Control API . Since proactive controls already target CloudFormation resources, including them here is unnecessary.

  • Actions should include Create and Update .

  • Hook mode should be set to Fail .

  • Target resources should include AWS::EC2::Instance and AWS::EC2::Volume . These resource types are specified in the control details within the AWS::ControlTower::Hook configuration.

Create Hook with Lambda - step 1 details

Create Hook with Lambda - step 2 details

After the Lambda Hook is created, when I reran terraform apply, and this time it failed as expected due to the Hook:

awscc_ec2_instance.this: Creating...
╷
│ Error: AWS SDK Go Service Operation Incomplete
│
│   with awscc_ec2_instance.this,
│   on main.tf line 21, in resource "awscc_ec2_instance" "this":
│   21: resource "awscc_ec2_instance" "this" {
│
│ Waiting for Cloud Control API service CreateResource operation completion returned: 
│ waiter state transitioned to FAILED. StatusMessage:
│ 149a3eef-eaba-4459-8ea7-1707b1183e11. Hook failures: HookName:
│ Private::Lambda::CTEC2PR7, HookArn:
│ arn:aws:cloudformation:us-east-1:2**********1:type/hook/Private-Lambda-CTEC2PR7/00000001/aws-hooks/AWS-Hooks-LambdaHook/00000001.00000024,
│ HookVersion: 00000025, Time: 2026-02-23T21:06:45Z, HookMessage: BlockDeviceMapping  
│ at index 0 has an unencrypted EBS volume. Set Encrypted to true.
╵
Enter fullscreen mode Exit fullscreen mode

Even Better: Replicating Proactive Controls with Guard Hooks

While researching further, I noticed that the rule specifications for all proactive controls are published under Proactive controls in the AWS Control Tower Controls Reference Guide. This makes replication significantly easier.

According to the documentation, proactive controls are implemented using Guard Hooks powered by AWS CloudFormation Guard, a domain-specific language (DSL) for policy-as-code. For more context and development instructions of Guard Hook, refer to Writing AWS CloudFormation Guard rules in the AWS CloudFormation Guard User Guide.

For our purposes, the CT.EC2.PR.7 control specification already contains everything needed to create the Hook. For instance, the rule specification is as follows:


# ###################################
##       Rule Specification        ##
#####################################
# 
# Rule Identifier:
#   ec2_encrypted_volumes_check
# 
# Description:
#   Checks whether standalone Amazon EC2 EBS volumes and new EC2 EBS volumes created through EC2 instance
#   Block Device Mappings are encrypted at rest.
# 
# Reports on:
#   AWS::EC2::Instance, AWS::EC2::Volume
# 
# Evaluates:
#   CloudFormation, CloudFormation hook
# 
# Rule Parameters:
#   None
# 
# Scenarios:
#   Scenario: 1
#     Given: The input document is an CloudFormation or CloudFormation hook document
#       And: The input document does not contain any Amazon EC2 volume resources
#      Then: SKIP
#   Scenario: 2
#     Given: The input document is an CloudFormation or CloudFormation hook document
#       And: The input document contains an EC2 instance resource
#       And: 'BlockDeviceMappings' has not been provided or has been provided as an empty list
#      Then: SKIP
#   Scenario: 3
#     Given: The input document is an CloudFormation or CloudFormation hook document
#       And: The input document contains an EC2 instance resource
#       And: 'BlockDeviceMappings' has been provided as a non-empty list
#       And: 'Ebs' has been provided in a 'BlockDeviceMappings' configuration
#       And: 'Encrypted' has not been provided in the 'Ebs' configuration
#      Then: FAIL
#   Scenario: 4
#     Given: The input document is an CloudFormation or CloudFormation hook document
#       And: The input document contains an EC2 instance resource
#       And: 'BlockDeviceMappings' has been provided as a non-empty list
#       And: 'Ebs' has been provided in a 'BlockDeviceMappings' configuration
#       And: 'Encrypted' has been provided in the 'Ebs' configuration and set to bool(false)
#      Then: FAIL
#   Scenario: 5
#     Given: The input document is an CloudFormation or CloudFormation hook document
#       And: The input document contains an EC2 volume resource
#       And: 'Encrypted' on the EC2 volume has not been provided
#      Then: FAIL
#   Scenario: 6
#     Given: The input document is an CloudFormation or CloudFormation hook document
#       And: The input document contains an EC2 volume resource
#       And: 'Encrypted' on the EC2 volume has been provided and is set to bool(false)
#      Then: FAIL
#   Scenario: 7
#     Given: The input document is an CloudFormation or CloudFormation hook document
#       And: The input document contains an EC2 instance resource
#       And: 'BlockDeviceMappings' has been provided as a non-empty list
#       And: 'Ebs' has been provided in a 'BlockDeviceMappings' configuration
#       And: 'Encrypted' has been provided in the 'Ebs' configuration and set to bool(true)
#      Then: PASS
#   Scenario: 8
#     Given: The input document is an CloudFormation or CloudFormation hook document
#       And: The input document contains an EC2 volume resource
#       And: 'Encrypted' on the EC2 volume has been provided and is set to bool(true)
#      Then: PASS

#
# Constants
#
let EC2_VOLUME_TYPE = "AWS::EC2::Volume"
let EC2_INSTANCE_TYPE = "AWS::EC2::Instance"
let INPUT_DOCUMENT = this

#
# Assignments
#
let ec2_volumes = Resources.*[ Type == %EC2_VOLUME_TYPE ]
let ec2_instances = Resources.*[ Type == %EC2_INSTANCE_TYPE ]

#
# Primary Rules
#
rule ec2_encrypted_volumes_check when is_cfn_template(%INPUT_DOCUMENT)
                                      %ec2_volumes not empty {
    check_volume(%ec2_volumes.Properties)
        <<
        [CT.EC2.PR.7]: Require that an Amazon EBS volume attached to an Amazon EC2 instance is encrypted at rest
        [FIX]: Set 'Encryption' to true on EC2 EBS Volumes.
        >>
}

rule ec2_encrypted_volumes_check when is_cfn_hook(%INPUT_DOCUMENT, %EC2_VOLUME_TYPE) {
    check_volume(%INPUT_DOCUMENT.%EC2_VOLUME_TYPE.resourceProperties)
        <<
        [CT.EC2.PR.7]: Require that an Amazon EBS volume attached to an Amazon EC2 instance is encrypted at rest
        [FIX]: Set 'Encryption' to true on EC2 EBS Volumes.
        >>
}

rule ec2_encrypted_volumes_check when is_cfn_template(%INPUT_DOCUMENT)
                                      %ec2_instances not empty {
    check_instance(%ec2_instances.Properties)
        <<
        [CT.EC2.PR.7]: Require that an Amazon EBS volume attached to an Amazon EC2 instance is encrypted at rest
        [FIX]: Set 'Encryption' to true on EC2 EBS Volumes.
        >>
}

rule ec2_encrypted_volumes_check when is_cfn_hook(%INPUT_DOCUMENT, %EC2_INSTANCE_TYPE) {
    check_instance(%INPUT_DOCUMENT.%EC2_INSTANCE_TYPE.resourceProperties)
        <<
        [CT.EC2.PR.7]: Require that an Amazon EBS volume attached to an Amazon EC2 instance is encrypted at rest
        [FIX]: Set 'Encryption' to true on EC2 EBS Volumes.
        >>
}

#
# Parameterized Rules
#

rule check_instance(ec2_instance) {
    %ec2_instance[
        filter_ec2_instance_block_device_mappings(this)
    ] {
        BlockDeviceMappings[
            Ebs exists
            Ebs is_struct
        ] {
            check_volume(Ebs)
        }
    }
}

rule check_volume(ec2_volume) {
    %ec2_volume {
        # Scenario 2
        Encrypted exists
        # Scenarios 3 and 4
        Encrypted == true
    }
}

rule filter_ec2_instance_block_device_mappings(ec2_instance) {
    %ec2_instance {
        BlockDeviceMappings exists
        BlockDeviceMappings is_list
        BlockDeviceMappings not empty
    }
}

#
# Utility Rules
#
rule is_cfn_template(doc) {
    %doc {
        AWSTemplateFormatVersion exists  or
        Resources exists
    }
}

rule is_cfn_hook(doc, RESOURCE_TYPE) {
    %doc.%RESOURCE_TYPE.resourceProperties exists
}
Enter fullscreen mode Exit fullscreen mode

To implement this, I first deleted the previous Lambda Hook and its associated IAM role and policy to avoid redundant checks. Then following the instructions to activate a Guard Hook, I created an S3 bucket and uploaded the Guard rule as CT.EC2.PR.7.guard, and created the Guard Hook as per the screenshots below. The key configuration details were:

  • Hook targets should include at least Cloud Control API . Since proactive controls already target CloudFormation resources, including them here is unnecessary.

  • Actions should include Create and Update .

  • Hook mode should be set to Fail .

  • Target resources should include AWS::EC2::Instance and AWS::EC2::Volume . These resource types are specified in the control details within the AWS::ControlTower::Hook configuration.

Create a Hook with Guard - step 1 details

Create a Hook with Guard - step 2 details

Create a Hook with Guard - step 3 details

After the Guard Hook is created, rerunning terraform apply resulted in a failure as expected:

awscc_ec2_instance.this: Creating...
╷
│ Error: AWS SDK Go Service Operation Incomplete
│
│   with awscc_ec2_instance.this,
│   on main.tf line 21, in resource "awscc_ec2_instance" "this":
│   21: resource "awscc_ec2_instance" "this" {
│
│ Waiting for Cloud Control API service CreateResource operation completion returned: 
│ waiter state transitioned to FAILED. StatusMessage:
│ 0708b661-d689-4451-b05f-28c8429f3836. Hook failures: HookName:
│ Private::Guard::CTEC2PR7, HookArn:
│ arn:aws:cloudformation:us-east-1:2**********1:type/hook/Private-Guard-CTEC2PR7/00000002/aws-hooks/AWS-Hooks-GuardHook/00000001.00000071,
│ HookVersion: 00000072, Time: 2026-02-24T02:28:27Z, HookMessage: Template failed     
│ validation, the following rule(s) failed: ec2_encrypted_volumes_check.
╵
Enter fullscreen mode Exit fullscreen mode

This confirms that Guard Hooks provide a clean and scalable way to extend proactive controls to Terraform via the Cloud Control API.

Next Steps

Now that we have a viable approach for replicating proactive controls using Guard Hooks, the next logical step is automation at scale.

I submitted another feature request to the Control Tower team to publish proactive control Guard rules in a GitHub repository, similar to the AWS Guard Rules Registry. After cross-checking the rules in that repository against the proactive control documentation, I found they differ.

As a workaround, I could develop a scraper to extract rule definitions directly from the documentation and publish them into my own GitHub repository.

From there, I could identify an appropriate trigger, such as a CloudTrail event for updates to the AWS::ControlTower::Hook, to invoke a Lambda function that automatically manages Guard Hook replication based on enabled proactive controls.

This could make for an interesting project, and perhaps a future blog post, demonstrating the capabilities of AI coding assistants such as Kiro.

Summary

In this blog post, I explored whether AWS Control Tower proactive controls can apply to resources created with Terraform. After a failed attempt, I looked for a workaround, which ultimately took the form of replicating the CloudFormation Guard rules that power the proactive controls. Hopefully, AWS will eventually implement the feature request to extend proactive controls to cover the Cloud Control API as well. In the meantime, we now have a viable and potentially automatable approach to replicate them.

If you enjoyed this blog post and the topic it covers, be sure to check out the Avangards Blog for more content. Thanks for reading!

Top comments (0)