// The cross-account domain claim (Task 17a, spec §4a) — added after review // found a CRITICAL cross-tenant sending path in Task 17. // // SES identities are per-AWS-ACCOUNT, not per-tenant: a domain any CARLOS // account has verified is verified GLOBALLY at SES. Before this file // existed, EnsureDomain computed §4's IAM policy from a domain set that was // never checked for ownership — a second account could call EnsureDomain on // a domain the first account already controlled, obtain the SAME identity // ARN with no proof of its own, and Enable would build it a policy granting // ses:SendRawEmail with ses:FromAddress at that domain. Both of §4's rails // (verified-only ARNs, From-address-must-match-a-granted-domain) held // perfectly and the tenant boundary still fell, because both are computed // downstream of a domain set nothing ever gated on ownership. // // The fix mirrors internal/domains/claims.go's discipline rather than // inventing a second pattern: a create-only bucket key whose WRITE is the // uniqueness check. domains.AttachClaim gates hostnames the same way, at // the SAME key regardless of which account or app is asking, so two // accounts racing for one host cannot both win — there is only ever one // object to Put. This claim key is deliberately GLOBAL for the identical // reason: keying it under an account prefix (accounts//email/..., // the shape DomainKey already uses for the per-account identity record) // would let every account hold its OWN claim on the same domain, and the // whole point of this file is that they cannot. package mail import ( "context" "encoding/json" "errors" "fmt" "log" "os" "strings" "time" "github.com/carlosframework/platform/internal/release" ) // domainClaimsPrefix is where every email domain claim lives — a namespace // distinct from DomainsPrefix (which is per-account) precisely because this // one is not. const domainClaimsPrefix = "email/domain-claims/" func domainClaimKey(domain string) string { return domainClaimsPrefix + domain + ".json" } // DomainClaimKey is domainClaimKey for the console's backend boundary, which // binds an SES identity or DNS name to the account holding this claim. The // caller validates domain (ValidDomain) before building a key from it. func DomainClaimKey(domain string) string { return domainClaimKey(domain) } // DomainClaim is the wire shape at domainClaimKey(Domain) — the global // record of which account claimed a sending domain first. Exported (unlike // the rest of this file) so ListDomainClaims can hand it to a caller // outside this package — I2's operator-visibility fix: until an // operator-facing CLI/console verb is built on top of this (filed, not // built here), this listing is the ONLY way to see or diagnose a claim // that isn't the caller's own, short of a direct bucket read — which // CLAUDE.md's operate-via-API rule treats as a product gap to close, not a // workaround to reach for. type DomainClaim struct { Schema int `json:"schema"` // 1 Domain string `json:"domain"` Account string `json:"account"` // App is the app that FIRST claimed the domain — attribution for the // orphan-claim sweep in Teardown (I1 round 2), and nothing else. It is // omitempty for wire-additivity: a claim written before this field // existed has none, and a legacy orphan with an empty App simply never // matches any real Teardown call (ValidApp requires a non-empty app), // which is the safe direction — unsweepable rather than swept by the // wrong caller. App string `json:"app,omitempty"` CreatedAt time.Time `json:"created_at"` // IdentityRegions are the regions where this claim's own walk CREATED // the SES identity, stamped right after the create (#216). It is the // only record of that for a claim whose domain record was never // written, so it is what lets a re-run keep the identity and lets the // orphan sweeps delete it — and what stops either touching an identity // somebody else made. Omitted on a claim written before it existed; // such a claim's identity is then treated as not CARLOS's to delete. IdentityRegions []string `json:"identity_regions,omitempty"` // VerifiedAt is when VerifyDomain first saw SES verify the domain for // this claim's account. A claim with it set never lapses (ClaimLapse). VerifiedAt *time.Time `json:"verified_at,omitempty"` } // createdIdentityIn reports whether this claim's walk created the domain's // identity in region. func (c *DomainClaim) createdIdentityIn(region string) bool { for _, r := range c.IdentityRegions { if r == region { return true } } return false } // CreatedIdentityIn is createdIdentityIn for the console's backend boundary: // a DeleteIdentity is provable only for an identity this claim's walk // created, the same rule every delete in this package already applies. func (c *DomainClaim) CreatedIdentityIn(region string) bool { return c.createdIdentityIn(region) } // ErrDomainClaimed is returned by EnsureDomain when the domain's global // claim is held by a DIFFERENT account. Deliberately neutral — it never // names the holder, so a caller cannot use this door as a cross-account // oracle for who owns a domain they do not. The caller's OWN account // holding the claim is never this error; see claimDomain. var ErrDomainClaimed = errors.New("mail: domain is claimed") // ErrIdentityNotOwned is EnsureDomain's refusal of a domain whose SES // identity this account's claim did not create — one verified outside // CARLOS, or left by a claim that predates the record of who created what — // when the account has not proved it controls the domain (#216). Adopting // it would hand the account send rights it never proved, rewrite the // identity's MAIL FROM and configuration set under its real owner, and let // a later teardown delete it. var ErrIdentityNotOwned = errors.New("mail: domain has an SES identity this account did not create") // ClaimLapse is how long a claim may hold a domain without ever being // verified (#216). Before it, a claim was first-come and permanent, so any // account could hold any domain it would never prove, against every other // account, for the life of the deployment. SES itself gives DKIM 72 hours // before it marks an identity failed; a week leaves room for a slow DNS // change and a weekend. A claim that reaches verified never lapses. const ClaimLapse = 7 * 24 * time.Hour // getDomainClaim reads domain's claim. A not-yet-claimed domain returns // (nil, nil), matching GetDomain's convention rather than propagating // os.ErrNotExist to every caller. func getDomainClaim(ctx context.Context, s release.Store, domain string) (*DomainClaim, error) { c, _, err := getDomainClaimETag(ctx, s, domain) if errors.Is(err, os.ErrNotExist) { return nil, nil } return c, err } func getDomainClaimETag(ctx context.Context, s release.Store, domain string) (*DomainClaim, string, error) { raw, etag, err := s.Get(ctx, domainClaimKey(domain)) if err != nil { return nil, "", err } var c DomainClaim if err := json.Unmarshal(raw, &c); err != nil { return nil, "", fmt.Errorf("mail: domain claim %s: %w", domain, err) } // A schema newer than this binary understands is refused rather than // silently truncated by json.Unmarshal — domains.getClaimETag's // precedent for the same reason. if c.Schema > 1 { return nil, "", fmt.Errorf("mail: domain claim %s: schema %d is newer than this binary understands — upgrade carlos", domain, c.Schema) } return &c, etag, nil } // claimDomain is the gate: it must run, and succeed, before EnsureIdentity // ever does. The bucket key is the cross-account uniqueness check, exactly // as domains.AttachClaim's key is for hostnames — two accounts racing to // claim one domain cannot both win, because there is only ever one object // at domainClaimKey(domain) to Put. // // Same account re-claiming is idempotent success (a re-run of EnsureDomain // by its own owner must never be refused by the gate it itself holds), and // deliberately does NOT rewrite App on that no-op path — App records only // who FIRST claimed the domain, for the orphan sweep's attribution (I1 // round 2), not who most recently retried it. Any other account gets // ErrDomainClaimed, with no identity ever minted and no domain record ever // written — the refusal happens before either exists. // // First-come is no longer forever: EnsureDomain hands this refusal to // takeLapsedClaim, which lets a claim that was never verified go after // ClaimLapse (#216). func claimDomain(ctx context.Context, s release.Store, domain, acct, app string, now time.Time) error { body, err := json.Marshal(DomainClaim{Schema: 1, Domain: domain, Account: acct, App: app, CreatedAt: now}) if err != nil { return err } for attempt := 0; ; attempt++ { cur, _, err := getDomainClaimETag(ctx, s, domain) if errors.Is(err, os.ErrNotExist) { if _, err := s.Put(ctx, domainClaimKey(domain), body, ""); err == nil { return nil } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { continue // lost the create race — re-read and judge the winner } else { return err } } else if err != nil { return err } if cur.Account != acct { return ErrDomainClaimed } return nil // already ours: idempotent no-op } } // updateOwnClaim applies change to domain's claim when acct holds it, under // CAS. change reports whether it changed anything; a claim held by anyone // else, or none at all, is left alone and is not an error. func updateOwnClaim(ctx context.Context, s release.Store, domain, acct string, change func(*DomainClaim) bool) error { for attempt := 0; ; attempt++ { cur, etag, err := getDomainClaimETag(ctx, s, domain) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } if cur.Account != acct || !change(cur) { return nil } body, err := json.Marshal(cur) if err != nil { return err } if _, err := s.Put(ctx, domainClaimKey(domain), body, etag); err == nil { return nil } else if !errors.Is(err, release.ErrCASConflict) || attempt >= 2 { return err } } } // stampClaimIdentity records on acct's claim that its walk created the // identity in region. func stampClaimIdentity(ctx context.Context, s release.Store, domain, acct, region string) error { return updateOwnClaim(ctx, s, domain, acct, func(c *DomainClaim) bool { if c.createdIdentityIn(region) { return false } c.IdentityRegions = append(c.IdentityRegions, region) return true }) } // stampClaimVerified records the first time acct's claim was verified. func stampClaimVerified(ctx context.Context, s release.Store, domain, acct string, now time.Time) error { return updateOwnClaim(ctx, s, domain, acct, func(c *DomainClaim) bool { if c.VerifiedAt != nil { return false } at := now.UTC() c.VerifiedAt = &at return true }) } // claimLapsed reports whether c, held by another account, has lapsed: older // than ClaimLapse and never verified by any of three witnesses — the claim's // own stamp, the holder's domain record, and SES itself. SES is asked // because a holder may have published its DNS and never run verify; such a // domain is in use, and handing it on would also hand on a verified // identity. func claimLapsed(ctx context.Context, s release.Store, p Provisioner, c *DomainClaim, region string, now time.Time) (bool, error) { if c.VerifiedAt != nil || now.Sub(c.CreatedAt) < ClaimLapse { return false, nil } rec, _, err := GetDomain(ctx, s, c.Account, c.Domain) if err != nil { return false, err } if rec != nil { for _, st := range rec.Regions { if st != nil && st.Status == StatusVerified { return false, nil } } } got, err := p.IdentityStatus(ctx, region, c.Domain) if err != nil { return false, err } if got.DKIM == "SUCCESS" || got.Sending == "SUCCESS" { return false, nil } return true, nil } // takeLapsedClaim is EnsureDomain's answer to ErrDomainClaimed: when the // other account's claim has lapsed, acct takes it over; otherwise the // refusal stands. // // The claim moves FIRST, under CAS against the etag the lapse was judged // on. A holder that verifies in the meantime stamps verified_at, which moves // the etag, so the CAS fails and the holder keeps everything. Once the claim // is acct's, the holder's VerifyDomain and teardown both refuse to act on // the domain, so its record can no longer verify into a grant. // // Only then does the lapsed holder's own half-made identity go — and only // when its claim says its walk created it, and SES, asked again right // before the delete, still has not verified it. Left in place, the new // holder would meet an identity it did not create and have to prove DNS // control for a domain whose only obstacle was an abandoned attempt. An // identity the lapsed claim did not create, or one SES has since verified, // is not touched: the new holder meets it as a foreign identity and needs // proof. A crash between the CAS and the delete leaves the same state. func takeLapsedClaim(ctx context.Context, s release.Store, p Provisioner, region, domain, acct, app string, now time.Time) error { cur, etag, err := getDomainClaimETag(ctx, s, domain) if errors.Is(err, os.ErrNotExist) { return claimDomain(ctx, s, domain, acct, app, now) // released meanwhile } if err != nil { return err } if cur.Account == acct { return nil } lapsed, err := claimLapsed(ctx, s, p, cur, region, now) if err != nil { return err } if !lapsed { return ErrDomainClaimed } body, err := json.Marshal(DomainClaim{Schema: 1, Domain: domain, Account: acct, App: app, CreatedAt: now}) if err != nil { return err } if _, err := s.Put(ctx, domainClaimKey(domain), body, etag); err != nil { if errors.Is(err, release.ErrCASConflict) { return ErrDomainClaimed // the holder or another claimant moved first } return err } log.Printf("mail: claim on %s lapsed unverified (held since %s); taken by another account", domain, cur.CreatedAt.Format(time.RFC3339)) if !cur.createdIdentityIn(region) { return nil } if st, err := p.IdentityStatus(ctx, region, domain); err != nil { log.Printf("mail: lapsed claim %s: identity status: %v; leaving the old identity", domain, err) return nil } else if st.DKIM == "SUCCESS" || st.Sending == "SUCCESS" { return nil } if old, _, err := GetDomain(ctx, s, cur.Account, domain); err == nil && old != nil && old.Regions[region] != nil && old.Regions[region].IdentityARN != "" { // SES refuses to delete an associated identity. if err := p.DisassociateTenantResource(ctx, TenantName(cur.Account), old.Regions[region].IdentityARN); err != nil { log.Printf("mail: lapsed claim %s: disassociating: %v", domain, err) } } // A failed delete is logged, not returned: the claim is already acct's, // and the walk goes on to meet the identity as a foreign one. if err := p.DeleteIdentity(ctx, region, domain); err != nil { log.Printf("mail: lapsed claim %s: delete old identity: %v", domain, err) } return nil } // TXTResolver is the one DNS question the walk asks: the proof record for a // domain whose SES identity this account did not create. net.Resolver // satisfies it; tests inject a fake. type TXTResolver interface { LookupTXT(ctx context.Context, name string) ([]string, error) } // ProofRecordName and ProofRecordValue are the TXT record a claimant // publishes to prove it controls a domain that already has an SES identity // (#216). The value names the account, so one account's proof is no use to // another; only somebody who can write the domain's DNS can publish it. func ProofRecordName(domain string) string { return "_carlos-mail." + domain } func ProofRecordValue(acct string) string { return "carlos-mail-account=" + acct } // provesControl reports whether domain publishes acct's proof record. A nil // resolver proves nothing, so a deployment that never wired one refuses // rather than adopts. func provesControl(ctx context.Context, r TXTResolver, domain, acct string) bool { if r == nil { return false } vals, err := r.LookupTXT(ctx, ProofRecordName(domain)) if err != nil { return false } want := ProofRecordValue(acct) for _, v := range vals { if strings.TrimSpace(v) == want { return true } } return false } // releaseDomainClaim removes domain's claim, but only when it is held by // acct — the same owner-gated neutrality domains.DetachClaim observes, so // this can never be used to discover or disturb another account's claim. // A claim already absent is a quiet no-op: release, like the claim itself, // is idempotent. // // Callers: only ever the two CONDITIONS below, never a third. Releasing // outside them would let a stranger claim a domain still backed by a live // record. // // - right after the identity/DNS for the LAST referring app is torn down // (the shared resource is gone by then, so releasing first is safe) — // detachDomainApp, shared by Teardown and RemoveDomain; // - for a claim this account holds with NO backing record at all // (reachable when EnsureDomain fails after claiming but before ever // writing a record) — Teardown's orphan-claim reconciliation pass, and // RemoveDomain's removeOrphanedClaim for the same state named one // domain at a time. // // It was ONLY Teardown until `carlos email domains rm` (RemoveDomain) was // built, and that was the defect: Teardown is reached solely by // PERMANENTLY DELETING an app, so a domain claimed by the wrong app stayed // unavailable to every other account — its rightful owner included — for // the life of the deployment. func releaseDomainClaim(ctx context.Context, s release.Store, domain, acct string) error { cur, err := getDomainClaim(ctx, s, domain) if err != nil { return err } if cur == nil || cur.Account != acct { return nil } return s.Delete(ctx, domainClaimKey(domain)) } // ListDomainClaims returns every valid domain claim in the bucket — global, // not scoped to any one account, mirroring domains.ListClaims. This is I2's // fix (review follow-up): before this existed, there was no way to see or // diagnose a claim that was not the caller's own short of reading the // bucket directly, which CLAUDE.md's operate-via-API rule treats as a // product gap to close rather than a workaround to reach for. It is also // what makes Teardown's orphan-claim reconciliation possible at all — that // pass has no other way to enumerate an account's claims. // // An unparseable or newer-schema object is logged and skipped, the same // discipline domains.ListClaims applies — one bad claim must never take // the whole listing down. // // The RELEASE half of the door this was written for now exists — `carlos // email domains rm` (RemoveDomain), which frees a claim without deleting // the app that holds it. A READ verb, listing claims an operator does not // hold, is still owed: this function remains the read path such a door // would call, and nothing but Teardown's own sweep calls it yet. func ListDomainClaims(ctx context.Context, s release.Store) ([]DomainClaim, error) { keys, err := s.List(ctx, domainClaimsPrefix) if err != nil { return nil, fmt.Errorf("mail: list domain claims: %w", err) } var claims []DomainClaim for _, key := range keys { raw, _, err := s.Get(ctx, key) if err != nil { log.Printf("mail: domain claim %s: %v", key, err) continue } var c DomainClaim if err := json.Unmarshal(raw, &c); err != nil { log.Printf("mail: domain claim %s: unparseable: %v", key, err) continue } if c.Schema > 1 { log.Printf("mail: domain claim %s: schema %d is newer than this binary understands, skipping", key, c.Schema) continue } claims = append(claims, c) } return claims, nil } // ErrDomainInventory is a strict inventory's refusal: some object under the // listed prefix could not be read or did not parse as exactly what its key // names, so the listing cannot be called complete. var ErrDomainInventory = errors.New("mail: domain inventory incomplete") // StrictDomainClaims is ListDomainClaims for a caller that must see EVERY // claim (the enabled purge's readiness, capture and teardown, C6). Any // listed object that fails to read, does not parse, carries an unsupported // schema, or does not match its own key (domain, account) aborts the whole // inventory with ErrDomainInventory instead of being skipped: an unreadable // orphan claim must stop a purge, never survive it with its SES identity. func StrictDomainClaims(ctx context.Context, s release.Store) ([]DomainClaim, error) { keys, err := s.List(ctx, domainClaimsPrefix) if err != nil { return nil, fmt.Errorf("mail: list domain claims: %w", err) } claims := make([]DomainClaim, 0, len(keys)) seen := make(map[string]struct{}, len(keys)) for _, key := range keys { // A listing may repeat a key (paged or eventually consistent stores); // one claim must count once, never twice. if _, dup := seen[key]; dup { continue } seen[key] = struct{}{} domain, ok := strings.CutSuffix(strings.TrimPrefix(key, domainClaimsPrefix), ".json") if !strings.HasPrefix(key, domainClaimsPrefix) || !ok || !ValidDomain(domain) || key != domainClaimKey(domain) { return nil, fmt.Errorf("%w: unexpected claim key %q", ErrDomainInventory, key) } raw, _, err := s.Get(ctx, key) if err != nil { return nil, fmt.Errorf("%w: claim %s: %v", ErrDomainInventory, key, err) } var c DomainClaim if err := json.Unmarshal(raw, &c); err != nil { return nil, fmt.Errorf("%w: claim %s: unparseable: %v", ErrDomainInventory, key, err) } // Schema 0 is a claim written before the field existed, which the // single-claim reader (getDomainClaimETag) also accepts. App is // attribution and may be empty on such a claim; when present it must // be a valid app name, since purge and teardown match on it. if c.Schema < 0 || c.Schema > 1 || c.Domain != domain || !release.ValidAccount(c.Account) || (c.App != "" && !release.ValidApp(c.App)) { return nil, fmt.Errorf("%w: claim %s: unsupported or mismatched body", ErrDomainInventory, key) } claims = append(claims, c) } return claims, nil }