Test an AWS Landing Zone with Terraform

Build and test a secure multi-VPC AWS blueprint with Terraform mocks.

Introduction

30 Second Summary

A security plan can look convincing on paper while one overlooked setting leaves the door open. Finding that gap after launch turns a small mistake into an expensive incident.

In this project, you will encode a secure AWS foundation as a local Terraform blueprint. Mocked tests verify the controls without AWS credentials or cloud charges.

What You'll Build

Your local test report shows every security control passing without contacting AWS or creating cloud infrastructure.

By the end of this project, you'll have:

  • Two isolated VPCs with private subnets that you can inspect to demonstrate network separation without a connection between them.
  • A security test that catches public egress before confirming that HTTPS traffic stays inside the workload boundary.
  • A protected audit trail whose tests prove encryption, non-public log storage, multi-Region coverage, log validation, and least-privilege read access.
  • Secret Mission: Convert the safe egress destination into a validated input and prove that 0.0.0.0/0 is rejected.

Are there any prerequisites?

You'll need a Windows computer with permission to install applications plus an internet connection for downloads. No AWS account or AWS CLI is required.

Before We Start

Before any hands-on setup, this step defines the project's boundary. You are building a local-only mocked AWS security blueprint that tests segmentation, encryption, logging, and least-privilege controls before deployment without creating AWS resources.

Set Up Terraform and Visual Studio Code

Your local AWS blueprint needs a reproducible Windows toolchain before it can inspect provider schemas or run mocked security tests. This local setup uses no AWS account.

The Terraform CLI checks your configuration from the terminal. Visual Studio Code gives you one workspace for writing the blueprint.

In this step, get ready to:
  • Install Visual Studio Code with access through the code command.
  • Prepare Terraform CLI 1.17.1 on your user PATH.
  • Prepare C:\Projects\secure-aws-blueprint with the HashiCorp Terraform extension.
Install Visual Studio Code

The Windows User Setup installer adds the code command to your user PATH. A fresh terminal can use that command after installation.

Your Downloads folder should now contain the Visual Studio Code installer.

The installer presents a short set of setup choices. The default User Setup options provide the command-line access needed here.

  • Run the downloaded installer.
  • Keep the default setup options.
  • Complete the installation.

Good progress. Visual Studio Code is installed for your Windows user.

  • Press the Windows key to open Windows search.
  • Type PowerShell.
  • Press Enter to open PowerShell.
  • Verify command-line access to Visual Studio Code by running:
code --version

What Does This Command Do?

The code command starts Visual Studio Code features from a terminal. The --version option prints the installed version information.

PowerShell should print the Visual Studio Code version. This confirms that the installer added code to your user PATH.

PowerShell Cannot Find Visual Studio Code?

Close every PowerShell window before opening a fresh one. Existing terminals do not receive the updated user PATH.

Confirm that you used the Windows User Setup installer. Reinstall Visual Studio Code if the command remains unavailable.

Ask for targeted help with restoring the Visual Studio Code command.

Install Terraform 1.17.1

Terraform uses one executable on Windows. Placing that executable in C:\Tools\Terraform gives the project a predictable installation path.

Your Downloads folder should now contain the pinned Terraform ZIP.

  • Switch back to the PowerShell window from earlier.
  • Create the Terraform installation folder by running:
New-Item -Path "C:\Tools\Terraform" -ItemType Directory -Force

What Does This Command Do?

The command creates C:\Tools\Terraform as a directory. The -Force option keeps the command safe to rerun when the directory already exists.

PowerShell should display the new directory details. You now have a fixed location for terraform.exe.

  • Open your Downloads folder in File Explorer.
  • Right-click the Terraform 1.17.1 Windows AMD64 ZIP.
  • Select Extract All.
  • Enter C:\Tools\Terraform as the destination.
  • Select Extract.

You should see terraform.exe inside C:\Tools\Terraform.

The user PATH editor is a little fiddly because Windows separates user values from system values. You only need to change the user entry.

  • Press the Windows key to open Windows search.
  • Type environment variables.
  • Select Edit environment variables for your account.
  • Select Path under User variables.
  • Select Edit.
  • Select New.
  • Enter C:\Tools\Terraform.
  • Select OK on each open dialog.

Your user PATH now includes the folder containing terraform.exe.

  • Close the PowerShell window from earlier.
  • Press the Windows key to open Windows search.
  • Type PowerShell.
  • Press Enter to open a fresh terminal.
  • Check the Terraform version by running:
terraform version

What Does This Command Do?

The command asks the Terraform executable on your current PATH to print its version. The result reveals whether Windows selected the pinned executable.

✔️ I see version 1.17.1

That is the pinned CLI working from your user PATH. Terraform is ready for the local blueprint.

ⓧ I see an older version

Windows found another Terraform installation before C:\Tools\Terraform in your user PATH.

  • Close the current PowerShell window.
  • Open Edit environment variables for your account through Windows search.
  • Select Path under User variables.
  • Select Edit.
  • Move C:\Tools\Terraform above any other Terraform directory.
  • Select OK on each open dialog.
  • Open a fresh PowerShell window through Windows search.
  • Rerun the version check shown above.

ⓧ Command not found

The fresh terminal cannot find terraform.exe through your user PATH.

  • Confirm that C:\Tools\Terraform\terraform.exe exists in File Explorer.
  • Open Edit environment variables for your account through Windows search.
  • Confirm that the user Path contains C:\Tools\Terraform.
  • Close every PowerShell window.
  • Open a fresh PowerShell window through Windows search.
  • Rerun the version check shown above.

Terraform Still Uses the Wrong Version?

Check that the ZIP extracted directly into C:\Tools\Terraform. An extra nested folder prevents the user PATH entry from reaching the executable.

Ask for help with diagnosing the Terraform PATH selection.

Prepare the project workspace

The Terraform extension adds language support for the files you create later. A workspace keeps those files under one named folder.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code.
  • Press Enter to open Visual Studio Code.
  • Press Ctrl+P to open Quick Open.
  • Install the HashiCorp Terraform extension by entering this Quick Open command:
ext install HashiCorp.terraform

What Does This Command Do?

Quick Open searches the Visual Studio Code Marketplace for the canonical HashiCorp Terraform extension. Running the command installs that extension into Visual Studio Code.

  • Press Enter to run the Quick Open command.

You should see HashiCorp Terraform listed as installed. The extension is now available to your project workspace.

The Extension Did Not Install?

Confirm that Quick Open contains the full extension identifier. Check your internet connection before running the command again.

Ask for help with installing the Terraform extension.

  • Switch back to the fresh PowerShell window from the Terraform check.
  • Create the project folder by running:
New-Item -Path "C:\Projects\secure-aws-blueprint" -ItemType Directory -Force

What Does This Command Do?

The command creates the C:\Projects\secure-aws-blueprint directory. The -Force option also allows the command to finish when the directory already exists.

PowerShell should display the secure-aws-blueprint directory details.

  • Open the new folder in Visual Studio Code by running:
code C:\Projects\secure-aws-blueprint

What Does This Command Do?

The code command passes the absolute folder path to Visual Studio Code. Visual Studio Code opens the folder as your workspace.

You should see SECURE-AWS-BLUEPRINT at the top of the Explorer sidebar. The empty folder is ready for the Terraform files created in the next step.

The Project Folder Did Not Open?

Confirm that PowerShell displayed the directory after the creation command. Check that the path passed to code is exactly C:\Projects\secure-aws-blueprint.

Ask for help with opening the project workspace.

  • Select Terminal in the Visual Studio Code menu bar.
  • Select New Terminal.

You should see a PowerShell prompt inside C:\Projects\secure-aws-blueprint.

Before you run the final check, which tool do you expect to display a subcommand list?

  • Verify both commands from the new project terminal by running:
terraform -help
code --version

What Do These Commands Prove?

The first command asks Terraform to list its available subcommands. The second command asks Visual Studio Code to print its installed version.

Successful output proves that the new PowerShell terminal can reach both tools through your user PATH.

You should see Terraform's subcommand list followed by Visual Studio Code version information. Your pinned Windows toolchain is ready for local testing.

One of the Final Commands Failed?

Restart Visual Studio Code if its terminal opened before you updated the user PATH. A restarted application creates a terminal with the latest value.

Ask for help with diagnosing the final toolchain check.

Your editor, pinned Terraform CLI, extension, and project workspace are ready. Next, you will model two isolated networks and prove their private subnet settings with local tests.

Build and Test the Segmented Network

Your Windows toolchain is ready. You can now use Terraform to describe the network boundary for your local AWS security blueprint.

A visible two-VPC model establishes isolation before later controls add complexity. Local tests make that isolation measurable without contacting AWS.

In this step, get ready to:
  • Pin the Terraform CLI and AWS provider versions.
  • Model two isolated VPCs with one private subnet in each.
  • Prove the network boundaries with local mocked tests.
Pin Terraform and define inputs

Version pins give your infrastructure as code a reproducible toolchain. Input variables keep names and network ranges in one place.

  • In the VS Code Explorer sidebar, select the new-file icon.
  • Name the file versions.tf.
  • Add the required versions and AWS provider configuration to versions.tf by pasting this code:
terraform {
  required_version = "= 1.17.1"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "= 6.71.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

What Does This Configuration Do?

  • The terraform block requires Terraform CLI 1.17.1.
  • The provider requirement selects hashicorp/aws version 6.71.0.
  • The provider block reads its Region from var.aws_region.
  • Save versions.tf.
  • Initialize the local Terraform directory without backend access by running this command in the project terminal:
terraform init -backend=false

What Does This Command Do?

  • The command prepares C:\Projects\secure-aws-blueprint for Terraform operations.
  • The backend flag skips backend configuration.
  • Terraform installs the pinned AWS provider into its local cache.

Initialization should complete with AWS provider 6.71.0 installed. This local setup does not use AWS credentials or create AWS resources.

Initialization Did Not Complete?

Confirm that the terminal path is C:\Projects\secure-aws-blueprint. Check your internet connection because Terraform must download the provider.

Ask for targeted help with diagnosing the Terraform initialization.

✔️ Awesome, I've got everything!

Great. Confirm that versions.tf is saved before you define the blueprint inputs.

ⓧ I'd like to double check the full code

terraform {
  required_version = "= 1.17.1"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "= 6.71.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

What Should I Compare?

Check both version pins. Confirm that the AWS provider Region points to var.aws_region.

The blueprint needs stable names and non-overlapping address ranges. These inputs let the network resources reuse the same values.

  • In the VS Code Explorer sidebar, select the new-file icon.
  • Name the file variables.tf.
  • Define the project inputs in variables.tf by pasting this code:
variable "project_name" {
  description = "Prefix used for the blueprint resources."
  type        = string
  default     = "secure-baseline"
}

variable "aws_region" {
  description = "Home Region for the modeled CloudTrail trail."
  type        = string
  default     = "us-east-1"
}

variable "account_id" {
  description = "Twelve-digit account ID used to construct policy ARNs."
  type        = string
}

variable "workload_vpc_cidr" {
  description = "IPv4 CIDR for the workload VPC."
  type        = string
  default     = "10.10.0.0/16"
}

variable "security_vpc_cidr" {
  description = "IPv4 CIDR for the security VPC."
  type        = string
  default     = "10.20.0.0/16"
}

What Do These Inputs Control?

  • The project_name value supplies a consistent prefix for modeled resources.
  • The aws_region value selects us-east-1 as the modeled home Region.
  • The account_id input supports policy ARNs later in the blueprint.
  • The two CIDR variables assign different address ranges to the workload and security VPCs.
  • Save variables.tf.
  • Check the configuration for syntax and internal consistency by running this command:
terraform validate

What Does This Check Prove?

  • Terraform reads the configuration files in the open project folder.
  • The validation check catches invalid syntax and unresolved internal references.
  • The command does not validate the configuration against AWS APIs.

Terraform should report that the configuration is valid. Your pinned provider now has every input referenced by versions.tf.

Validation Did Not Pass?

Compare the braces and quoted values in variables.tf with the reference below. Confirm that aws_region matches the reference in versions.tf.

Ask for help with finding the Terraform validation issue.

✔️ Awesome, I've got everything!

Your input contract is valid. Save variables.tf before modeling the network.

ⓧ I'd like to double check the full code

variable "project_name" {
  description = "Prefix used for the blueprint resources."
  type        = string
  default     = "secure-baseline"
}

variable "aws_region" {
  description = "Home Region for the modeled CloudTrail trail."
  type        = string
  default     = "us-east-1"
}

variable "account_id" {
  description = "Twelve-digit account ID used to construct policy ARNs."
  type        = string
}

variable "workload_vpc_cidr" {
  description = "IPv4 CIDR for the workload VPC."
  type        = string
  default     = "10.10.0.0/16"
}

variable "security_vpc_cidr" {
  description = "IPv4 CIDR for the security VPC."
  type        = string
  default     = "10.20.0.0/16"
}

What Should I Compare?

Confirm that all five variables are present. Check that the workload and security CIDRs use different address ranges.

Model the isolated VPCs

Each VPC represents a separate network boundary. The configuration contains no gateway or connection resource between them.

  • In the VS Code Explorer sidebar, select the new-file icon.
  • Name the file network.tf.
  • Define the workload VPC in network.tf by pasting this code:
resource "aws_vpc" "workload" {
  cidr_block           = var.workload_vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "${var.project_name}-workload-vpc"
  }
}

What Does the Workload VPC Represent?

  • The resource reads its address range from var.workload_vpc_cidr.
  • DNS support lets the modeled VPC resolve names.
  • The name tag combines the shared project prefix with the workload role.
  • Save network.tf.
  • Verify the workload VPC configuration by running this command:
terraform validate

What Does This Check Now Include?

Terraform now validates the workload VPC against the installed provider schema. It also confirms that every variable reference resolves.

Terraform should report that the configuration is valid. The first network boundary is now represented in code.

Workload VPC Not Valid?

Check that var.workload_vpc_cidr and var.project_name match the variable names in variables.tf.

Ask for help with diagnosing the workload VPC resource.

  • Place the cursor after the workload VPC block in network.tf.
  • Add the security VPC below it by pasting this code:
resource "aws_vpc" "security" {
  cidr_block           = var.security_vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "${var.project_name}-security-vpc"
  }
}

How Does the Second VPC Stay Separate?

  • The security VPC reads a different CIDR from var.security_vpc_cidr.
  • The resource has its own identity and name tag.
  • No connection resource joins the two VPCs.
  • Save network.tf.
  • Verify both VPC definitions by running this command:
terraform validate

What Does This Check Now Include?

Terraform validates both VPC resources against the provider schema. It also checks the separate CIDR variable references.

Terraform should report that the configuration is valid. The blueprint now contains two disconnected network boundaries.

Security VPC Not Valid?

Confirm that the second resource is named security. Check that its CIDR references var.security_vpc_cidr.

Ask for help with comparing the two VPC resources.

A private subnet keeps automatic public IPv4 assignment disabled. Each VPC gets one subnet so the tests can check this boundary directly.

  • Place the cursor after the security VPC block in network.tf.
  • Add the workload private subnet below it by pasting this code:
resource "aws_subnet" "workload_private" {
  vpc_id                  = aws_vpc.workload.id
  cidr_block              = "10.10.1.0/24"
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.project_name}-workload-private"
  }
}

What Makes This Subnet Private?

  • The subnet belongs to aws_vpc.workload.
  • Its CIDR sits inside the workload VPC range.
  • The map_public_ip_on_launch value explicitly disables automatic public IPv4 assignment.
  • Save network.tf.
  • Verify the workload subnet configuration by running this command:
terraform validate

What Does This Check Now Include?

Terraform checks the subnet arguments against the provider schema. It also resolves the subnet reference to the workload VPC.

Terraform should report that the configuration is valid. The workload boundary now contains a subnet with public address assignment disabled.

Workload Subnet Not Valid?

Confirm that vpc_id references aws_vpc.workload.id. Check the subnet CIDR and closing braces against the reference.

Ask for help with diagnosing the workload subnet resource.

  • Place the cursor after the workload subnet block in network.tf.
  • Add the security private subnet below it by pasting this code:
resource "aws_subnet" "security_private" {
  vpc_id                  = aws_vpc.security.id
  cidr_block              = "10.20.1.0/24"
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.project_name}-security-private"
  }
}

How Does This Complete the Network Model?

  • The subnet belongs to aws_vpc.security.
  • Its CIDR sits inside the security VPC range.
  • Automatic public IPv4 assignment remains disabled.
  • Save network.tf.
  • Verify the complete network model by running this command:
terraform validate

What Does This Validation Cover?

Terraform checks all four network resources and their references. The provider schema confirms that the VPC and subnet arguments are internally consistent.

Terraform should report that the configuration is valid. You now have two modeled VPCs with one private subnet in each.

Complete Network Not Valid?

Confirm that the security subnet references aws_vpc.security.id. Check that each resource has a unique Terraform name.

Ask for help with reviewing the complete network model.

✔️ Awesome, I've got everything!

The two VPCs and their private subnets are valid. Confirm that network.tf is saved.

ⓧ I'd like to double check the full code

resource "aws_vpc" "workload" {
  cidr_block           = var.workload_vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "${var.project_name}-workload-vpc"
  }
}

resource "aws_vpc" "security" {
  cidr_block           = var.security_vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "${var.project_name}-security-vpc"
  }
}

resource "aws_subnet" "workload_private" {
  vpc_id                  = aws_vpc.workload.id
  cidr_block              = "10.10.1.0/24"
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.project_name}-workload-private"
  }
}

resource "aws_subnet" "security_private" {
  vpc_id                  = aws_vpc.security.id
  cidr_block              = "10.20.1.0/24"
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.project_name}-security-private"
  }
}

What Should I Compare?

Confirm that each subnet references the correct VPC. Check that both map_public_ip_on_launch values are false.

Test the network locally

Terraform provider mocking evaluates provider-backed configuration without AWS credentials or infrastructure. Plan mode keeps every test focused on the proposed configuration.

Why Mock AWS?

A real deployment would require an AWS account and could create billable infrastructure. Mocking keeps this project local while still checking configured values against the provider schema.

This approach gives you fast security feedback before any deployment decision.

  • In the VS Code Explorer sidebar, select the new-folder icon.
  • Name the folder tests.
  • Select the tests folder.
  • Select the new-file icon.
  • Name the file security.tftest.hcl.
  • Add the mocked network test to tests\security.tftest.hcl by pasting this code:
mock_provider "aws" {}

variables {
  account_id = "123456789012"
}

run "secure_blueprint" {
  command = plan

  assert {
    condition     = aws_vpc.workload.cidr_block != aws_vpc.security.cidr_block
    error_message = "Workload and security VPC CIDRs must be different."
  }

  assert {
    condition     = aws_subnet.workload_private.map_public_ip_on_launch == false
    error_message = "The workload subnet must not assign public IP addresses."
  }

  assert {
    condition     = aws_subnet.security_private.map_public_ip_on_launch == false
    error_message = "The security subnet must not assign public IP addresses."
  }
}

What Does This Test Prove?

  • The mocked AWS provider removes the need for AWS credentials.
  • The test supplies an account ID used only by the local configuration.
  • Plan mode prevents the run block from creating infrastructure.
  • The assertions require different VPC CIDRs and disabled public IPv4 assignment for both subnets.
  • Save tests\security.tftest.hcl.

✔️ Awesome, I've got everything!

Your mocked network test is ready. Confirm that the file sits inside the tests folder.

ⓧ I'd like to double check the full code

mock_provider "aws" {}

variables {
  account_id = "123456789012"
}

run "secure_blueprint" {
  command = plan

  assert {
    condition     = aws_vpc.workload.cidr_block != aws_vpc.security.cidr_block
    error_message = "Workload and security VPC CIDRs must be different."
  }

  assert {
    condition     = aws_subnet.workload_private.map_public_ip_on_launch == false
    error_message = "The workload subnet must not assign public IP addresses."
  }

  assert {
    condition     = aws_subnet.security_private.map_public_ip_on_launch == false
    error_message = "The security subnet must not assign public IP addresses."
  }
}

What Should I Compare?

Confirm that the test uses plan mode. Check that all three assertions appear inside secure_blueprint.

The final checks now cover initialization, configuration validity, and the network assertions. Each check stays local to your Windows device.

  • Confirm the local provider initialization by running this command:
terraform init -backend=false

What Does This Recheck Confirm?

Terraform confirms that the project directory is initialized with the pinned AWS provider. Backend access remains disabled.

Initialization should complete successfully. The local provider cache is ready for validation and testing.

  • Validate the complete configuration by running this command:
terraform validate

What Does the Complete Validation Check?

Terraform checks every configuration file for valid syntax and internal consistency. It does not contact AWS APIs.

Terraform should report that the configuration is valid. Your provider pins, inputs, VPCs, and subnets now form one consistent blueprint.

Before you run the focused test, do you expect all three network assertions to pass?

  • Run the focused mocked network test with this command:
terraform test -filter='tests\security.tftest.hcl'

What Does the Focused Test Check?

  • The filter runs only tests\security.tftest.hcl.
  • The mocked provider evaluates the plan without AWS credentials.
  • The assertions compare the VPC CIDRs and inspect public IPv4 assignment for both subnets.

The focused test should pass all three assertions. That result proves the modeled VPC CIDRs differ and both subnets disable automatic public IPv4 assignment.

That is your first complete security feedback loop. Your segmented network is now represented and tested without creating anything in AWS.

Focused Test Did Not Pass?

Confirm that the test file path is tests\security.tftest.hcl. Check that the resource names in the assertions match network.tf exactly.

Compare both subnet values for map_public_ip_on_launch with the reference. Ask for help with diagnosing the mocked network test.

Your local blueprint now has a tested network boundary. Next, you will watch that safety net catch a deliberately public egress rule.

Watch an Open Egress Rule Fail

Your two isolated VPCs already pass their local Terraform tests. The next layer controls which destinations the workload can reach.

A public egress destination gives workloads a path beyond their intended boundary. You will model that risk with an AWS security group, then use a test to judge the HTTPS rule against your security intent.

In this step, get ready to:
  • Create workload and security-tools groups with no inbound rules.
  • Add TCP port 443 egress to 0.0.0.0/0.
  • Extend the focused test with egress assertions and inspect the intended failure.
Create the security groups

A security group acts as a traffic boundary for resources inside a VPC. Separate groups let the workload and security-tools networks carry different rules.

  • Create security.tf inside C:\Projects\secure-aws-blueprint from the VS Code Explorer sidebar.
  • Define the workload group by adding this code to security.tf:
resource "aws_security_group" "workload" {
  name        = "${var.project_name}-workload"
  description = "Workload group with no inbound rules and restricted HTTPS egress"
  vpc_id      = aws_vpc.workload.id

  tags = {
    Name = "${var.project_name}-workload"
  }
}

What Does This Resource Do?

  • The vpc_id value places the group inside the workload VPC.
  • The resource declares no inbound rules.
  • The tag gives the modeled group a project-specific name.
  • Save security.tf.
  • Check the new resource against the provider schema by running this command in the project terminal:
terraform validate

What Does This Command Do?

The command checks every Terraform file for valid syntax and internal consistency. It also checks the resource against the initialized provider schema.

Terraform reports that the configuration is valid. The workload group now fits the pinned provider schema.

Workload Group Did Not Validate?

Confirm that security.tf sits directly inside C:\Projects\secure-aws-blueprint. Check the resource name against aws_security_group.workload.

Ask for help with diagnosing the workload security group.

The security-tools group models a stricter boundary. Its configuration contains no ingress or egress rule resources.

  • Add the security-tools group below the workload group in security.tf by copying this code:
resource "aws_security_group" "security_tools" {
  name        = "${var.project_name}-security-tools"
  description = "Security tools group with no ingress or egress rules"
  vpc_id      = aws_vpc.security.id

  tags = {
    Name = "${var.project_name}-security-tools"
  }
}

What Does This Resource Do?

  • The group belongs to aws_vpc.security.
  • The configuration declares no inbound rules.
  • The configuration declares no outbound rules.
  • Save security.tf.
  • Validate both security groups by running this command:
terraform validate

What Does This Command Do?

Terraform checks both security-group resources against the local configuration and provider schema. The command does not contact AWS.

Terraform reports that the configuration is valid. Both modeled groups are now accepted locally.

Security-Tools Group Did Not Validate?

Check that vpc_id points to aws_vpc.security.id. Confirm that both resource blocks have their own closing brace.

Ask for help with diagnosing both security groups.

Add the public HTTPS egress rule

A standalone egress-rule resource makes the outbound destination explicit. This rule allows TCP port 443 to the public CIDR 0.0.0.0/0.

  • Return to security.tf in the editor.
  • Add the egress rule below the security-tools group by copying this code:
resource "aws_vpc_security_group_egress_rule" "workload_https" {
  security_group_id = aws_security_group.workload.id
  description       = "Allow HTTPS only inside the workload VPC"
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
}

What Does This Resource Do?

  • The security_group_id value attaches the rule to the workload group.
  • The matching port values limit the rule to port 443.
  • The cidr_ipv4 value defines the allowed destination range.
  • Save security.tf.
  • Check whether the provider schema accepts the egress resource by running this command:
terraform validate

What Does This Command Do?

Terraform checks the rule arguments against the local configuration and AWS provider schema. The validation remains local.

Terraform reports that the configuration is valid. The provider schema accepts the standalone egress rule.

Egress Rule Did Not Validate?

Confirm that security_group_id references aws_security_group.workload.id. Check that both port values are 443.

Ask for help with diagnosing the egress resource.

✔️ Awesome, I've got everything!

Great. Double-check that you saved security.tf.

ⓧ I'd like to double check the full code

resource "aws_security_group" "workload" {
  name        = "${var.project_name}-workload"
  description = "Workload group with no inbound rules and restricted HTTPS egress"
  vpc_id      = aws_vpc.workload.id

  tags = {
    Name = "${var.project_name}-workload"
  }
}

resource "aws_security_group" "security_tools" {
  name        = "${var.project_name}-security-tools"
  description = "Security tools group with no ingress or egress rules"
  vpc_id      = aws_vpc.security.id

  tags = {
    Name = "${var.project_name}-security-tools"
  }
}

resource "aws_vpc_security_group_egress_rule" "workload_https" {
  security_group_id = aws_security_group.workload.id
  description       = "Allow HTTPS only inside the workload VPC"
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
}
Make the boundary test fail

Provider validation proves that Terraform understands the resource shape. Security assertions check whether the planned values match your blueprint's rules.

  • Switch back to tests\security.tftest.hcl in the editor.
  • Find the assertion ending with The security subnet must not assign public IP addresses..
  • Add the TCP port assertion immediately below it by copying this code:
  assert {
    condition = (
      aws_vpc_security_group_egress_rule.workload_https.ip_protocol == "tcp" &&
      aws_vpc_security_group_egress_rule.workload_https.from_port == 443 &&
      aws_vpc_security_group_egress_rule.workload_https.to_port == 443
    )
    error_message = "Workload egress must allow TCP port 443 only."
  }

What Does This Assertion Check?

The assertion requires the egress rule to use TCP. It also requires both ends of the port range to equal 443.

  • Save tests\security.tftest.hcl.

Before you run the test, predict whether the current rule satisfies the TCP port assertion.

  • Run the focused security test with this command:
terraform test -filter='tests\security.tftest.hcl'

What Does This Command Do?

The command loads the selected test file. Its mocked provider evaluates the plan without AWS credentials or infrastructure.

The focused test passes. This confirms that the current rule uses TCP port 443.

Port Assertion Did Not Pass?

Check that ip_protocol is tcp. Confirm that from_port and to_port both equal 443.

Ask for help with diagnosing the port assertion.

The port check proves the protocol is narrow. A second assertion now checks whether the destination stays inside the workload boundary.

  • Find the assertion containing aws_vpc_security_group_egress_rule.workload_https.ip_protocol == "tcp".
  • Insert the destination assertion immediately above it by copying this code:
  assert {
    condition     = aws_vpc_security_group_egress_rule.workload_https.cidr_ipv4 == var.workload_vpc_cidr
    error_message = "Workload egress must stay inside the workload VPC."
  }

What Does This Assertion Check?

The condition compares the planned egress destination with var.workload_vpc_cidr. A mismatch triggers the assertion message.

  • Save tests\security.tftest.hcl.

Before you run the test, predict whether the public destination stays inside the workload VPC.

  • Run the focused security test again with this command:
terraform test -filter='tests\security.tftest.hcl'

What Does This Command Do?

The focused test evaluates both egress assertions against the mocked plan. No AWS credentials or resources are involved.

Terraform marks the test as failed and displays Workload egress must stay inside the workload VPC. This is the intended result.

You made the hidden risk visible. The test now exposes public egress before any infrastructure is created.

Did the Test Pass Unexpectedly?

Confirm that cidr_ipv4 remains 0.0.0.0/0 in security.tf. Check that the assertion compares it with var.workload_vpc_cidr.

Ask for help with diagnosing an unexpected passing result.

✔️ Awesome, I've got everything!

Great. Keep the intended failing result in place for the next step.

ⓧ I'd like to double check the full code

mock_provider "aws" {}

variables {
  account_id = "123456789012"
}

run "secure_blueprint" {
  command = plan

  assert {
    condition     = aws_vpc.workload.cidr_block != aws_vpc.security.cidr_block
    error_message = "Workload and security VPC CIDRs must be different."
  }

  assert {
    condition     = aws_subnet.workload_private.map_public_ip_on_launch == false
    error_message = "The workload subnet must not assign public IP addresses."
  }

  assert {
    condition     = aws_subnet.security_private.map_public_ip_on_launch == false
    error_message = "The security subnet must not assign public IP addresses."
  }

  assert {
    condition     = aws_vpc_security_group_egress_rule.workload_https.cidr_ipv4 == var.workload_vpc_cidr
    error_message = "Workload egress must stay inside the workload VPC."
  }

  assert {
    condition = (
      aws_vpc_security_group_egress_rule.workload_https.ip_protocol == "tcp" &&
      aws_vpc_security_group_egress_rule.workload_https.from_port == 443 &&
      aws_vpc_security_group_egress_rule.workload_https.to_port == 443
    )
    error_message = "Workload egress must allow TCP port 443 only."
  }
}

Your test has caught the unsafe destination exactly as designed. Next, you will restrict the rule to the workload VPC and watch the same assertion pass.

Restrict Network Egress

The failing Terraform test exposed the exact gap in your network boundary. The workload security group can currently send HTTPS traffic to every IPv4 destination.

This step replaces the public destination with the workload VPC CIDR. The focused test then proves that the rule allows TCP port 443 only inside that VPC.

In this step, get ready to:
  • Replace the public egress destination with the workload VPC CIDR.
  • Confirm that the security-tools group remains free of traffic rules.
  • Prove the corrected rule with local validation plus the focused test.
Restrict the workload egress destination

The CIDR 0.0.0.0/0 matches every IPv4 address. The value in var.workload_vpc_cidr resolves to the workload boundary already defined in variables.tf.

  • In security.tf, locate the aws_vpc_security_group_egress_rule.workload_https resource.
  • Find the cidr_ipv4 line whose value is 0.0.0.0/0.
  • Replace that line with the code below:
  cidr_ipv4         = var.workload_vpc_cidr

What Does This Change Do?

The egress rule now takes its destination from var.workload_vpc_cidr. That variable has the value 10.10.0.0/16 in your current blueprint.

The protocol remains TCP. Both port values remain fixed at 443.

  • Save security.tf.

✔️ Awesome, I've got everything!

Your workload egress destination now references var.workload_vpc_cidr. The protocol plus port settings remain unchanged.

ⓧ I'd like to double check the full code

Compare your saved security.tf file with this completed version.

resource "aws_security_group" "workload" {
  name        = "${var.project_name}-workload"
  description = "Workload group with no inbound rules and restricted HTTPS egress"
  vpc_id      = aws_vpc.workload.id

  tags = {
    Name = "${var.project_name}-workload"
  }
}

resource "aws_security_group" "security_tools" {
  name        = "${var.project_name}-security-tools"
  description = "Security tools group with no ingress or egress rules"
  vpc_id      = aws_vpc.security.id

  tags = {
    Name = "${var.project_name}-security-tools"
  }
}

resource "aws_vpc_security_group_egress_rule" "workload_https" {
  security_group_id = aws_security_group.workload.id
  description       = "Allow HTTPS only inside the workload VPC"
  cidr_ipv4         = var.workload_vpc_cidr
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
}

What Should Match?

This reference shows the complete security-group configuration for this step. The workload egress resource uses the VPC variable while the security-tools group contains no traffic rules.

Preserve the empty security-tools group

Terraform removes the default allow-all egress rule when a security group has no explicit traffic rules. Keeping aws_security_group.security_tools empty preserves its isolated state.

  • In security.tf, locate the aws_security_group.security_tools resource.
  • Confirm that the resource contains no ingress blocks.
  • Confirm that the resource contains no egress blocks.

You should find its name, description, VPC reference, plus tags. This confirms that the security-tools group has no allowed traffic paths.

Validate and retest the network controls

Terraform validation checks the configuration syntax. It also checks internal consistency.

  • Validate the saved configuration by running this command in the project terminal:
terraform validate

What Does This Command Check?

The command checks whether the Terraform files form a valid configuration. It performs this check without contacting AWS.

Terraform should report that the configuration is valid. Your egress edit now passes the configuration checks.

Validation Did Not Succeed?

Check that cidr_ipv4 uses var.workload_vpc_cidr without quotation marks. Confirm that you saved security.tf before rerunning the command.

Ask for targeted help with diagnosing the Terraform validation result.

Before you run the focused test, do you expect the egress assertion to pass or fail now?

  • Run the focused network security test by using this command:
terraform test -filter='tests\security.tftest.hcl'

What Does This Test Prove?

The mocked provider evaluates the planned security-group values without AWS credentials. The assertions compare the egress destination with var.workload_vpc_cidr.

The same test checks TCP plus both port boundaries. Together, these assertions enforce HTTPS-only egress inside the workload VPC.

Terraform should mark the secure_blueprint run as passed. The plan now contains TCP port 443 egress only to the workload VPC CIDR.

The Focused Test Still Fails?

Confirm that the edited line appears inside aws_vpc_security_group_egress_rule.workload_https. Check that from_port plus to_port still use 443.

Ask for help with diagnosing the focused Terraform test.

You closed the public-egress gap. Next, you will add encrypted audit logging plus least-privilege read access.

Add Encrypted Audit Logging and Least Privilege

Your Terraform blueprint now keeps workload traffic inside its intended boundary. That boundary protects the network path.

Network isolation cannot prove who changed a resource. This step adds encrypted audit storage through KMS, S3, and CloudTrail. It also adds a least-privilege IAM policy for reading the resulting logs.

In this step, get ready to:
  • Protect the audit bucket with versioning and SSE-KMS encryption.
  • Model a multi-Region CloudTrail trail with narrowly scoped service permissions.
  • Expand the local tests to verify every logging and access control.
Build the encrypted audit store

The audit store needs stable names before its resources can refer to one another. Local values keep those names consistent across the key, bucket, trail, and policies.

  • Create logging.tf from the VS Code Explorer sidebar.
  • Add the shared audit names by pasting this code into logging.tf:
locals {
  trail_name                 = "${var.project_name}-audit"
  trail_arn                  = "arn:aws:cloudtrail:${var.aws_region}:${var.account_id}:trail/${local.trail_name}"
  audit_bucket_name          = "${var.project_name}-cloudtrail-${var.account_id}"
  audit_encryption_algorithm = "aws:kms"
}

What Do These Local Values Do?

  • The trail name applies the project prefix to the modeled audit trail.
  • The trail ARN gives the service policies one exact CloudTrail resource to trust.
  • The bucket name includes the modeled account ID to create a predictable audit destination.
  • The encryption algorithm gives the bucket configuration and tests one shared SSE-KMS value.
  • Save logging.tf.
  • Confirm that the new file is valid by running this command in the project terminal:
terraform validate

What Does This Command Check?

The command checks the configuration's syntax and internal references. A success result proves the local values fit the existing blueprint.

You should see a success message. The empty audit layer now has stable names for every control that follows.

The KMS key needs an account administration statement. It also needs narrowly scoped permissions that let CloudTrail generate data keys and inspect the key.

  • Start the audit key below the local values by pasting this first section:
resource "aws_kms_key" "audit" {
  description             = "Encrypt CloudTrail audit logs"
  enable_key_rotation     = true
  deletion_window_in_days = 30

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "EnableAccountKeyAdministration"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${var.account_id}:root"
        }
        Action   = "kms:*"
        Resource = "*"
      },

What Does the Key Foundation Do?

  • Key rotation refreshes the key material on the managed rotation schedule.
  • The deletion window gives administrators time to recover from an accidental deletion request.
  • The first policy statement preserves account-level administration of the key.
  • Continue the policy directly after the previous comma by pasting this encryption statement:
      {
        Sid    = "AllowCloudTrailEncryptLogs"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "kms:GenerateDataKey*"
        Resource = "*"
        Condition = {
          StringEquals = {
            "aws:SourceArn" = local.trail_arn
          }
          StringLike = {
            "kms:EncryptionContext:aws:cloudtrail:arn" = "arn:aws:cloudtrail:*:${var.account_id}:trail/*"
          }
        }
      },

