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.
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:
- 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 ons3:DeleteBucket,s3:DeleteObjectands3:CreateBucketagainst the deployment bucket and the data bucket. Releasing an instance lease is aDeleteObjectoninstances/<host>/lock.json. On the flagship this made every hibernating instance answer 503 for the full 15-minute lease TTL. The fix (907c3c9e onworktree-lease-release-hardening) is not on main and no tag carries it. - The pin bump brings other changes. It also adds
host_ssm_parameter_scope[0](scope_ssm_parameter_readsdefaults to true; Tito'sdeny_foreign_parametersalready covers it) and VPC data sources. Nobody has checked those plan as a no-op on the security group and the instance. - It would add nothing. Tito's own
aws_iam_role_policy.host_ie_object_storealready allowsiam:CreateUserandiam:PutUserPolicyonuser/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, namedcarlos-tito-host-ie-store-boundary(the module's name). It mirrors the module document: - Allows3:ListBucketands3:GetBucketLocationonarn:aws:s3:::carlos-store-tito-*. - Allows3:GetObject,s3:PutObjectands3:DeleteObjectonarn: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-665106592142and/*, if that adopted bucket is served through a store user. - Update
aws_iam_role_policy.host_ie_tenant_mail: addses:DeleteTenantResourceAssociationtoMailTenants. 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_storeby splittingStoreUsersin two: -iam:CreateUserandiam:PutUserPolicyonuser/carlos-store/*, withStringEquals {"iam:PermissionsBoundary" = aws_iam_policy.store_user_boundary.arn}. - The rest (GetUser,DeleteUser,DeleteUserPolicy,CreateAccessKey,DeleteAccessKey,ListAccessKeys), unconditioned, as today. -StoreBucketsis 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:DeleteTenantResourceAssociationisallowed.- After phase 2,
iam:CreateUseronuser/carlos-store/xisallowedwithiam:PermissionsBoundaryset to the ARN andimplicitDenywithout it. - As a regression check against the flagship outage,
s3:DeleteObjecton<IE data bucket>/instances/x/lock.jsonis stillallowed.
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.devCARLOS_STORE_USER_BOUNDARY: main's console exits at boot without it, becauseCARLOS_STORE_BACKEND=awsis 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 thebapalias 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 legacyemail.jsonrecords and dropsmail_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
- Preconditions. - Read the live console env and the
EnvironmentFileofconsole.service. - Confirmcf02a4d5-titois still a console release incarlos-tito-platform-665106592142, or keep the binary to ship again. - Pick the console commit: the main commit carrying #176, or v0.22.2 (7edd4b33) withCARLOS_SYSTEM_OPERATORSadded in a later console roll. - Apply phase 1 and verify it.
- Edit the console env after backing it up, and re-run the escrow script.
- 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 consolehas noconsole:fatal line. - 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.
- 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, thencarlos system status. The apply restarts carlos-edge, which is how IE's agent picks up the newPLATFORM_EDGE_IPS. IE'sedges: alias ... not publishedlines should stop. - After a day of clean running, take the phase 2 inventory, then apply phase 2 and verify it.
Rollback
- Console: in direct-bucket mode (
AWS_PROFILE=titogo,CARLOS_DEPLOYMENT_BUCKET=carlos-tito-platform-665106592142,CARLOS_RELEASE_KEYfrom SSM/carlos/release-keyfor the one command), runcarlos promote --app console --hotfix cf02a4d5-tito canary/console. - 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.shdoesn't runreset-failed. - 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. - 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.
- Boxes:
carlos system rollback, orcarlos 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 ofCARLOS_STORE_OPERATORSandCONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS; - whetherCARLOS_REGIONis present; - whetherPLATFORM_EDGE_IPSis 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-665106592142is served as a store, and so whether the boundary must name it. - Whether the other five boxes carry a short
PLATFORM_EDGE_IPStoo. - Whether any Tito app relies on a store user reading object tags.
- Whether
cf02a4d5-titois 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.
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 hasPLATFORM_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, noCARLOS_REGION(not fatal on main: thecarlos dbdoor 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_IPSwill carry seven IPs. Tofu runs here are targeted only, so main's code cannot plan Canada away. carlos system status --console https://carlos.tito.ioworks: 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 ontitogo-uploads-665106592142(the adopted bucket). So the boundary does needtitogo-uploads-665106592142and/*, or a re-created store for bae would lose its bucket; adding it.s3:GetObjectTaggingis not in that user's policy; leaving it out. - Phase 2 consequence: once
PutUserPolicyis 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-arm64is still in the bucket.
Progress 2026-10-03:
- Phase 1 applied, targeted:
aws_iam_policy.store_user_boundaryonly (1 to add, 0 to change, 0 to destroy). ARNarn:aws:iam::665106592142:policy/carlos-tito-host-ie-store-boundary. Simulated oncarlos-tito-host-ie:ses:DeleteTenantResourceAssociationallowed,s3:DeleteObjectoncarlos-tito-ie-665106592142/instances/x/lock.jsonallowed. PR: https://github.com/teamtito/carlos-infrastructure/pull/18, baseregion-ca(#17), because the state holds the Canada box and main's code cannot plan against it. - Dropped the mail.tf change. The livehost_ie_tenant_mailalready grantsses:DeleteTenantResourceAssociation, plusDeleteTenant,ListTenantResources,GetReputationEntityandUpdateReputationEntityCustomerManagedStatus, none of which are inmail.tf. Applying it would have removed four grants. That drift is noted in the PR and wants its own fix. - host.env on carlos-tito-ie backed up to
/etc/carlos/host.env.bak-201-20261003, then:PLATFORM_EDGE_IPSnow lists all seven EIPs (adds 13.233.36.247, 3.92.98.101, 63.181.123.82, 52.60.185.94), plusCARLOS_STORE_USER_BOUNDARY,CONSOLE_RESERVE_PLATFORM_DOMAINS=trueandCARLOS_SYSTEM_OPERATORS=bill@keymail.dev. Escrow refreshed (/carlos/escrow/ie/host.env). - 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 ofbad/crispaandbag/website; both are gone from the bucket, so that is correct.roll-console.shneedsGOFLAGS=-buildvcs=falsewhen the checkout is a git worktree. - Domain reservation in effect: attaching
rsv201check.platform.tito.iotobah/jamwas refused with "that hostname is platform-managed". - Boxes:
carlos system update --channel stable --version v0.22.2promoted. 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, thencarlos 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 withiam:PutUserPermissionsBoundary. - PR #18 (stacked on #17) wants merging on Tito's side. The
mail.tfdrift wants its own PR. PLATFORM_EDGE_IPSis 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-titogois still the only one, with no boundary). - Addition 1,
s3:GetObjectTaggingin the boundary. Main'sstorePolicyJSONgrants 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 callsPutUserPolicyon every run, so without this, phase 2 would break every future walk of bae's store. The boundary already coverstitogo-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 ontitogo-uploads-665106592142are all still allowed. host_ie_object_storesplit:iam:CreateUserandiam:PutUserPolicyneediam: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 oncarlos-store-tito-*allowed. The leaseDeleteObjectoncarlos-tito-ie-665106592142allowed. - 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 statusagainst 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. - Thehost_ie_tenant_maildrift: four live SES actions not inmail.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).