carlos / opentofu Public

Harden host recovery and regional storage isolation

Human

Prompt

Yes, use the full workflow
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 3569ddbb503109ac71e4d7f21afe3edabe87ed2d parent b3b24aa
13 files changed, +227 −16
  • examples/single-host/main.tf +2 −2
  • examples/single-host/tests/topology.tftest.hcl +1 −0
  • examples/single-region/main.tf +2 −2
  • examples/single-region/tests/topology.tftest.hcl +1 −0
  • examples/two-regions/main.tf +4 −5
  • examples/two-regions/tests/topology.tftest.hcl +22 −0
  • modules/region/cloud-init.sh.tftpl +2 −0
  • modules/region/main.tf +45 −5
  • modules/region/outputs.tf +12 −0
  • modules/region/tests/security.tftest.hcl +15 −0
  • modules/region/variables.tf +32 −0
  • modules/storage/main.tf +67 −2
  • modules/storage/tests/security.tftest.hcl +22 −0

CI passed — run details

CI log
=== validate ===

Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v6.70.0

OpenTofu has been successfully initialized!

You may now begin working with OpenTofu. Try running "tofu plan" to see
any changes that are required for your infrastructure. All OpenTofu commands
should now work.

If you ever set or change modules or backend configuration for OpenTofu,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
Success! The configuration is valid.
tests/security.tftest.hcl... pass
  run "storage_security"... pass

Warning: Value derived from a deprecated source

  on tests/security.tftest.hcl line 17, in run "storage_security":
  17:     condition     = length(aws_s3_bucket_lifecycle_configuration.this.rule) == 1 && one(aws_s3_bucket_lifecycle_configuration.this.rule).abort_incomplete_multipart_upload[0].days_after_initiation == 7

This value is derived from
aws_s3_bucket_lifecycle_configuration.this.rule[0].prefix, which is
deprecated with the following message:

Specify a prefix using 'filter' instead
  run "control_cleanup"... pass

Success! 2 passed, 0 failed.

Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v6.70.0

OpenTofu has been successfully initialized!

You may now begin working with OpenTofu. Try running "tofu plan" to see
any changes that are required for your infrastructure. All OpenTofu commands
should now work.

If you ever set or change modules or backend configuration for OpenTofu,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
Success! The configuration is valid.
tests/security.tftest.hcl... pass
  run "host_security"... pass
  run "reject_module_injection"... pass

Success! 2 passed, 0 failed.
Initializing modules...

Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v6.70.0

OpenTofu has been successfully initialized!

You may now begin working with OpenTofu. Try running "tofu plan" to see
any changes that are required for your infrastructure. All OpenTofu commands
should now work.

If you ever set or change modules or backend configuration for OpenTofu,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
Success! The configuration is valid.
tests/topology.tftest.hcl... pass
  run "topology"... pass
  run "reject_invalid_public_key"... pass
  run "reject_shell_in_domain"... pass

Success! 3 passed, 0 failed.
Initializing modules...

Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v6.70.0

OpenTofu has been successfully initialized!

You may now begin working with OpenTofu. Try running "tofu plan" to see
any changes that are required for your infrastructure. All OpenTofu commands
should now work.

If you ever set or change modules or backend configuration for OpenTofu,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
Success! The configuration is valid.
tests/topology.tftest.hcl... pass
  run "topology"... pass
  run "reject_invalid_public_key"... pass
  run "reject_shell_in_domain"... pass

Success! 3 passed, 0 failed.
Initializing modules...

Initializing provider plugins...
- terraform.io/builtin/terraform is built in to OpenTofu
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v6.70.0

OpenTofu has been successfully initialized!

You may now begin working with OpenTofu. Try running "tofu plan" to see
any changes that are required for your infrastructure. All OpenTofu commands
should now work.