How Is Encryption Scoped?

  • The service principal grants the permission to CloudTrail.
  • The source ARN condition limits requests to the trail modeled by this blueprint.
  • The encryption context limits generated data keys to CloudTrail trail ARNs for the modeled account.
  • Finish the policy and key resource by pasting this final section immediately below the encryption statement:
      {
        Sid    = "AllowCloudTrailDescribeKey"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "kms:DescribeKey"
        Resource = "*"
        Condition = {
          StringEquals = {
            "aws:SourceArn" = local.trail_arn
          }
        }
      }
    ]
  })

  tags = {
    Name = "${var.project_name}-audit-key"
  }
}

Why Can CloudTrail Describe the Key?

  • The describe permission lets CloudTrail inspect the key before using it.
  • The source condition keeps that inspection tied to the modeled trail.
  • The final tag gives the key a project-specific name.
  • Save logging.tf.
  • Check the completed key resource by running this command:
terraform validate

What Does This Validation Prove?

Terraform checks the completed key policy as part of the local configuration. A success result confirms that every policy section closes correctly.

You should see a success message. Your blueprint now models a rotating encryption key with CloudTrail-specific permissions.

  • Add the dedicated audit bucket below the KMS key by pasting this resource:
resource "aws_s3_bucket" "audit" {
  bucket = local.audit_bucket_name

  tags = {
    Name = local.audit_bucket_name
  }
}

What Does the Audit Bucket Represent?

The bucket represents the private destination for CloudTrail log objects. Its name comes from the shared local value.

  • Add version protection below the bucket by pasting this resource:
resource "aws_s3_bucket_versioning" "audit" {
  bucket = aws_s3_bucket.audit.id

  versioning_configuration {
    status = "Enabled"
  }
}

Why Enable Versioning?

Versioning preserves earlier forms of an object after later writes or deletions. That history strengthens the audit store's resistance to accidental changes.

  • Save logging.tf.
  • Verify the bucket references by running this command:
terraform validate

What Does This Check Confirm?

The validation checks that the versioning resource points to the modeled audit bucket. It also checks the surrounding configuration.

You should see a success message. The local model now contains a versioned audit destination.

  • Block every form of public bucket access by adding this resource below the versioning resource:
resource "aws_s3_bucket_public_access_block" "audit" {
  bucket = aws_s3_bucket.audit.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

What Do the Four Public-Access Controls Cover?

  • Public ACL creation is blocked.
  • Public bucket policies are blocked.
  • Existing public ACLs are ignored.
  • Public access through bucket policies is restricted.
  • Require KMS encryption by adding this resource below the public-access block:
resource "aws_s3_bucket_server_side_encryption_configuration" "audit" {
  bucket = aws_s3_bucket.audit.id

  rule {
    apply_server_side_encryption_by_default {
      kms_master_key_id = aws_kms_key.audit.arn
      sse_algorithm     = local.audit_encryption_algorithm
    }

    blocked_encryption_types = ["SSE-C"]
  }
}

How Is the Bucket Encryption Enforced?

  • The default encryption rule points to the project KMS key.
  • The algorithm comes from the shared SSE-KMS local value.
  • The blocked type prevents customer-provided SSE-C keys from bypassing the modeled key.
  • Save logging.tf.
  • Verify the protected storage configuration by running this command:
terraform validate

What Does This Validation Cover?

The command checks the public-access resource and encryption resource against the pinned provider schema. It also resolves their references to the bucket and KMS key.

You should see a success message. The audit destination is now versioned, non-public, and encrypted with the modeled KMS key.

Seeing a Validation Error?

Check that each resource sits outside the preceding resource's closing brace. Confirm that aws_kms_key.audit and aws_s3_bucket.audit match their references exactly.

Ask for targeted help with diagnosing the encrypted audit store.

Add the trail and audit-reader policy

CloudTrail needs permission to inspect the bucket and write into the account-specific log prefix. The trail also needs an explicit dependency on the bucket policy and encryption configuration.

  • Start the bucket policy below the encryption resource by pasting this first section:
resource "aws_s3_bucket_policy" "audit" {
  bucket = aws_s3_bucket.audit.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AWSCloudTrailAclCheck20150319"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "s3:GetBucketAcl"
        Resource = aws_s3_bucket.audit.arn
        Condition = {
          StringEquals = {
            "aws:SourceArn" = local.trail_arn
          }
        }
      },

Why Can CloudTrail Read the Bucket ACL?

CloudTrail checks the destination bucket before delivering logs. The source ARN condition limits this permission to the modeled trail.

  • Finish the bucket policy by pasting this write statement directly after the previous comma:
      {
        Sid    = "AWSCloudTrailWrite20150319"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "s3:PutObject"
        Resource = "${aws_s3_bucket.audit.arn}/AWSLogs/${var.account_id}/*"
        Condition = {
          StringEquals = {
            "s3:x-amz-acl" = "bucket-owner-full-control"
            "aws:SourceArn" = local.trail_arn
          }
        }
      }
    ]
  })
}

How Is Log Delivery Restricted?

  • The write action targets only the account-specific CloudTrail log prefix.
  • The object ACL condition requires bucket-owner control.
  • The source ARN condition limits delivery to the modeled trail.
  • Save logging.tf.
  • Verify the completed bucket policy by running this command:
terraform validate

What Does This Check Prove?

The validation checks the JSON policy expression and every referenced ARN. A success result confirms that the delivery policy fits the local model.

You should see a success message. CloudTrail now has a tightly scoped path into the audit bucket.

  • Create the multi-Region audit trail below the bucket policy by pasting this resource:
resource "aws_cloudtrail" "audit" {
  depends_on = [
    aws_s3_bucket_policy.audit,
    aws_s3_bucket_server_side_encryption_configuration.audit
  ]

  name                          = local.trail_name
  s3_bucket_name                = aws_s3_bucket.audit.id
  kms_key_id                    = aws_kms_key.audit.arn
  include_global_service_events = true
  is_multi_region_trail         = true
  enable_log_file_validation    = true
  enable_logging                = true

  tags = {
    Name = local.trail_name
  }
}

What Does the Trail Capture?

  • The trail sends logs to the protected S3 bucket.
  • The KMS key encrypts the delivered log objects.
  • The multi-Region setting extends the modeled trail across AWS Regions.
  • Global service events and log-file validation strengthen coverage and integrity checking.
  • Save logging.tf.
  • Verify the CloudTrail configuration by running this command:
terraform validate

What Does This Validation Confirm?

The command checks the trail arguments against the pinned AWS provider schema. It also resolves the trail's dependencies on the storage controls.

You should see a success message. The blueprint now models encrypted audit delivery across Regions.

The audit reader needs enough access to inspect trail status and find events. It also needs access to the exact bucket, log prefix, and KMS key.

  • Start the managed audit-reader policy below the trail by pasting this first section:
resource "aws_iam_policy" "audit_reader" {
  name        = "${var.project_name}-audit-reader"
  description = "Read only access to this blueprint's CloudTrail status and encrypted logs"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "ReadTrailStatus"
        Effect   = "Allow"
        Action   = ["cloudtrail:GetTrailStatus"]
        Resource = local.trail_arn
      },
      {
        Sid      = "LookupAccountEvents"
        Effect   = "Allow"
        Action   = ["cloudtrail:LookupEvents"]
        Resource = "*"
      },

