carlos / platform Public

tenant email spec: cross-account domain claims (section 4a)

Review demonstrated a cross-tenant sending path the design did not close.
SES identities are per-AWS-account, so a domain any tenant verifies is
verified globally — a second account can call EnsureDomain + VerifyDomain
on it, obtain the same identity ARN with no proof of control, and have
Enable build an IAM policy for *@that-domain.

Both of section 4's rails hold and the boundary still falls, because both
are computed from a domain set nothing checked for ownership.

The fix is the mechanism the platform already has for hostnames:
domains.AttachClaim's create-only, globally-keyed write, taken BEFORE the
identity is created. Claim first, then provision — the same ordering
domainattach.go records for the same reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 0d0230457647bb41895094ac34f1d3a7152ee527 parent 24e422f
1 file changed, +31 −0
  • docs/superpowers/specs/2026-08-19-tenant-email-design.md +31 −0
diff --git a/docs/superpowers/specs/2026-08-19-tenant-email-design.md b/docs/superpowers/specs/2026-08-19-tenant-email-design.md
index 2a3d05a25..cbcb8fe70 100644
--- a/docs/superpowers/specs/2026-08-19-tenant-email-design.md
+++ b/docs/superpowers/specs/2026-08-19-tenant-email-design.md
@@ -150,6 +150,37 @@ secret or the derived password. The password is derived at delivery time
from the IAM secret held by the provisioning door. A bucket leak therefore
exposes usernames, never the ability to send.
+## 4a. Cross-account domain claims — ADDED 2026-08-19 after review
+
+**§4 was incomplete.** It described what stops one account sending as
+another *once the domain set is correct*, and said nothing about how a
+domain enters that set. Review found the gap and demonstrated it: because
+SES identities are per-AWS-**account**, not per-tenant, a domain any tenant
+has verified is verified *globally*. A second account could therefore call
+`EnsureDomain` + `VerifyDomain` on `victim.com`, obtain the same identity
+ARN with **no proof of control of its own**, and have `Enable` build it an
+IAM policy granting `ses:SendRawEmail` with `ses:FromAddress: *@victim.com`.
+
+Both §4 rails hold perfectly and the tenant boundary still falls, because
+both rails are computed from a domain set that was never checked for
+ownership.
+
+**The fix is the mechanism this platform already has.** Custom hostnames
+are gated fleet-wide by `domains.AttachClaim` — a create-only bucket key
+whose write IS the uniqueness check, decided before anything else about the
+host exists. Email domains must pass the same gate: **a create-only claim,
+keyed globally by domain, written BEFORE the SES identity is created.** A
+second account's claim write fails, and it never reaches `EnsureIdentity`.
+
+Order matters and is the same lesson `domainattach.go` records: claim
+first, then provision. The reverse ordering lets two accounts each create
+an identity and discover the conflict from the fleet.
+
+A platform-suffixed domain (`<app>.<sqid>.oncarlos.com`) is not exempt.
+It is not *more* exposed — the names are account-scoped by construction —
+but the claim costs nothing there and a single code path is worth more than
+a special case.
+
## 5. The provisioner seam
One interface, mirroring `objstore.Provisioner` in both shape and contract: