# 🤖 The public status bucket (issue #550 Parts 11/12): world-readable # dashboards — fleet.json + books.json (published every 5 minutes by # deploy/status-publish.sh on the router box) and the two static viewer pages # (web/status/). Everything in it is AGGREGATE BY DESIGN: fleet counts and # version distribution with no hostnames, and Stripe income as totals only — # decision #19 (income publishes aggregated, never per-payer; expenses are the # deliberate mirror image and publish itemized per provider) and invariant 12 # (no PII in our stores) are enforced upstream in the generator; this bucket # only ever receives what passed that gate. docs/status.md is the guide. # # NOT YET APPLIED — needs a deliberate, named # `AWS_PROFILE=… tofu apply` in infra/prod; plan only until then. resource "aws_s3_bucket" "status" { bucket = "eleven-status-${local.account_id}" } resource "aws_s3_bucket_versioning" "status" { bucket = aws_s3_bucket.status.id versioning_configuration { status = "Enabled" } } # The JSONs are clobbered every 5 minutes (≈576 noncurrent versions/day), so # versions are pure rollback/forensic noise past a short window — 7 days is # plenty to reconstruct "what did the page say when". resource "aws_s3_bucket_lifecycle_configuration" "status" { bucket = aws_s3_bucket.status.id rule { id = "expire-noncurrent-7d" status = "Enabled" filter {} noncurrent_version_expiration { noncurrent_days = 7 } } } # Deliberately PUBLIC-READ, like the releases bucket (releases.tf) and against # the everything-else-blocked posture (backup.tf, hibernate.tf): a public # status page is the point — the content is aggregates that were reviewed for # publication before the generator ever wrote them (no hostnames, no per-payer # data, no PII exists upstream to leak). ACLs stay fully blocked; only the # bucket *policy* door is opened, and the GET-only policy below is the entire # public surface (no anonymous List — object names are known, enumeration # isn't offered). resource "aws_s3_bucket_public_access_block" "status" { bucket = aws_s3_bucket.status.id block_public_acls = true ignore_public_acls = true block_public_policy = false restrict_public_buckets = false } data "aws_iam_policy_document" "status_public_read" { statement { sid = "PublicReadStatusObjects" actions = ["s3:GetObject"] resources = ["${aws_s3_bucket.status.arn}/*"] principals { type = "*" identifiers = ["*"] } } # Public means anonymous, not unencrypted: deny any request over plain # HTTP. The viewer pages and the publisher both use the https endpoint # already; this closes the door on downgraded fetches. statement { sid = "DenyInsecureTransport" effect = "Deny" actions = ["s3:*"] resources = [ aws_s3_bucket.status.arn, "${aws_s3_bucket.status.arn}/*", ] principals { type = "*" identifiers = ["*"] } condition { test = "Bool" variable = "aws:SecureTransport" values = ["false"] } } } resource "aws_s3_bucket_policy" "status" { bucket = aws_s3_bucket.status.id policy = data.aws_iam_policy_document.status_public_read.json # The policy grants public access, so the access block must relax # block_public_policy before it can be attached. depends_on = [aws_s3_bucket_public_access_block.status] } # The publisher's credentials: object put/get + list on this bucket plus # read-only Cost Explorer, nothing else in the account — and no DeleteObject, # so a leaked publisher key can overwrite the current dashboards (noncurrent # versions survive; versioning is the rollback) but never destroy history. # Mint the access key by hand # (aws iam create-access-key --user-name status-publisher) — keys never land # in tofu state; they go in /data/status.env on the router box. resource "aws_iam_user" "status_publisher" { name = "status-publisher" } data "aws_iam_policy_document" "status_publisher" { statement { sid = "ListStatusBucket" actions = ["s3:ListBucket"] resources = [aws_s3_bucket.status.arn] } statement { sid = "WriteStatusObjects" actions = [ "s3:GetObject", "s3:PutObject", ] resources = ["${aws_s3_bucket.status.arn}/*"] } # Read-only access to the mesh's stats side-channel in the hibernation # bucket (hibernate.tf's prod_instances) — deploy/status-publish.sh's # mesh_peer_boxes reads every live mesh peer's self-published capacity # snapshot (band words + counts only — never raw ratios, meshstats.go) to # build fleet.json's multi-host hosts.boxes array. Scoped to the "*-stats/" # sibling prefix internal/activator/heartbeat.go's PutStats writes to, not # the whole bucket: the publisher must never be able to read or restore # actual (ciphertext) instance replicas, only the small stats objects next # to them. No s3:PutObject/DeleteObject here — this credential only reads. statement { sid = "ListMeshStatsPrefix" actions = ["s3:ListBucket"] resources = [aws_s3_bucket.prod_instances.arn] condition { test = "StringLike" variable = "s3:prefix" values = ["*-stats/*"] } } statement { sid = "ReadMeshStatsObjects" actions = ["s3:GetObject"] resources = ["${aws_s3_bucket.prod_instances.arn}/*-stats/*"] } # The books page's expenses section: the publisher asks Cost Explorer for # the account's month-to-date spend (deploy/status-publish.sh, cached — CE # bills per request). ce:GetCostAndUsage has no resource-level scoping, so # "*" is the narrowest possible grant; it exposes only read-only cost # metadata, which is exactly what we deliberately publish anyway (decision # #19: expenses itemize). statement { sid = "ReadCostAndUsage" actions = ["ce:GetCostAndUsage"] resources = ["*"] } } resource "aws_iam_user_policy" "status_publisher" { name = "status-publisher-bucket" user = aws_iam_user.status_publisher.name policy = data.aws_iam_policy_document.status_publisher.json } output "status_bucket" { value = aws_s3_bucket.status.bucket } # Where the dashboards live once published. output "status_url" { value = "https://${aws_s3_bucket.status.bucket_regional_domain_name}/index.html" } # ---- ledger streams (issue #1224) --------------------------------------------- # The append-only, hash-chained ledger streams under ledgers// — writes # stream live from contributor machines (tools/ledger-append.mjs / # tools/music-ledger.mjs --stream) instead of riding the release train; the # viewer pages read them through the same public GET door as everything else # (no anonymous List — pages fetch HEAD.json + derived views by known key, # and tools/ledger-verify.mjs walks the prev-hash chain, so enumeration stays # unoffered). Tamper-EVIDENT, not tamper-proof: the chain (plus a future # anchor of the head hash into the public releases repo) is the guarantee; # this credential simply can't delete, and versioning keeps a short forensic # window like the rest of the bucket. # Mint the access key by hand (aws iam create-access-key --user-name # status-ledger-writer) — keys never land in tofu state; store it in # Bitwarden per deploy/vault-items.sh and hand it only to contributor # machines that stream ledgers. resource "aws_iam_user" "ledger_writer" { name = "status-ledger-writer" } data "aws_iam_policy_document" "ledger_writer" { # PutObject only, and only under ledgers/ — this key can append entries, # blobs, and derived views, and nothing else: no reads beyond the public # door everyone has, no deletes, no touching the dashboards' own JSONs. statement { sid = "AppendLedgerObjects" actions = ["s3:PutObject"] resources = ["${aws_s3_bucket.status.arn}/ledgers/*"] } } resource "aws_iam_user_policy" "ledger_writer" { name = "status-ledger-writer-append" user = aws_iam_user.ledger_writer.name policy = data.aws_iam_policy_document.ledger_writer.json }