What Can the Reader Learn from CloudTrail?

  • The status permission targets the exact trail ARN.
  • The event lookup permission supports account event searches.
  • Finish the audit-reader policy by pasting this storage and decryption section directly after the previous comma:
      {
        Sid      = "ListAuditBucket"
        Effect   = "Allow"
        Action   = ["s3:ListBucket"]
        Resource = aws_s3_bucket.audit.arn
      },
      {
        Sid      = "ReadAuditObjects"
        Effect   = "Allow"
        Action   = ["s3:GetObject"]
        Resource = "${aws_s3_bucket.audit.arn}/AWSLogs/${var.account_id}/*"
      },
      {
        Sid      = "DecryptAuditObjects"
        Effect   = "Allow"
        Action   = ["kms:Decrypt"]
        Resource = aws_kms_key.audit.arn
      }
    ]
  })

  tags = {
    Name = "${var.project_name}-audit-reader"
  }
}

How Does the Policy Apply Least Privilege?

  • Bucket listing targets only the audit bucket.
  • Object reads target only the account-specific log prefix.
  • Decryption targets only the project KMS key.
  • The policy grants no write or administrative permissions.
  • Save logging.tf.
  • Verify the complete logging configuration by running this command:
terraform validate

What Does This Validation Establish?

Terraform checks every logging resource and policy reference as one configuration. A success result confirms that the encrypted trail and reader policy are internally consistent.

You should see a success message. The blueprint now models protected audit delivery and narrowly scoped read access.

Logging Configuration Does Not Validate?

Confirm that the bucket policy closes before aws_cloudtrail.audit begins. Check that the reader policy uses aws_kms_key.audit.arn for its decryption resource.

Ask for focused help with troubleshooting the logging policy references.

✔️ Awesome, I've got everything!

Great work. Save logging.tf before expanding the security tests.

ⓧ I'd like to double check the full code

locals {
  trail_name                 = "${var.project_name}-audit"
  trail_arn                  = "arn:aws:cloudtrail:${var.aws_region}:${var.account_id}:trail/${local.trail_name}"
  audit_bucket_name          = "${var.project_name}-cloudtrail-${var.account_id}"
  audit_encryption_algorithm = "aws:kms"
}

resource "aws_kms_key" "audit" {
  description             = "Encrypt CloudTrail audit logs"
  enable_key_rotation     = true
  deletion_window_in_days = 30

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "EnableAccountKeyAdministration"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${var.account_id}:root"
        }
        Action   = "kms:*"
        Resource = "*"
      },
      {
        Sid    = "AllowCloudTrailEncryptLogs"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "kms:GenerateDataKey*"
        Resource = "*"
        Condition = {
          StringEquals = {
            "aws:SourceArn" = local.trail_arn
          }
          StringLike = {
            "kms:EncryptionContext:aws:cloudtrail:arn" = "arn:aws:cloudtrail:*:${var.account_id}:trail/*"
          }
        }
      },
      {
        Sid    = "AllowCloudTrailDescribeKey"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "kms:DescribeKey"
        Resource = "*"
        Condition = {
          StringEquals = {
            "aws:SourceArn" = local.trail_arn
          }
        }
      }
    ]
  })

  tags = {
    Name = "${var.project_name}-audit-key"
  }
}

resource "aws_s3_bucket" "audit" {
  bucket = local.audit_bucket_name

  tags = {
    Name = local.audit_bucket_name
  }
}

resource "aws_s3_bucket_versioning" "audit" {
  bucket = aws_s3_bucket.audit.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_public_access_block" "audit" {
  bucket = aws_s3_bucket.audit.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_server_side_encryption_configuration" "audit" {
  bucket = aws_s3_bucket.audit.id

  rule {
    apply_server_side_encryption_by_default {
      kms_master_key_id = aws_kms_key.audit.arn
      sse_algorithm     = local.audit_encryption_algorithm
    }

    blocked_encryption_types = ["SSE-C"]
  }
}

resource "aws_s3_bucket_policy" "audit" {
  bucket = aws_s3_bucket.audit.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AWSCloudTrailAclCheck20150319"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "s3:GetBucketAcl"
        Resource = aws_s3_bucket.audit.arn
        Condition = {
          StringEquals = {
            "aws:SourceArn" = local.trail_arn
          }
        }
      },
      {
        Sid    = "AWSCloudTrailWrite20150319"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "s3:PutObject"
        Resource = "${aws_s3_bucket.audit.arn}/AWSLogs/${var.account_id}/*"
        Condition = {
          StringEquals = {
            "s3:x-amz-acl" = "bucket-owner-full-control"
            "aws:SourceArn" = local.trail_arn
          }
        }
      }
    ]
  })
}

resource "aws_cloudtrail" "audit" {
  depends_on = [
    aws_s3_bucket_policy.audit,
    aws_s3_bucket_server_side_encryption_configuration.audit
  ]

  name                          = local.trail_name
  s3_bucket_name                = aws_s3_bucket.audit.id
  kms_key_id                    = aws_kms_key.audit.arn
  include_global_service_events = true
  is_multi_region_trail         = true
  enable_log_file_validation    = true
  enable_logging                = true

  tags = {
    Name = local.trail_name
  }
}

resource "aws_iam_policy" "audit_reader" {
  name        = "${var.project_name}-audit-reader"
  description = "Read only access to this blueprint's CloudTrail status and encrypted logs"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "ReadTrailStatus"
        Effect   = "Allow"
        Action   = ["cloudtrail:GetTrailStatus"]
        Resource = local.trail_arn
      },
      {
        Sid      = "LookupAccountEvents"
        Effect   = "Allow"
        Action   = ["cloudtrail:LookupEvents"]
        Resource = "*"
      },
      {
        Sid      = "ListAuditBucket"
        Effect   = "Allow"
        Action   = ["s3:ListBucket"]
        Resource = aws_s3_bucket.audit.arn
      },
      {
        Sid      = "ReadAuditObjects"
        Effect   = "Allow"
        Action   = ["s3:GetObject"]
        Resource = "${aws_s3_bucket.audit.arn}/AWSLogs/${var.account_id}/*"
      },
      {
        Sid      = "DecryptAuditObjects"
        Effect   = "Allow"
        Action   = ["kms:Decrypt"]
        Resource = aws_kms_key.audit.arn
      }
    ]
  })

  tags = {
    Name = "${var.project_name}-audit-reader"
  }
}
Test every audit control

Validation proves that the configuration is internally consistent. Unit-test assertions prove that its planned values preserve your security requirements.

  • Return to tests\security.tftest.hcl in the VS Code editor.
  • Add this KMS rotation assertion inside run "secure_blueprint" after the existing egress assertions:
  assert {
    condition     = aws_kms_key.audit.enable_key_rotation == true
    error_message = "The audit KMS key must enable rotation."
  }

What Does This Assertion Protect?

The assertion reads the planned KMS key configuration. The test fails if key rotation is disabled.

  • Add this encryption algorithm assertion directly below the rotation assertion:
  assert {
    condition     = local.audit_encryption_algorithm == "aws:kms"
    error_message = "The audit bucket must use SSE-KMS."
  }

What Does This Assertion Protect?

The assertion locks the shared encryption value to SSE-KMS. A different algorithm causes the test to fail.

  • Save tests\security.tftest.hcl.
  • Run the expanded local suite with this command:
terraform test

What Does This Test Run Do?

Terraform plans the blueprint through the mocked AWS provider. The new assertions inspect the planned key rotation and encryption algorithm without contacting AWS.

The suite should pass. You have automated the first two audit-storage requirements.

  • Add this public-access assertion below the encryption assertion:
  assert {
    condition = (
      aws_s3_bucket_public_access_block.audit.block_public_acls == true &&
      aws_s3_bucket_public_access_block.audit.block_public_policy == true &&
      aws_s3_bucket_public_access_block.audit.ignore_public_acls == true &&
      aws_s3_bucket_public_access_block.audit.restrict_public_buckets == true
    )
    error_message = "Every S3 public-access block must be enabled."
  }

