carlos / platform Public

infra: region-remote-host module — data bucket + scoped off-cloud principal (pi now, hq later)

Paul Campbell e7b40fb013fd9a888403e774e66c4aa9701d51d8 parent f11e8c5
4 files changed, +125 −0
  • infra/modules/region-remote-host/README.md +21 −0
  • infra/modules/region-remote-host/main.tf +84 −0
  • infra/modules/region-remote-host/outputs.tf +6 −0
  • infra/modules/region-remote-host/variables.tf +14 −0
diff --git a/infra/modules/region-remote-host/README.md b/infra/modules/region-remote-host/README.md
new file mode 100644
index 000000000..eeee954e8
--- /dev/null
+++ b/infra/modules/region-remote-host/README.md
@@ -0,0 +1,21 @@
+# region-remote-host
+
+Provisions what an off-cloud CARLOS host needs when there is no EC2 instance
+profile to lean on: a region data bucket plus an IAM user carrying a scoped
+inline policy and access key, limited to exactly the deployment bucket and
+this region's data bucket (wrong-region-fails-at-IAM, the same residency
+property `region-host` gives cloud hosts).
+
+Two consumers: the dev Raspberry Pi (`region_code = "pi"`) landing in this
+PR, and tito's hq box (`region_code = "hq"`) in a later step of the
+remote-host plan. The module is reusable as-is for both — they differ only
+in account and variable values, not shape.
+
+The S3 action list in the inline policy is not independently chosen: it is
+copied verbatim from `infra/modules/region-host/main.tf`'s
+`aws_iam_role_policy.host_buckets` (the authority for what this principal
+shape may do to S3), split the same way region-host splits it — object
+actions (`GetObject`/`PutObject`/`DeleteObject`) scoped to the object ARNs,
+and bucket-level actions (`ListBucket`/`GetBucketLocation`) scoped to the
+bucket ARNs. If region-host's action list changes, this module's should
+change with it.
diff --git a/infra/modules/region-remote-host/main.tf b/infra/modules/region-remote-host/main.tf
new file mode 100644
index 000000000..4e85d35e4
--- /dev/null
+++ b/infra/modules/region-remote-host/main.tf
@@ -0,0 +1,84 @@
+# region-remote-host: what an off-cloud CARLOS host (a Raspberry Pi today,
+# tito's hq box later) needs from AWS — a region data bucket plus a scoped
+# IAM principal, since there is no EC2 instance profile to lean on off-cloud.
+#
+# Residency is enforced the same way as region-host: the principal reaches
+# the deployment bucket plus ITS OWN region's data bucket and nothing else.
+# A wrong-region host fails loudly at the IAM layer, same property as every
+# cloud host.
+
+terraform {
+ required_version = ">= 1.10"
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.0"
+ }
+ }
+}
+
+resource "aws_s3_bucket" "data" {
+ bucket = var.data_bucket_name
+}
+
+resource "aws_s3_bucket_public_access_block" "data" {
+ bucket = aws_s3_bucket.data.id
+ block_public_acls = true
+ block_public_policy = true
+ ignore_public_acls = true
+ restrict_public_buckets = true
+}
+
+# The off-cloud host principal. No instance profile exists off EC2; this
+# user + access key is the C6 decision-6 shape (IAM Roles Anywhere is the
+# later hardening — the policy below is what either principal would carry).
+resource "aws_iam_user" "host" {
+ name = "carlos-remote-host-${var.region_code}"
+}
+
+resource "aws_iam_access_key" "host" {
+ user = aws_iam_user.host.name
+}
+
+# Scope: the deployment bucket + THIS region's data bucket and nothing
+# else — wrong-region-fails-at-IAM, same property as every cloud host.
+#
+# The action list is not independently chosen: region-host's
+# aws_iam_role_policy.host_buckets (infra/modules/region-host/main.tf) is
+# the authority for what this principal shape may do to S3, and this
+# policy copies it verbatim — object actions (GetObject/PutObject/
+# DeleteObject) scoped to the object ARNs, list/location actions
+# (ListBucket/GetBucketLocation) scoped to the bucket ARNs.
+resource "aws_iam_user_policy" "host" {
+ name = "carlos-remote-host-${var.region_code}"
+ user = aws_iam_user.host.name
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Sid = "DeploymentBucketObjects"
+ Effect = "Allow"
+ Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
+ Resource = ["${var.deployment_bucket_arn}/*"]
+ },
+ {
+ Sid = "DeploymentBucketList"
+ Effect = "Allow"
+ Action = ["s3:ListBucket", "s3:GetBucketLocation"]
+ Resource = [var.deployment_bucket_arn]
+ },
+ {
+ Sid = "OwnRegionDataObjects"
+ Effect = "Allow"
+ Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
+ Resource = ["${aws_s3_bucket.data.arn}/*"]
+ },
+ {
+ Sid = "OwnRegionDataList"
+ Effect = "Allow"
+ Action = ["s3:ListBucket", "s3:GetBucketLocation"]
+ Resource = [aws_s3_bucket.data.arn]
+ },
+ ]
+ })
+}
diff --git a/infra/modules/region-remote-host/outputs.tf b/infra/modules/region-remote-host/outputs.tf
new file mode 100644
index 000000000..4a277965e
--- /dev/null
+++ b/infra/modules/region-remote-host/outputs.tf
@@ -0,0 +1,6 @@
+output "bucket_name" { value = aws_s3_bucket.data.bucket }
+output "access_key_id" { value = aws_iam_access_key.host.id }
+output "secret_access_key" {
+ value = aws_iam_access_key.host.secret
+ sensitive = true
+}
diff --git a/infra/modules/region-remote-host/variables.tf b/infra/modules/region-remote-host/variables.tf
new file mode 100644
index 000000000..8b1240278
--- /dev/null
+++ b/infra/modules/region-remote-host/variables.tf
@@ -0,0 +1,14 @@
+variable "region_code" {
+ description = "Short region code this host serves (e.g. pi, hq)."
+ type = string
+}
+
+variable "data_bucket_name" {
+ description = "Name of this region's data bucket (created here)."
+ type = string
+}
+
+variable "deployment_bucket_arn" {
+ description = "ARN of the existing deployment bucket the host reads releases/status from and writes heartbeats to."
+ type = string
+}