infra: the blob mirror bucket + scoped IAM user (federated blobs Phase A, #992) (#1029)
The AWS half of the flip: eleven-prod-blob-mirror bucket (TLS-only, fully access-blocked, versioning deliberately OFF — blobs are write-once and a delete is member GC; retaining a deleted member's media as noncurrent versions would betray the deletion) + blob-mirror-prod IAM user scoped to object rw on this bucket only (no List — the sweeps address by id from the blobs table). Access key minted by hand into the vault, never in state. The Hetzner Object Storage primary is a console action — neither provider manages it.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+86
−0
infra/prod/blob-mirror.tf+86 −0
diff --git a/infra/prod/blob-mirror.tf b/infra/prod/blob-mirror.tf| new file mode 100644 |
| index 0000000..eb96741 |
| --- /dev/null |
| +++ b/infra/prod/blob-mirror.tf |
| @@ -0,0 +1,86 @@ |
| +# 🤖 The blob MIRROR bucket (federated blobs Phase A, #992 — |
| +# docs/federated-blob-storage.md): the second copy of every store-backed |
| +# attachment, on the second provider. The PRIMARY blob store is Hetzner |
| +# Object Storage — enabled and credentialed in the Hetzner console, since |
| +# neither the hcloud provider nor this AWS one can manage it; this file is |
| +# the AWS half only. |
| +# |
| +# Versioning is deliberately OFF, unlike the hibernation bucket beside it: |
| +# blobs are write-once (an id is never rewritten, so versions can't help), |
| +# and a delete here is member-initiated GC — retaining a deleted member's |
| +# media as noncurrent versions would quietly betray the deletion. When a |
| +# blob goes, both copies actually go. |
| + |
| +resource "aws_s3_bucket" "prod_blob_mirror" { |
| + bucket = "eleven-prod-blob-mirror-${local.account_id}" |
| +} |
| + |
| +resource "aws_s3_bucket_public_access_block" "prod_blob_mirror" { |
| + bucket = aws_s3_bucket.prod_blob_mirror.id |
| + |
| + block_public_acls = true |
| + block_public_policy = true |
| + ignore_public_acls = true |
| + restrict_public_buckets = true |
| +} |
| + |
| +# TLS-only, same shape as the hibernation bucket: deny-only grants nothing |
| +# public, so it attaches fine under the full access block above. |
| +data "aws_iam_policy_document" "prod_blob_mirror_tls_only" { |
| + statement { |
| + sid = "DenyInsecureTransport" |
| + effect = "Deny" |
| + actions = ["s3:*"] |
| + resources = [ |
| + aws_s3_bucket.prod_blob_mirror.arn, |
| + "${aws_s3_bucket.prod_blob_mirror.arn}/*", |
| + ] |
| + |
| + principals { |
| + type = "*" |
| + identifiers = ["*"] |
| + } |
| + |
| + condition { |
| + test = "Bool" |
| + variable = "aws:SecureTransport" |
| + values = ["false"] |
| + } |
| + } |
| +} |
| + |
| +resource "aws_s3_bucket_policy" "prod_blob_mirror" { |
| + bucket = aws_s3_bucket.prod_blob_mirror.id |
| + policy = data.aws_iam_policy_document.prod_blob_mirror_tls_only.json |
| +} |
| + |
| +# The instances' mirror credentials (LCHAT_BLOB_S3_MIRROR_ACCESS_KEY / |
| +# _SECRET_KEY): object rw on this bucket only — no List, because the mirror |
| +# and audit sweeps address every object by id straight from the blobs table |
| +# (HeadObject rides the s3:GetObject grant). Key minted by hand |
| +# (`aws iam create-access-key`), sealed into the vault — never in tofu state. |
| +resource "aws_iam_user" "blob_mirror_prod" { |
| + name = "blob-mirror-prod" |
| +} |
| + |
| +data "aws_iam_policy_document" "blob_mirror_prod" { |
| + statement { |
| + sid = "ReadWriteBlobMirrorObjects" |
| + actions = [ |
| + "s3:GetObject", |
| + "s3:PutObject", |
| + "s3:DeleteObject", |
| + ] |
| + resources = ["${aws_s3_bucket.prod_blob_mirror.arn}/*"] |
| + } |
| +} |
| + |
| +resource "aws_iam_user_policy" "blob_mirror_prod" { |
| + name = "blob-mirror-prod-bucket" |
| + user = aws_iam_user.blob_mirror_prod.name |
| + policy = data.aws_iam_policy_document.blob_mirror_prod.json |
| +} |
| + |
| +output "prod_blob_mirror_bucket" { |
| + value = aws_s3_bucket.prod_blob_mirror.bucket |
| +} |