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>
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: |