carlos / platform Public

region-host: store_scanning, the console's opt-in read-and-tag grant

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev a42a55026120e4b469641863d44570706632f958 parent 741e5bd
3 files changed, +104 −0
  • infra/modules/region-host/main.tf +35 −0
  • infra/modules/region-host/tests/store_scanning.tftest.hcl +63 −0
  • infra/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