region-host: store_scanning, the console's opt-in read-and-tag grant
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+104
−0
infra/modules/region-host/main.tf+35 −0infra/modules/region-host/tests/store_scanning.tftest.hcl+63 −0infra/modules/region-host/variables.tf+6 −0
diff --git a/infra/modules/region-host/main.tf b/infra/modules/region-host/main.tf| index 14b7724a6..2b8cb7246 100644 |
| --- a/infra/modules/region-host/main.tf |
| +++ b/infra/modules/region-host/main.tf |
| @@ -610,6 +610,8 @@ resource "aws_iam_role_policy" "host_store" { |
| # ListBucket on the bucket and DeleteObject under it; teardown needs |
| # DeleteBucket. No GetObject: the console provisions stores, it does not |
| # read tenant data, and the economics sweep sizes buckets with ListBucket. |
| + # (Scanning is the exception, and it is a separate policy behind |
| + # store_scanning, below.) |
| { |
| Effect = "Allow" |
| Action = ["s3:CreateBucket", "s3:DeleteBucket", "s3:ListBucket", "s3:GetBucketLocation"] |
| @@ -669,3 +671,36 @@ resource "aws_iam_role_policy" "host_store" { |
| ] |
| }) |
| } |
| + |
| +# ---- object storage scanning (opt-in) -------------------------------------- |
| +# |
| +# The one grant that lets this role read tenant object bytes, so it is its own |
| +# policy behind its own switch: a deployment that has not turned scanning on |
| +# plans no change here, and the sentence in host_store above ("it does not |
| +# read tenant data") stays true for it. |
| +# |
| +# Exactly the calls the scanner makes. HeadObject is authorised by GetObject; |
| +# ListObjectsV2 is already in host_store. Store buckets use SSE-S3, so there is |
| +# no KMS statement. PutObjectTagging is how a verdict is written; the store |
| +# user boundary deliberately lacks it, so an app cannot write one itself. |
| +resource "aws_iam_role_policy" "host_store_scan" { |
| + count = var.store_scanning ? 1 : 0 |
| + name = "${var.iam_name}-store-scan" |
| + role = aws_iam_role.host.id |
| + policy = jsonencode({ |
| + Version = "2012-10-17" |
| + Statement = [ |
| + { |
| + Effect = "Allow" |
| + Action = ["s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging"] |
| + Resource = "arn:aws:s3:::${var.store_bucket_prefix}*/*" |
| + }, |
| + ] |
| + }) |
| + lifecycle { |
| + precondition { |
| + condition = var.store_bucket_prefix != "" |
| + error_message = "store_scanning needs store_bucket_prefix: with no prefix there are no object stores on this deployment to scan, and the grant would name every bucket in the account." |
| + } |
| + } |
| +} |
diff --git a/infra/modules/region-host/tests/store_scanning.tftest.hcl b/infra/modules/region-host/tests/store_scanning.tftest.hcl| new file mode 100644 |
| index 000000000..16a9ed0e9 |
| --- /dev/null |
| +++ b/infra/modules/region-host/tests/store_scanning.tftest.hcl |
| @@ -0,0 +1,63 @@ |
| +# Plan-time tests for the scanning grant. mock_provider means no AWS account |
| +# and no credentials: these assert what the module WOULD create. |
| +mock_provider "aws" {} |
| + |
| +override_data { |
| + target = data.aws_vpcs.default |
| + values = { ids = ["vpc-0aaaaaaaaaaaaaaaa"] } |
| +} |
| + |
| +variables { |
| + name = "carlos-test-ie" |
| + sg_description = "carlos test host" |
| + iam_name = "carlos-test-host" |
| + instance_type = "t4g.small" |
| + root_volume_size = 20 |
| + user_data = "#!/bin/sh\n" |
| + deployment_bucket_arn = "arn:aws:s3:::carlos-test-platform" |
| + data_bucket_arn = "arn:aws:s3:::carlos-test-ie" |
| + ssm_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/carlos/*" |
| + store_bucket_prefix = "carlos-store-test-" |
| +} |
| + |
| +run "off_by_default" { |
| + command = plan |
| + assert { |
| + condition = length(aws_iam_role_policy.host_store_scan) == 0 |
| + error_message = "store_scanning defaults to false and must create nothing" |
| + } |
| +} |
| + |
| +run "on_grants_exactly_read_and_tag" { |
| + command = plan |
| + variables { |
| + store_scanning = true |
| + } |
| + assert { |
| + condition = length(aws_iam_role_policy.host_store_scan) == 1 |
| + error_message = "store_scanning = true must create the scan policy" |
| + } |
| + assert { |
| + condition = aws_iam_role_policy.host_store_scan[0].name == "carlos-test-host-store-scan" |
| + error_message = "the policy is named <iam_name>-store-scan" |
| + } |
| + assert { |
| + condition = toset(jsondecode(aws_iam_role_policy.host_store_scan[0].policy).Statement[0].Action) == toset([ |
| + "s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging", |
| + ]) |
| + error_message = "exactly the three actions the scanner calls, no more" |
| + } |
| + assert { |
| + condition = jsondecode(aws_iam_role_policy.host_store_scan[0].policy).Statement[0].Resource == "arn:aws:s3:::carlos-store-test-*/*" |
| + error_message = "scoped to objects under the store prefix" |
| + } |
| +} |
| + |
| +run "refused_without_a_store_prefix" { |
| + command = plan |
| + variables { |
| + store_scanning = true |
| + store_bucket_prefix = "" |
| + } |
| + expect_failures = [aws_iam_role_policy.host_store_scan] |
| +} |
diff --git a/infra/modules/region-host/variables.tf b/infra/modules/region-host/variables.tf| index 75f16cdd5..dfdd197c9 100644 |
| --- a/infra/modules/region-host/variables.tf |
| +++ b/infra/modules/region-host/variables.tf |
| @@ -206,6 +206,12 @@ variable "store_user_path" { |
| default = "/carlos-store/" |
| } |
| +variable "store_scanning" { |
| + description = "Let this host's role READ and TAG objects under store_bucket_prefix, which is what the console's virus scanner needs (s3:GetObject, s3:GetObjectTagging, s3:PutObjectTagging). Off by default: without it the console provisions stores and never reads tenant data. Set it only on the box that runs the console, and only on a deployment that runs a scanner (infra/modules/scanner-host). Requires store_bucket_prefix." |
| + type = bool |
| + default = false |
| +} |
| + |
| variable "relay" { |
| description = "Open the platform STUN/TURN relay's ports (udp+tcp 3478, udp 49152-65535). Off by default; a box also needs /etc/carlos/relay-masters before the unit starts." |
| type = bool |