What Does This Assertion Protect?

The condition requires every public-access control to remain enabled. One changed value is enough to fail the test.

  • Add this trail assertion directly below the public-access assertion:
  assert {
    condition = (
      aws_cloudtrail.audit.is_multi_region_trail == true &&
      aws_cloudtrail.audit.include_global_service_events == true &&
      aws_cloudtrail.audit.enable_log_file_validation == true &&
      aws_cloudtrail.audit.enable_logging == true
    )
    error_message = "CloudTrail must be enabled across Regions with global events and log validation."
  }

What Does This Assertion Protect?

The condition checks four trail controls together. The test fails if regional coverage, global events, validation, or logging is disabled.

  • Save tests\security.tftest.hcl.
  • Run the local suite again with this command:
terraform test

What Does This Test Run Confirm?

The mocked plan now checks the audit bucket's public-access controls and the trail's coverage settings. No AWS credentials or resources are involved.

The suite should pass again. Your automated checks now cover encryption, public access, and trail integrity settings.

  • Add the final audit-reader assertion below the trail assertion:
  assert {
    condition     = aws_iam_policy.audit_reader.name == "${var.project_name}-audit-reader"
    error_message = "The scoped audit-reader policy must exist."
  }

What Does the Final Assertion Protect?

The assertion verifies that the planned audit-reader policy uses the project-scoped name. Its presence completes the automated audit-control checklist.

Before the final checks, do you expect the complete blueprint to pass without contacting AWS?

  • Save tests\security.tftest.hcl.
  • Check the complete configuration by running this command:
terraform validate

What Does the Final Validation Check?

The command checks syntax and internal consistency across the network, security, logging, and test files. It does not call AWS APIs.

You should see a success message. The complete blueprint is internally consistent.

  • Run the entire mocked security suite by using this command:
terraform test

What Does the Complete Suite Prove?

The mocked provider supplies local plan data for every AWS-backed resource. The assertions evaluate the network, KMS, S3, IAM, and CloudTrail values without creating infrastructure.

You should see every test pass. That result proves the blueprint represents the required controls at plan time.

A Security Assertion Failed?

Read the assertion message to identify the changed control. Compare that resource with the complete logging.tf reference above.

Confirm that each new assertion remains inside run "secure_blueprint". Ask for help with diagnosing the failed Terraform assertion.

✔️ Awesome, I've got everything!

Excellent. Save tests\security.tftest.hcl after confirming the complete suite passes.

ⓧ I'd like to double check the full code

mock_provider "aws" {}

variables {
  account_id = "123456789012"
}

run "secure_blueprint" {
  command = plan

  assert {
    condition     = aws_vpc.workload.cidr_block != aws_vpc.security.cidr_block
    error_message = "Workload and security VPC CIDRs must be different."
  }

  assert {
    condition     = aws_subnet.workload_private.map_public_ip_on_launch == false
    error_message = "The workload subnet must not assign public IP addresses."
  }

  assert {
    condition     = aws_subnet.security_private.map_public_ip_on_launch == false
    error_message = "The security subnet must not assign public IP addresses."
  }

  assert {
    condition     = aws_vpc_security_group_egress_rule.workload_https.cidr_ipv4 == var.workload_vpc_cidr
    error_message = "Workload egress must stay inside the workload VPC."
  }

  assert {
    condition = (
      aws_vpc_security_group_egress_rule.workload_https.ip_protocol == "tcp" &&
      aws_vpc_security_group_egress_rule.workload_https.from_port == 443 &&
      aws_vpc_security_group_egress_rule.workload_https.to_port == 443
    )
    error_message = "Workload egress must allow TCP port 443 only."
  }

  assert {
    condition     = aws_kms_key.audit.enable_key_rotation == true
    error_message = "The audit KMS key must enable rotation."
  }

  assert {
    condition     = local.audit_encryption_algorithm == "aws:kms"
    error_message = "The audit bucket must use SSE-KMS."
  }

  assert {
    condition = (
      aws_s3_bucket_public_access_block.audit.block_public_acls == true &&
      aws_s3_bucket_public_access_block.audit.block_public_policy == true &&
      aws_s3_bucket_public_access_block.audit.ignore_public_acls == true &&
      aws_s3_bucket_public_access_block.audit.restrict_public_buckets == true
    )
    error_message = "Every S3 public-access block must be enabled."
  }

  assert {
    condition = (
      aws_cloudtrail.audit.is_multi_region_trail == true &&
      aws_cloudtrail.audit.include_global_service_events == true &&
      aws_cloudtrail.audit.enable_log_file_validation == true &&
      aws_cloudtrail.audit.enable_logging == true
    )
    error_message = "CloudTrail must be enabled across Regions with global events and log validation."
  }

  assert {
    condition     = aws_iam_policy.audit_reader.name == "${var.project_name}-audit-reader"
    error_message = "The scoped audit-reader policy must exist."
  }
}

Strong finish. Your local blueprint now combines segmented networking with encrypted audit storage, multi-Region logging, and least-privilege read access.

Secret mission

Turn the Egress Rule into a Guardrail

Turn the safe HTTPS egress destination into a validated input. Prove that the local test suite rejects 0.0.0.0/0 without contacting AWS.

Clean Up Your Resources

Clean Up Your Resources

Your mocked Terraform tests run locally without creating AWS resources. Keeping the project folder costs $0.

Resources you used:

  • The local folder at C:\Projects\secure-aws-blueprint.

Keep everything running

This option suits you if you want to extend the blueprint or rerun its local checks. No cleanup is required.

  • Keep the C:\Projects\secure-aws-blueprint folder on your computer.
  • Reuse the existing test suite when you experiment with more security controls.

The blueprint remains available for future local tests. It creates no cloud resources while its tests use the mocked provider.

Pause - I'll come back to this later

This option closes your editor while preserving every project file. Completed Terraform tests leave no background process running.

  • Close the Visual Studio Code window from earlier.
  • Keep the C:\Projects\secure-aws-blueprint folder on your computer.

Your configuration files and local test suite stay ready for your next session.

Delete - I don't want to use this again

Deleting the folder is permanent, but it affects only the local prototype. No AWS resource needs deletion because the tests used mocked plans.

  • Close the Visual Studio Code window from earlier.
  • Press the Windows key to open search.
  • Search for File Explorer.
  • Select File Explorer from the search results.
  • Enter C:\Projects in the address bar.
  • Select the secure-aws-blueprint folder.
  • Press Shift+Delete to remove the folder permanently.
  • Confirm the permanent deletion when Windows asks.

You should no longer see secure-aws-blueprint inside C:\Projects. That confirms your source files and cached provider data are gone.

Nice Work!

Nice Work!

You did it! You've built a local Terraform blueprint that models a secure AWS foundation. Your full mocked test suite passes without AWS credentials or AWS resources.

You've learned how to:

  • Model network isolation through two non-overlapping VPCs. Keep both subnets private. Leave the VPCs disconnected.
  • Use Terraform unit tests with provider mocking to catch a public egress rule. Restrict HTTPS egress to var.workload_vpc_cidr. Run every check locally.
  • Encode protected audit logging with KMS encryption. Protect the S3 log bucket through versioning plus public-access controls. Require a multi-Region CloudTrail trail. Limit audit access through a scoped IAM policy.
  • Secret Mission: Protect var.workload_egress_cidr with an input validation guardrail. Prove that 0.0.0.0/0 is rejected by the negative test.

Your local blueprint has passed its security checks. Ready to quiz yourself?