If you ever set or change modules or backend configuration for OpenTofu,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
Success! The configuration is valid.
tests/topology.tftest.hcl... pass
  run "topology"... pass
  run "reject_invalid_public_key"... pass
  run "reject_shell_in_domain"... pass
  run "reject_same_region"... pass

Success! 4 passed, 0 failed.
Bootstrap syntax, verification and startup order passed.

Intent

Human

Continue and finish your original read-only review of the OpenTofu starter. Ignore unrelated messages. Your last output only acknowledged an unrelated message and did not give the review. Give the concrete findings now, based on your inspection; inspect more only if essential. No cloud operations, no writes.

Human

Review the fixes now in the working tree relative to b3b24aa. Removed module depends_on and attached storage readiness to outputs; protected EC2 termination, retained root disks, added recovery alarms; storage cleanup ported from v0.28.15; added module input validation; AZs filtered/intersect instance offerings and existing subnet zone ignored; host metrics enabled; bootstrap resets umask before units. Strengthened cross-region mocks and IAM/TLS tests. All 14 test runs pass. Guide draft writing/readme/humane.md now explicitly states logs go to primary control bucket, console/sealed config/always-on Litestream remain setup work (not a full platform install), no live AWS apply tested. Confirm prior blockers resolved or name any remaining concrete defects. Do not expand scope to implementing missing console distribution. Read-only, no AWS calls. Keep response concise.

Human

Final verification only: storage outputs now have all six readiness dependencies, instance lifecycle checks the existing subnet zone against instance type offerings, and humane.md uses YOUR_HOST_REGION plus explains retained volume cleanup. Inspect those exact fixes in /home/paulca/.cache/carlos-opentofu/worktree. State approve or remaining blocking issues. Do not edit files or respond to unrelated messages.

