carlos / platform Public

Move Tito's console from cf02a4d5-tito back onto main Issue closed

What and why

Tito's console (carlos.tito.io, on carlos-tito-ie, eu-west-1) runs cf02a4d5-tito: platform 16593de5 (2026-09-11) plus the short-alias commits (on main as e36cdb6a and 86d10e28) and one CI-only commit. Main is 314 commits ahead. It is pinned because main's console refuses to boot with CARLOS_STORE_BACKEND set and no CARLOS_STORE_USER_BOUNDARY, and Tito's account has no boundary policy. This thread creates the boundary, fixes the console env, and rolls the console to main. The env change also carries CONSOLE_RESERVE_PLATFORM_DOMAINS=true (#156; Tito hosts other people's apps), the full edge list (#179), and CARLOS_SYSTEM_OPERATORS for Bill (#176, building on branch system-operators-176).

The production change (needs Paul's scoped approval)

Account and region: titogo (665106592142), eu-west-1. IAM only. The only principal that changes is the role carlos-tito-host-ie.

Repo: teamtito/carlos-infrastructure, as a PR on GitHub, its primary. The region-host module pin 7f7f941797fa77830893c4615a5bbaaeb01c0e8a stays where it is.

Why not the module's store_bucket_prefix input

That input would create module.host_ie.aws_iam_policy.store_user_boundary[0] and module.host_ie.aws_iam_role_policy.host_store[0]. Tito's pin predates it; v0.19.0 is the first tag with it. It is not safe today, for three reasons:

  1. The lease outage would come back. Every tag from v0.19.0 to v0.22.2, and main (infra/modules/region-host/main.tf, host_store), ships an explicit Deny on s3:DeleteBucket, s3:DeleteObject and s3:CreateBucket against the deployment bucket and the data bucket. Releasing an instance lease is a DeleteObject on instances/<host>/lock.json. On the flagship this made every hibernating instance answer 503 for the full 15-minute lease TTL. The fix (907c3c9e on worktree-lease-release-hardening) is not on main and no tag carries it.
  2. The pin bump brings other changes. It also adds host_ssm_parameter_scope[0] (scope_ssm_parameter_reads defaults to true; Tito's deny_foreign_parameters already covers it) and VPC data sources. Nobody has checked those plan as a no-op on the security group and the instance.
  3. It would add nothing. Tito's own aws_iam_role_policy.host_ie_object_store already allows iam:CreateUser and iam:PutUserPolicy on user/carlos-store/* with no condition. IAM combines Allow statements across policies, so the module's boundary-conditioned Allow changes nothing while the unconditioned one stands.

So the boundary and the condition go into Tito's own object-store.tf. Once a fixed tag exists, a later change can move them into the module.

Phase 1 (additions only; safe for the console running today)

  • Create aws_iam_policy.store_user_boundary, named carlos-tito-host-ie-store-boundary (the module's name). It mirrors the module document: - Allow s3:ListBucket and s3:GetBucketLocation on arn:aws:s3:::carlos-store-tito-*. - Allow s3:GetObject, s3:PutObject and s3:DeleteObject on arn:aws:s3:::carlos-store-tito-*/*. - Two possible additions, Paul to decide: - s3:GetObjectTagging. The store user's inline policy grants it and the module's boundary drops it. - titogo-uploads-665106592142 and /*, if that adopted bucket is served through a store user.
  • Update aws_iam_role_policy.host_ie_tenant_mail: add ses:DeleteTenantResourceAssociation to MailTenants. Main's console calls it before deleting a sending identity (internal/mail/aws.go). Carloku's tofu lacks it too.
  • Add output "store_user_boundary_arn". No resource.

Phase 2 (after the new console has run cleanly for a day)

  • Update aws_iam_role_policy.host_ie_object_store by splitting StoreUsers in two: - iam:CreateUser and iam:PutUserPolicy on user/carlos-store/*, with StringEquals {"iam:PermissionsBoundary" = aws_iam_policy.store_user_boundary.arn}. - The rest (GetUser, DeleteUser, DeleteUserPolicy, CreateAccessKey, DeleteAccessKey, ListAccessKeys), unconditioned, as today. - StoreBuckets is unchanged. In particular, no Deny on the deployment or data buckets.

Purpose. Phase 1 lets main's console boot. Phase 2 closes the escalation the boundary exists for: without it, a compromised console role can create a /carlos-store/ user with any inline policy it likes.

Permissions effect. In phase 1 the role gains one SES action. In phase 2 it loses the ability to create or re-policy a store user without the boundary. Store users that existed before this change carry no boundary, so phase 2 refuses PutUserPolicy on them and a re-grant on such a user fails. An inventory is therefore a precondition of phase 2. Fixing existing users means an admin attaching the boundary with iam:PutUserPermissionsBoundary, and that is part of this approval only if Paul says so.

Traffic effect. None on app traffic.

Cost. $0.

Duration. Each apply takes minutes and is permanent. The phases are about a day apart.

Apply. Targeted:

  • Phase 1: -target=aws_iam_policy.store_user_boundary -target=aws_iam_role_policy.host_ie_tenant_mail
  • Phase 2: -target=aws_iam_role_policy.host_ie_object_store

Read the plan first and stop on anything else, especially under module.host_ie.aws_instance.host. Don't combine with PR #17 (region-ca).

Verification. aws iam get-policy on the boundary ARN, then aws iam simulate-principal-policy against carlos-tito-host-ie:

  • ses:DeleteTenantResourceAssociation is allowed.
  • After phase 2, iam:CreateUser on user/carlos-store/x is allowed with iam:PermissionsBoundary set to the ARN and implicitDeny without it.
  • As a regression check against the flagship outage, s3:DeleteObject on <IE data bucket>/instances/x/lock.json is still allowed.

Cleanup and rollback. Revert phase 2 and re-apply; that restores the unconditioned grant. Phase 1 needs no rollback, because the old console ignores it. A boundary policy can't be deleted while any user carries it, so leave it in place.

Console env changes

In the file console.service reads on carlos-tito-ie. Back it up first, then re-run scripts/escrow-host-env.sh titogo eu-west-1 i-025a0b70f3d93304d ie so the SSM escrow matches.

CARLOS_STORE_USER_BOUNDARY=<store_user_boundary_arn from tofu output; expected arn:aws:iam::665106592142:policy/carlos-tito-host-ie-store-boundary>
CONSOLE_RESERVE_PLATFORM_DOMAINS=true
PLATFORM_EDGE_IPS=54.228.234.85,35.177.77.51,16.16.113.244,13.233.36.247,3.92.98.101,63.181.123.82
CARLOS_SYSTEM_OPERATORS=bill@keymail.dev
  • CARLOS_STORE_USER_BOUNDARY: main's console exits at boot without it, because CARLOS_STORE_BACKEND=aws is set.
  • CONSOLE_RESERVE_PLATFORM_DOMAINS: on main, reserving the apps domain and the console's hostnames is opt-in. Without it, any Tito account can attach names under Tito's apps domain, and the bap alias delegation loses its limits. A value that isn't a boolean stops the boot.
  • PLATFORM_EDGE_IPS: 3 of the 6 edges today, which makes the custom-domain check flap (#179). The console reads it at start. carlos-edge reads it too, and picks it up when the box roll below restarts the edge. If PR #17 adds Canada, its IP joins the list.
  • CARLOS_SYSTEM_OPERATORS: set it only on a build that carries #176.

Keep these as they are: CARLOS_STORE_BACKEND, CARLOS_STORE_REGION, CARLOS_STORE_BUCKET_PREFIX=carlos-store-tito-, CARLOS_STORE_OPERATORS, CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS (bap) and CARLOS_REGION (main's console now reads it for the carlos db door).

Leave these unset:

  • CARLOS_MAIL_LEGACY_SWEEP. It deletes the legacy email.json records and drops mail_counts.
  • CARLOS_STORE_MAX_PER_APP. Unset means the default; if set, it must be a positive whole number or the boot fails.

The old console doesn't read the new keys, so writing them before the roll is harmless.

Roll order

  1. Preconditions. - Read the live console env and the EnvironmentFile of console.service. - Confirm cf02a4d5-tito is still a console release in carlos-tito-platform-665106592142, or keep the binary to ship again. - Pick the console commit: the main commit carrying #176, or v0.22.2 (7edd4b33) with CARLOS_SYSTEM_OPERATORS added in a later console roll.
  2. Apply phase 1 and verify it.
  3. Edit the console env after backing it up, and re-run the escrow script.
  4. Roll the console: platform-infrastructure/scripts/roll-console.sh <clean checkout at the chosen commit> tito. Then verify: - curl -sI https://carlos.tito.io/ | grep -i x-carlos-version; - carlos system status --console https://carlos.tito.io; - journalctl -u console has no console: fatal line.
  5. Check domain reservation. From an account not on the alias list, try to attach an unused name under the apps domain. It should be refused as platform-managed. If it is accepted, remove the claim and stop.
  6. Roll the boxes close behind the console (v0.22.0's operator note): carlos system update --console https://carlos.tito.io --version v0.22.2, then carlos system status. The apply restarts carlos-edge, which is how IE's agent picks up the new PLATFORM_EDGE_IPS. IE's edges: alias ... not published lines should stop.
  7. After a day of clean running, take the phase 2 inventory, then apply phase 2 and verify it.

Rollback

  1. Console: in direct-bucket mode (AWS_PROFILE=titogo, CARLOS_DEPLOYMENT_BUCKET=carlos-tito-platform-665106592142, CARLOS_RELEASE_KEY from SSM /carlos/release-key for the one command), run carlos promote --app console --hotfix cf02a4d5-tito canary/console.
  2. Recover the unit: over SSM, run systemctl reset-failed console && systemctl restart console. On 2026-09-11 the promote alone didn't recover a crash-looping console. roll-console.sh doesn't run reset-failed.
  3. What a console rollback loses: email pause and rotate state made under main's console (sender records moved to senders/app.json), and mail counts recorded in between. New registry tables and columns stay behind, unused.
  4. Never roll the console back after phase 2. The old console creates store users without a boundary, and phase 2 refuses that. Revert phase 2 first.
  5. Boxes: carlos system rollback, or carlos system update --version v0.21.0.

Open questions (couldn't answer read-only)

  • What the console env file on carlos-tito-ie holds today: - whether it is /etc/carlos/host.env; - the values of CARLOS_STORE_OPERATORS and CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS; - whether CARLOS_REGION is present; - whether PLATFORM_EDGE_IPS is still the three-address list.
  • Whether any /carlos-store/ IAM users exist in titogo, and whether any carries a boundary. Phase 2 waits on this.
  • Whether titogo-uploads-665106592142 is served as a store, and so whether the boundary must name it.
  • Whether the other five boxes carry a short PLATFORM_EDGE_IPS too.
  • Whether any Tito app relies on a store user reading object tags.
  • Whether cf02a4d5-tito is still in the bucket.
  • Whether the boxes are still on v0.21.0.
Agent notes

Sources: git diff 16593de5 origin/main -- cmd/console/main.go for new console env; region-host main.tf host_store Deny on main and v0.19.0-v0.22.2; teamtito/carlos-infrastructure@4898210 object-store.tf (unconditioned StoreUsers, checked), mail.tf, region-ie.tf pin 7f7f9417; internal/mail/aws.go for DeleteTenantResourceAssociation; platform-infrastructure scripts/roll-console.sh. Not verified: a tofu plan of the pin bump; every helper the console calls for env reads; live host.env; the bucket still holding cf02a4d5-tito.

Paul approved this plan as written on 2026-10-02 ("go ahead" in session 0b3e87ad). Scope: phase 1 IAM in titogo eu-west-1 (boundary policy, ses:DeleteTenantResourceAssociation, output), the four console env lines, the console roll to main a7a2aaa, the box roll, and phase 2 after a day of clean running. Not in scope: attaching boundaries to existing store users by hand (PutUserPermissionsBoundary). The two optional boundary additions (s3:GetObjectTagging, titogo-uploads bucket) stay out unless the preconditions show they are needed. Starting the read-only preconditions now; each step will be reported here.

Preconditions (read-only, 2026-10-02):

  • Console unit: EnvironmentFile=/etc/carlos/host.env (shared with the agent) plus -/etc/carlos/release.env; running /opt/carlos/versions/cf02a4d5-tito/console. host.env has PLATFORM_EDGE_IPS=54.228.234.85,35.177.77.51,16.16.113.244 (3 edges), CARLOS_STORE_OPERATORS=paul@keymail.dev, CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS=bap, PLATFORM_APPS_DOMAIN=platform.tito.io, no CARLOS_REGION (not fatal on main: the carlos db door is just off), no boundary, no reserve flag.
  • Seven boxes, not six: carlos-tito-ca (i-04ae9239a563c6eb5, ca-central-1, EIP 52.60.185.94) is running although PR #17 (region-ca) is unmerged, so it was applied from the branch. PLATFORM_EDGE_IPS will carry seven IPs. Tofu runs here are targeted only, so main's code cannot plan Canada away.
  • carlos system status --console https://carlos.tito.io works: all seven boxes on v0.21.0, stable, no system pointer on either channel yet.
  • Store users: exactly one, carlos-store-bae-titogo, no boundary, inline policy on titogo-uploads-665106592142 (the adopted bucket). So the boundary does need titogo-uploads-665106592142 and /*, or a re-created store for bae would lose its bucket; adding it. s3:GetObjectTagging is not in that user's policy; leaving it out.
  • Phase 2 consequence: once PutUserPolicy is boundary-conditioned, re-granting bae's existing user fails until an admin attaches the boundary to it. That stays out of this approval; phase 2 waits for Paul's call on it.
  • Rollback build: apps/bab/console/releases/cf02a4d5-tito/linux-arm64 is still in the bucket.

Progress 2026-10-03:

  1. Phase 1 applied, targeted: aws_iam_policy.store_user_boundary only (1 to add, 0 to change, 0 to destroy). ARN arn:aws:iam::665106592142:policy/carlos-tito-host-ie-store-boundary. Simulated on carlos-tito-host-ie: ses:DeleteTenantResourceAssociation allowed, s3:DeleteObject on carlos-tito-ie-665106592142/instances/x/lock.json allowed. PR: https://github.com/teamtito/carlos-infrastructure/pull/18, base region-ca (#17), because the state holds the Canada box and main's code cannot plan against it. - Dropped the mail.tf change. The live host_ie_tenant_mail already grants ses:DeleteTenantResourceAssociation, plus DeleteTenant, ListTenantResources, GetReputationEntity and UpdateReputationEntityCustomerManagedStatus, none of which are in mail.tf. Applying it would have removed four grants. That drift is noted in the PR and wants its own fix.
  2. host.env on carlos-tito-ie backed up to /etc/carlos/host.env.bak-201-20261003, then: PLATFORM_EDGE_IPS now lists all seven EIPs (adds 13.233.36.247, 3.92.98.101, 63.181.123.82, 52.60.185.94), plus CARLOS_STORE_USER_BOUNDARY, CONSOLE_RESERVE_PLATFORM_DOMAINS=true and CARLOS_SYSTEM_OPERATORS=bill@keymail.dev. Escrow refreshed (/carlos/escrow/ie/host.env).
  3. Console rolled to fe2088e3 (main; carries #176 and the #199 stopgap; CI passed) with roll-console.sh. x-carlos-version: fe2088e3, unit active, NRestarts=0, no fatal lines. On start it released the registry names of bad/crispa and bag/website; both are gone from the bucket, so that is correct. roll-console.sh needs GOFLAGS=-buildvcs=false when the checkout is a git worktree.
  4. Domain reservation in effect: attaching rsv201check.platform.tito.io to bah/jam was refused with "that hostname is platform-managed".
  5. Boxes: carlos system update --channel stable --version v0.22.2 promoted. All seven are rolling over a 5-minute spread; watching now.

Still to do: the box roll result, IE agent's alias lines (#179), Bill's own carlos system status, and phase 2 after a day.

Box roll done 2026-10-03 00:12 UTC: all seven on v0.22.2, current, stable pointer release:v0.22.2, about 6.5 minutes end to end. Every EIP answers jam.bah.platform.tito.io the same way (303) through --resolve. IE's carlos-edge restarted at 23:07:38 UTC with the seven-IP PLATFORM_EDGE_IPS in its environment. Since then its only "not published" lines are three aliases that really don't point at Tito: box-office.lgc.li and tickets.worldfootballsummit.com (no DNS) and quovixi.com (resolves to 63.176.8.218 and 35.157.26.135).

Remaining:

  • Bill: carlos login --console https://carlos.tito.io, then carlos system status. That is the end-to-end check for #176, and it can only be run as Bill.
  • Phase 2: no earlier than 2026-10-04 ~00:00 UTC, after a day of clean running. It still needs Paul's call on carlos-store-bae-titogo. Once phase 2 lands, re-granting that user is refused until the boundary is attached to it with iam:PutUserPermissionsBoundary.
  • PR #18 (stacked on #17) wants merging on Tito's side. The mail.tf drift wants its own PR.
  • PLATFORM_EDGE_IPS is hand-set: nothing in Tito's tofu writes it. The next armed region has to be added by hand again until #179 item 2 lands.

Phase 2, approved by Paul 2026-10-07 ("Go ahead"), with two additions to the original plan:

  • Precondition met: console on fe2088e3 since 2026-10-02 23:04 UTC, NRestarts=0, no AccessDenied lines, no new store users (carlos-store-bae-titogo is still the only one, with no boundary).
  • Addition 1, s3:GetObjectTagging in the boundary. Main's storePolicyJSON grants it to every store user and the phase 1 boundary didn't, so a store created since the roll would have silently lost tag reads. None has been created. The module's boundary has the same gap, which affects the flagship; that goes in a separate thread.
  • Addition 2, attach the boundary to carlos-store-bae-titogo (iam:PutUserPermissionsBoundary). The store walk calls PutUserPolicy on every run, so without this, phase 2 would break every future walk of bae's store. The boundary already covers titogo-uploads, so bae's effective access doesn't change.

Order: widen the boundary, attach it to bae, condition CreateUser and PutUserPolicy, then verify by simulation. IAM only, titogo, no traffic effect, $0.

Phase 2 done 2026-10-07. This thread's scope is complete.

  • Boundary widened with s3:GetObjectTagging: targeted apply, 0/1/0.
  • Boundary attached to carlos-store-bae-titogo. Simulated afterwards: ListBucket, GetObject, PutObject and DeleteObject on titogo-uploads-665106592142 are all still allowed.
  • host_ie_object_store split: iam:CreateUser and iam:PutUserPolicy need iam:PermissionsBoundary = arn:aws:iam::665106592142:policy/carlos-tito-host-ie-store-boundary. Targeted apply, 0/1/0.
  • Simulated on carlos-tito-host-ie: both calls allowed with the boundary, implicitDeny without. CreateAccessKey, DeleteAccessKey, ListAccessKeys and GetUser allowed. CreateBucket and ListBucket on carlos-store-tito-* allowed. The lease DeleteObject on carlos-tito-ie-665106592142 allowed.
  • Not exercised live: an actual store walk for bae's store. Its next grant re-walk rewrites bae's inline policy to main's storePolicyJSON (adds GetObjectTagging and the anti-forgery Denys); that is now permitted.
  • PR: teamtito/carlos-infrastructure#18 carries both phases and matches live state. It is still stacked on #17 (region-ca), and both need merging Tito-side.
  • Still open elsewhere: - #176: Bill's own carlos system status against carlos.tito.io. - #179: item 2, deriving the edge set from the registry. - #255: the region-host module boundary has the same GetObjectTagging gap, which affects the flagship. - The host_ie_tenant_mail drift: four live SES actions not in mail.tf.

Tito-side follow-ups are tracked in Tito's Linear as PRO-5279: https://linear.app/tito/issue/PRO-5279 (merge #17 then #18, the mail.tf drift, Bill's check, the runbook, PLATFORMEDGEIPS upkeep, expired canaries).