eleven / messenger Public

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>
Paul Campbell 222cdcde2553407e3a96c5ca0f37b4ecf9319590 parent 30619d4
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
+}