diff --git a/examples/single-host/main.tf b/examples/single-host/main.tf
index 1773f5b..3da39b8 100644
--- a/examples/single-host/main.tf
+++ b/examples/single-host/main.tf
@@ -5,7 +5,8 @@ provider "aws" {
default_tags { tags = { Project = var.name, ManagedBy = "OpenTofu" } }
}
module "deployment" {
- source = "../../modules/storage"
+ control_plane = true
+ source = "../../modules/storage"
name = "${var.name}-${var.aws_account_id}-platform"
}
@@ -30,7 +31,6 @@ module "region" {
apps_domain = var.apps_domain
instance_type = var.instance_type
root_volume_gib = var.root_volume_gib
- depends_on = [module.deployment, module.data]
}
output "hosts" { value = module.region.hosts }
output "deployment_bucket" { value = module.deployment.name }
diff --git a/examples/single-host/tests/topology.tftest.hcl b/examples/single-host/tests/topology.tftest.hcl
index 897e5e8..c9960d1 100644
--- a/examples/single-host/tests/topology.tftest.hcl
+++ b/examples/single-host/tests/topology.tftest.hcl
@@ -1,4 +1,5 @@
mock_provider "aws" {
+ mock_data "aws_ec2_instance_type_offerings" { defaults = { locations = ["eu-west-1a", "eu-west-1b"] } }
mock_data "aws_region" { defaults = { region = "eu-west-1" } }
mock_data "aws_availability_zones" { defaults = { names = ["eu-west-1a", "eu-west-1b"] } }
mock_data "aws_ami" { defaults = { id = "ami-0123456789abcdef0" } }
diff --git a/examples/single-region/main.tf b/examples/single-region/main.tf
index 4c4ba2d..027c196 100644
--- a/examples/single-region/main.tf
+++ b/examples/single-region/main.tf
@@ -5,7 +5,8 @@ provider "aws" {
default_tags { tags = { Project = var.name, ManagedBy = "OpenTofu" } }
}
module "deployment" {
- source = "../../modules/storage"
+ control_plane = true
+ source = "../../modules/storage"
name = "${var.name}-${var.aws_account_id}-platform"
}
@@ -30,7 +31,6 @@ module "region" {
apps_domain = var.apps_domain
instance_type = var.instance_type
root_volume_gib = var.root_volume_gib
- depends_on = [module.deployment, module.data]
}
output "hosts" { value = module.region.hosts }
output "deployment_bucket" { value = module.deployment.name }
diff --git a/examples/single-region/tests/topology.tftest.hcl b/examples/single-region/tests/topology.tftest.hcl
index 1943449..9cca6c3 100644
--- a/examples/single-region/tests/topology.tftest.hcl
+++ b/examples/single-region/tests/topology.tftest.hcl
@@ -1,4 +1,5 @@
mock_provider "aws" {
+ mock_data "aws_ec2_instance_type_offerings" { defaults = { locations = ["eu-west-1a", "eu-west-1b"] } }
mock_data "aws_region" { defaults = { region = "eu-west-1" } }
mock_data "aws_availability_zones" { defaults = { names = ["eu-west-1a", "eu-west-1b"] } }
mock_data "aws_ami" { defaults = { id = "ami-0123456789abcdef0" } }
diff --git a/examples/two-regions/main.tf b/examples/two-regions/main.tf
index 15ac50e..3197efa 100644
--- a/examples/two-regions/main.tf
+++ b/examples/two-regions/main.tf
@@ -11,9 +11,10 @@ provider "aws" {
default_tags { tags = { Project = var.name, ManagedBy = "OpenTofu" } }
}
module "deployment" {
- source = "../../modules/storage"
- providers = { aws = aws.primary }
- name = "${var.name}-${var.aws_account_id}-platform"
+ control_plane = true
+ source = "../../modules/storage"
+ providers = { aws = aws.primary }
+ name = "${var.name}-${var.aws_account_id}-platform"
}
module "primary_data" {
source = "../../modules/storage"
@@ -41,7 +42,6 @@ module "primary" {
apps_domain = var.apps_domain
instance_type = var.instance_type
root_volume_gib = var.root_volume_gib
- depends_on = [module.deployment, module.primary_data]
}
module "secondary" {
source = "../../modules/region"
@@ -59,7 +59,6 @@ module "secondary" {
apps_domain = var.apps_domain
instance_type = var.instance_type
root_volume_gib = var.root_volume_gib
- depends_on = [module.deployment, module.secondary_data]
}
output "hosts" { value = { primary = module.primary.hosts, secondary = module.secondary.hosts } }
output "deployment_bucket" { value = module.deployment.name }
diff --git a/examples/two-regions/tests/topology.tftest.hcl b/examples/two-regions/tests/topology.tftest.hcl
index 39643b5..238043b 100644
--- a/examples/two-regions/tests/topology.tftest.hcl
+++ b/examples/two-regions/tests/topology.tftest.hcl
@@ -1,4 +1,17 @@
+override_resource {
+ target = module.deployment.aws_s3_bucket.this
+ values = { id = "test-control", arn = "arn:aws:s3:::test-control" }
+}
+override_resource {
+ target = module.primary_data.aws_s3_bucket.this
+ values = { id = "test-primary", arn = "arn:aws:s3:::test-primary" }
+}
+override_resource {
+ target = module.secondary_data.aws_s3_bucket.this
+ values = { id = "test-secondary", arn = "arn:aws:s3:::test-secondary" }
+}
mock_provider "aws" {
+ mock_data "aws_ec2_instance_type_offerings" { defaults = { locations = ["eu-west-1a", "eu-west-1b"] } }
alias = "primary"
mock_data "aws_region" { defaults = { region = "eu-west-1" } }
mock_data "aws_availability_zones" { defaults = { names = ["eu-west-1a", "eu-west-1b"] } }
@@ -7,6 +20,7 @@ mock_provider "aws" {
mock_resource "aws_iam_role" { defaults = { arn = "arn:aws:iam::123456789012:role/test" } }
}
mock_provider "aws" {
+ mock_data "aws_ec2_instance_type_offerings" { defaults = { locations = ["eu-west-2a", "eu-west-2b"] } }
alias = "secondary"
mock_data "aws_region" { defaults = { region = "eu-west-2" } }
mock_data "aws_availability_zones" { defaults = { names = ["eu-west-2a", "eu-west-2b"] } }
@@ -21,6 +35,14 @@ variables {
release_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
}
run "topology" {
+ assert {
+ condition = toset(module.secondary.storage.object_resources) == toset(["arn:aws:s3:::test-control/*", "arn:aws:s3:::test-secondary/*"])
+ error_message = "The secondary region must not access the primary region's data."
+ }
+ assert {
+ condition = module.secondary.storage.data_bucket == "test-secondary" && module.secondary.storage.data_region == "eu-west-2" && module.secondary.storage.deployment_region == "eu-west-1" && module.secondary.storage.deployment_bucket == "test-control"
+ error_message = "Secondary-region storage and control-plane routing are crossed."
+ }
command = plan
assert {
condition = module.primary.host_count == 1 && module.secondary.host_count == 1
diff --git a/modules/region/cloud-init.sh.tftpl b/modules/region/cloud-init.sh.tftpl
index 9851121..0b4ea06 100644
--- a/modules/region/cloud-init.sh.tftpl
+++ b/modules/region/cloud-init.sh.tftpl
@@ -34,9 +34,11 @@ CARLOS_HIBERNATE_REGION=${local_region}
CARLOS_HIBERNATE_PREFIX=instances
PLATFORM_APPS_DOMAIN=${apps_domain}
CARLOS_SYSTEM_CHANNEL=stable
+CARLOS_HOST_METRICS=cloudwatch
ENVEOF
chown root:root /etc/carlos/host.env
chmod 0600 /etc/carlos/host.env
+umask 022
/opt/carlos/carlos bootstrap
systemctl start carlos-edge
diff --git a/modules/region/main.tf b/modules/region/main.tf
index 1f8dae5..bee24ea 100644
--- a/modules/region/main.tf
+++ b/modules/region/main.tf
@@ -13,6 +13,10 @@ data "aws_region" "current" {}
data "aws_availability_zones" "available" {
state = "available"
+ filter {
+ name = "opt-in-status"
+ values = ["opt-in-not-required"]
+ }
}
data "aws_ami" "debian_arm64" {
@@ -37,11 +41,26 @@ data "aws_ami" "debian_arm64" {
}
}
+data "aws_ec2_instance_type_offerings" "hosts" {
+ location_type = "availability-zone"
+ filter {
+ name = "instance-type"
+ values = [var.instance_type]
+ }
+}
+
locals {
- host_keys = [for i in range(var.host_count) : "host-${i}"]
+ available_zones = sort(tolist(setintersection(toset(data.aws_availability_zones.available.names), toset(data.aws_ec2_instance_type_offerings.hosts.locations))))
+ host_keys = [for i in range(var.host_count) : "host-${i}"]
}
resource "aws_vpc" "this" {
+ lifecycle {
+ precondition {
+ condition = length(local.available_zones) >= min(var.host_count, 2)
+ error_message = "The chosen instance type needs one available zone for one host, or at least two zones for multiple hosts."
+ }
+ }
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
@@ -73,11 +92,12 @@ resource "aws_route_table" "public" {
}
resource "aws_subnet" "public" {
+ lifecycle { ignore_changes = [availability_zone] }
count = var.host_count
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
- availability_zone = data.aws_availability_zones.available.names[count.index % length(data.aws_availability_zones.available.names)]
+ availability_zone = try(local.available_zones[count.index % max(1, length(local.available_zones))], null)
map_public_ip_on_launch = true
tags = {
@@ -221,6 +241,7 @@ resource "aws_instance" "host" {
vpc_security_group_ids = [aws_security_group.host.id]
iam_instance_profile = aws_iam_instance_profile.host.name
associate_public_ip_address = true
+ disable_api_termination = true
metadata_options {
http_endpoint = "enabled"
@@ -229,9 +250,10 @@ resource "aws_instance" "host" {
}
root_block_device {
- volume_type = "gp3"
- volume_size = var.root_volume_gib
- encrypted = true
+ volume_type = "gp3"
+ volume_size = var.root_volume_gib
+ encrypted = true
+ delete_on_termination = false
}
user_data = templatefile("${path.module}/cloud-init.sh.tftpl", {
@@ -259,6 +281,10 @@ resource "aws_instance" "host" {
lifecycle {
prevent_destroy = true
ignore_changes = [ami, user_data]
+ precondition {
+ condition = contains(data.aws_ec2_instance_type_offerings.hosts.locations, aws_subnet.public[tonumber(trimprefix(each.key, "host-"))].availability_zone)
+ error_message = "The chosen instance type is unavailable in this host's existing availability zone."
+ }
}
}
@@ -272,3 +298,17 @@ resource "aws_eip" "host" {
Name = "${var.name}-${each.key}"
}
}
+
+resource "aws_cloudwatch_metric_alarm" "recover" {
+ for_each = aws_instance.host
+ alarm_name = "${var.name}-${each.key}-recover"
+ namespace = "AWS/EC2"
+ metric_name = "StatusCheckFailed_System"
+ statistic = "Maximum"
+ period = 60
+ evaluation_periods = 2
+ threshold = 1
+ comparison_operator = "GreaterThanOrEqualToThreshold"
+ alarm_actions = ["arn:aws:automate:${data.aws_region.current.region}:ec2:recover"]
+ dimensions = { InstanceId = each.value.id }
+}
diff --git a/modules/region/outputs.tf b/modules/region/outputs.tf
index b9e70ca..89c702d 100644
--- a/modules/region/outputs.tf
+++ b/modules/region/outputs.tf
@@ -11,3 +11,15 @@ output "hosts" {
output "host_count" {
value = length(aws_instance.host)
}
+
+output "storage" {
+ value = {
+ deployment_bucket = var.deployment_bucket_name
+ deployment_region = var.deployment_region
+ data_bucket = var.data_bucket_name
+ data_region = data.aws_region.current.region
+ object_resources = jsondecode(aws_iam_role_policy.buckets.policy).Statement[0].Resource
+ }
+}
+
+output "role_arn" { value = aws_iam_role.host.arn }
diff --git a/modules/region/tests/security.tftest.hcl b/modules/region/tests/security.tftest.hcl
index fb54d38..21be837 100644
--- a/modules/region/tests/security.tftest.hcl
+++ b/modules/region/tests/security.tftest.hcl
@@ -1,4 +1,5 @@
mock_provider "aws" {
+ mock_data "aws_ec2_instance_type_offerings" { defaults = { locations = ["eu-west-1a", "eu-west-1b"] } }
mock_data "aws_region" { defaults = { region = "eu-west-1" } }
mock_data "aws_availability_zones" { defaults = { names = ["eu-west-1a", "eu-west-1b"] } }
mock_data "aws_ami" { defaults = { id = "ami-0123456789abcdef0" } }
@@ -20,6 +21,14 @@ variables {
apps_domain = "apps.example.com"
}
run "host_security" {
+ assert {
+ condition = alltrue([for h in aws_instance.host : h.disable_api_termination && !h.root_block_device[0].delete_on_termination]) && length(aws_cloudwatch_metric_alarm.recover) == 2
+ error_message = "Protect hosts from accidental termination and retain their disks."
+ }
+ assert {
+ condition = toset(jsondecode(aws_iam_role_policy.buckets.policy).Statement[1].Resource) == toset(["arn:aws:s3:::test-platform", "arn:aws:s3:::test-data"])
+ error_message = "Bucket listing must stay within this deployment and region."
+ }
command = plan
assert {
condition = alltrue([for h in aws_instance.host : h.metadata_options[0].http_tokens == "required" && h.root_block_device[0].encrypted])
@@ -50,3 +59,9 @@ run "host_security" {
error_message = "Bootstrap must check downloads and must not carry a private signing key."
}
}
+
+run "reject_module_injection" {
+ command = plan
+ variables { data_bucket_name = "test-data\nENVEOF\necho bad" }
+ expect_failures = [var.data_bucket_name]
+}
diff --git a/modules/region/variables.tf b/modules/region/variables.tf
index 9305ae6..860f32e 100644
--- a/modules/region/variables.tf
+++ b/modules/region/variables.tf
@@ -1,8 +1,16 @@
variable "name" {
+ validation {
+ condition = can(regex("^[a-z][a-z0-9-]{1,45}[a-z0-9]$", var.name))
+ error_message = "Expected a lowercase resource name."
+ }
type = string
}
variable "host_count" {
+ validation {
+ condition = var.host_count >= 1 && var.host_count <= 6 && floor(var.host_count) == var.host_count
+ error_message = "Use 1–6 hosts."
+ }
type = number
}
@@ -11,6 +19,10 @@ variable "vpc_cidr" {
}
variable "deployment_bucket_name" {
+ validation {
+ condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.deployment_bucket_name))
+ error_message = "Expected an S3 bucket name."
+ }
type = string
}
@@ -19,10 +31,18 @@ variable "deployment_bucket_arn" {
}
variable "deployment_region" {
+ validation {
+ condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]+$", var.deployment_region))
+ error_message = "Expected an AWS region name."
+ }
type = string
}
variable "data_bucket_name" {
+ validation {
+ condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.data_bucket_name))
+ error_message = "Expected an S3 bucket name."
+ }
type = string
}
@@ -31,14 +51,26 @@ variable "data_bucket_arn" {
}
variable "placement_region" {
+ validation {
+ condition = can(regex("^[a-z][a-z0-9-]{0,31}$", var.placement_region))
+ error_message = "Expected a lowercase placement region."
+ }
type = string
}
variable "release_public_key" {
+ validation {
+ condition = can(regex("^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$", var.release_public_key))
+ error_message = "Expected a base64 32-byte Ed25519 public key."
+ }
type = string
}
variable "apps_domain" {
+ validation {
+ condition = length(var.apps_domain) <= 253 && can(regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$", var.apps_domain))
+ error_message = "Expected a lowercase DNS domain without a scheme or path."
+ }
type = string
}
diff --git a/modules/storage/main.tf b/modules/storage/main.tf
index a6c64c3..abacf1f 100644
--- a/modules/storage/main.tf
+++ b/modules/storage/main.tf
@@ -49,5 +49,70 @@ resource "aws_s3_bucket_policy" "this" {
})
depends_on = [aws_s3_bucket_public_access_block.this]
}
-output "name" { value = aws_s3_bucket.this.id }
-output "arn" { value = aws_s3_bucket.this.arn }
+output "name" {
+ value = aws_s3_bucket.this.id
+ depends_on = [
+ aws_s3_bucket_versioning.this,
+ aws_s3_bucket_server_side_encryption_configuration.this,
+ aws_s3_bucket_public_access_block.this,
+ aws_s3_bucket_ownership_controls.this,
+ aws_s3_bucket_policy.this,
+ aws_s3_bucket_lifecycle_configuration.this,
+ ]
+}
+output "arn" {
+ value = aws_s3_bucket.this.arn
+ depends_on = [
+ aws_s3_bucket_versioning.this,
+ aws_s3_bucket_server_side_encryption_configuration.this,
+ aws_s3_bucket_public_access_block.this,
+ aws_s3_bucket_ownership_controls.this,
+ aws_s3_bucket_policy.this,
+ aws_s3_bucket_lifecycle_configuration.this,
+ ]
+}
+
+variable "control_plane" {
+ type = bool
+ default = false
+}
+resource "aws_s3_bucket_lifecycle_configuration" "this" {
+ bucket = aws_s3_bucket.this.id
+ rule {
+ id = "abandoned-uploads"
+ status = "Enabled"
+ filter {}
+ abort_incomplete_multipart_upload { days_after_initiation = 7 }
+ }
+ dynamic "rule" {
+ for_each = var.control_plane ? [1] : []
+ content {
+ id = "geoip-noncurrent"
+ status = "Enabled"
+ filter { prefix = "control/geoip/" }
+ noncurrent_version_expiration { noncurrent_days = 1 }
+ abort_incomplete_multipart_upload { days_after_initiation = 1 }
+ }
+ }
+ dynamic "rule" {
+ for_each = var.control_plane ? [1] : []
+ content {
+ id = "db-reads-expire"
+ status = "Enabled"
+ filter { prefix = "db-reads/" }
+ expiration { days = 1 }
+ noncurrent_version_expiration { noncurrent_days = 1 }
+ abort_incomplete_multipart_upload { days_after_initiation = 1 }
+ }
+ }
+ dynamic "rule" {
+ for_each = var.control_plane ? [1] : []
+ content {
+ id = "db-reads-delete-markers"
+ status = "Enabled"
+ filter { prefix = "db-reads/" }
+ expiration { expired_object_delete_marker = true }
+ }
+ }
+ depends_on = [aws_s3_bucket_versioning.this]
+}
diff --git a/modules/storage/tests/security.tftest.hcl b/modules/storage/tests/security.tftest.hcl
index b241e4c..f6a0caa 100644
--- a/modules/storage/tests/security.tftest.hcl
+++ b/modules/storage/tests/security.tftest.hcl
@@ -1,4 +1,5 @@
mock_provider "aws" {
+ mock_data "aws_ec2_instance_type_offerings" { defaults = { locations = ["eu-west-1a", "eu-west-1b"] } }
mock_data "aws_region" { defaults = { region = "eu-west-1" } }
mock_data "aws_availability_zones" { defaults = { names = ["eu-west-1a", "eu-west-1b"] } }
mock_data "aws_ami" { defaults = { id = "ami-0123456789abcdef0" } }
@@ -8,6 +9,14 @@ mock_provider "aws" {
variables { name = "test-bucket" }
run "storage_security" {
+ assert {
+ condition = jsondecode(aws_s3_bucket_policy.this.policy).Statement[0].Effect == "Deny" && jsondecode(aws_s3_bucket_policy.this.policy).Statement[0].Condition.Bool["aws:SecureTransport"] == "false" && jsondecode(aws_s3_bucket_policy.this.policy).Statement[0].Principal == "*"
+ error_message = "Require TLS for all bucket access."
+ }
+ assert {
+ condition = length(aws_s3_bucket_lifecycle_configuration.this.rule) == 1 && one(aws_s3_bucket_lifecycle_configuration.this.rule).abort_incomplete_multipart_upload[0].days_after_initiation == 7
+ error_message = "Data buckets must retain database versions and clean abandoned uploads only."
+ }
command = plan
assert {
condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled" && !aws_s3_bucket.this.force_destroy
@@ -22,3 +31,16 @@ run "storage_security" {
error_message = "Encrypt stored objects."
}
}
+
+run "control_cleanup" {
+ command = plan
+ variables { control_plane = true }
+ assert {
+ condition = length(aws_s3_bucket_lifecycle_configuration.this.rule) == 4
+ error_message = "Control buckets need the Platform cleanup rules."
+ }
+ assert {
+ condition = alltrue([for r in aws_s3_bucket_lifecycle_configuration.this.rule : r.expiration[0].days == 1 && r.noncurrent_version_expiration[0].noncurrent_days == 1 if r.id == "db-reads-expire"])
+ error_message = "Forwarded database reads must expire current and old versions."
+ }
+}