carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The cross-account domain claim (Task 17a, spec §4a) — added after review
2// found a CRITICAL cross-tenant sending path in Task 17.
3//
4// SES identities are per-AWS-ACCOUNT, not per-tenant: a domain any CARLOS
5// account has verified is verified GLOBALLY at SES. Before this file
6// existed, EnsureDomain computed §4's IAM policy from a domain set that was
7// never checked for ownership — a second account could call EnsureDomain on
8// a domain the first account already controlled, obtain the SAME identity
9// ARN with no proof of its own, and Enable would build it a policy granting
10// ses:SendRawEmail with ses:FromAddress at that domain. Both of §4's rails
11// (verified-only ARNs, From-address-must-match-a-granted-domain) held
12// perfectly and the tenant boundary still fell, because both are computed
13// downstream of a domain set nothing ever gated on ownership.
14//
15// The fix mirrors internal/domains/claims.go's discipline rather than
16// inventing a second pattern: a create-only bucket key whose WRITE is the
17// uniqueness check. domains.AttachClaim gates hostnames the same way, at
18// the SAME key regardless of which account or app is asking, so two
19// accounts racing for one host cannot both win — there is only ever one
20// object to Put. This claim key is deliberately GLOBAL for the identical
21// reason: keying it under an account prefix (accounts/<sqid>/email/...,
22// the shape DomainKey already uses for the per-account identity record)
23// would let every account hold its OWN claim on the same domain, and the
24// whole point of this file is that they cannot.
25package mail
26
27import (
28 "context"
29 "encoding/json"
30 "errors"
31 "fmt"
32 "log"
33 "os"
34 "strings"
35 "time"
36
37 "github.com/carlosframework/platform/internal/release"
38)
39
40// domainClaimsPrefix is where every email domain claim lives — a namespace
41// distinct from DomainsPrefix (which is per-account) precisely because this
42// one is not.
43const domainClaimsPrefix = "email/domain-claims/"
44
45func domainClaimKey(domain string) string { return domainClaimsPrefix + domain + ".json" }
46
47// DomainClaimKey is domainClaimKey for the console's backend boundary, which
48// binds an SES identity or DNS name to the account holding this claim. The
49// caller validates domain (ValidDomain) before building a key from it.
50func DomainClaimKey(domain string) string { return domainClaimKey(domain) }
51
52// DomainClaim is the wire shape at domainClaimKey(Domain) — the global
53// record of which account claimed a sending domain first. Exported (unlike
54// the rest of this file) so ListDomainClaims can hand it to a caller
55// outside this package — I2's operator-visibility fix: until an
56// operator-facing CLI/console verb is built on top of this (filed, not
57// built here), this listing is the ONLY way to see or diagnose a claim
58// that isn't the caller's own, short of a direct bucket read — which
59// CLAUDE.md's operate-via-API rule treats as a product gap to close, not a
60// workaround to reach for.
61type DomainClaim struct {
62 Schema int `json:"schema"` // 1
63 Domain string `json:"domain"`
64 Account string `json:"account"`
65 // App is the app that FIRST claimed the domain — attribution for the
66 // orphan-claim sweep in Teardown (I1 round 2), and nothing else. It is
67 // omitempty for wire-additivity: a claim written before this field
68 // existed has none, and a legacy orphan with an empty App simply never
69 // matches any real Teardown call (ValidApp requires a non-empty app),
70 // which is the safe direction — unsweepable rather than swept by the
71 // wrong caller.
72 App string `json:"app,omitempty"`
73 CreatedAt time.Time `json:"created_at"`
74 // IdentityRegions are the regions where this claim's own walk CREATED
75 // the SES identity, stamped right after the create (#216). It is the
76 // only record of that for a claim whose domain record was never
77 // written, so it is what lets a re-run keep the identity and lets the
78 // orphan sweeps delete it — and what stops either touching an identity
79 // somebody else made. Omitted on a claim written before it existed;
80 // such a claim's identity is then treated as not CARLOS's to delete.
81 IdentityRegions []string `json:"identity_regions,omitempty"`
82 // VerifiedAt is when VerifyDomain first saw SES verify the domain for
83 // this claim's account. A claim with it set never lapses (ClaimLapse).
84 VerifiedAt *time.Time `json:"verified_at,omitempty"`
85}
86
87// createdIdentityIn reports whether this claim's walk created the domain's
88// identity in region.
89func (c *DomainClaim) createdIdentityIn(region string) bool {
90 for _, r := range c.IdentityRegions {
91 if r == region {
92 return true
93 }
94 }
95 return false
96}
97
98// CreatedIdentityIn is createdIdentityIn for the console's backend boundary:
99// a DeleteIdentity is provable only for an identity this claim's walk
100// created, the same rule every delete in this package already applies.
101func (c *DomainClaim) CreatedIdentityIn(region string) bool { return c.createdIdentityIn(region) }
102
103// ErrDomainClaimed is returned by EnsureDomain when the domain's global
104// claim is held by a DIFFERENT account. Deliberately neutral — it never
105// names the holder, so a caller cannot use this door as a cross-account
106// oracle for who owns a domain they do not. The caller's OWN account
107// holding the claim is never this error; see claimDomain.
108var ErrDomainClaimed = errors.New("mail: domain is claimed")
109
110// ErrIdentityNotOwned is EnsureDomain's refusal of a domain whose SES
111// identity this account's claim did not create — one verified outside
112// CARLOS, or left by a claim that predates the record of who created what —
113// when the account has not proved it controls the domain (#216). Adopting
114// it would hand the account send rights it never proved, rewrite the
115// identity's MAIL FROM and configuration set under its real owner, and let
116// a later teardown delete it.
117var ErrIdentityNotOwned = errors.New("mail: domain has an SES identity this account did not create")
118
119// ClaimLapse is how long a claim may hold a domain without ever being
120// verified (#216). Before it, a claim was first-come and permanent, so any
121// account could hold any domain it would never prove, against every other
122// account, for the life of the deployment. SES itself gives DKIM 72 hours
123// before it marks an identity failed; a week leaves room for a slow DNS
124// change and a weekend. A claim that reaches verified never lapses.
125const ClaimLapse = 7 * 24 * time.Hour
126
127// getDomainClaim reads domain's claim. A not-yet-claimed domain returns
128// (nil, nil), matching GetDomain's convention rather than propagating
129// os.ErrNotExist to every caller.
130func getDomainClaim(ctx context.Context, s release.Store, domain string) (*DomainClaim, error) {
131 c, _, err := getDomainClaimETag(ctx, s, domain)
132 if errors.Is(err, os.ErrNotExist) {
133 return nil, nil
134 }
135 return c, err
136}
137
138func getDomainClaimETag(ctx context.Context, s release.Store, domain string) (*DomainClaim, string, error) {
139 raw, etag, err := s.Get(ctx, domainClaimKey(domain))
140 if err != nil {
141 return nil, "", err
142 }
143 var c DomainClaim
144 if err := json.Unmarshal(raw, &c); err != nil {
145 return nil, "", fmt.Errorf("mail: domain claim %s: %w", domain, err)
146 }
147 // A schema newer than this binary understands is refused rather than
148 // silently truncated by json.Unmarshal — domains.getClaimETag's
149 // precedent for the same reason.
150 if c.Schema > 1 {
151 return nil, "", fmt.Errorf("mail: domain claim %s: schema %d is newer than this binary understands — upgrade carlos", domain, c.Schema)
152 }
153 return &c, etag, nil
154}
155
156// claimDomain is the gate: it must run, and succeed, before EnsureIdentity
157// ever does. The bucket key is the cross-account uniqueness check, exactly
158// as domains.AttachClaim's key is for hostnames — two accounts racing to
159// claim one domain cannot both win, because there is only ever one object
160// at domainClaimKey(domain) to Put.
161//
162// Same account re-claiming is idempotent success (a re-run of EnsureDomain
163// by its own owner must never be refused by the gate it itself holds), and
164// deliberately does NOT rewrite App on that no-op path — App records only
165// who FIRST claimed the domain, for the orphan sweep's attribution (I1
166// round 2), not who most recently retried it. Any other account gets
167// ErrDomainClaimed, with no identity ever minted and no domain record ever
168// written — the refusal happens before either exists.
169//
170// First-come is no longer forever: EnsureDomain hands this refusal to
171// takeLapsedClaim, which lets a claim that was never verified go after
172// ClaimLapse (#216).
173func claimDomain(ctx context.Context, s release.Store, domain, acct, app string, now time.Time) error {
174 body, err := json.Marshal(DomainClaim{Schema: 1, Domain: domain, Account: acct, App: app, CreatedAt: now})
175 if err != nil {
176 return err
177 }
178 for attempt := 0; ; attempt++ {
179 cur, _, err := getDomainClaimETag(ctx, s, domain)
180 if errors.Is(err, os.ErrNotExist) {
181 if _, err := s.Put(ctx, domainClaimKey(domain), body, ""); err == nil {
182 return nil
183 } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
184 continue // lost the create race — re-read and judge the winner
185 } else {
186 return err
187 }
188 } else if err != nil {
189 return err
190 }
191 if cur.Account != acct {
192 return ErrDomainClaimed
193 }
194 return nil // already ours: idempotent no-op
195 }
196}
197
198// updateOwnClaim applies change to domain's claim when acct holds it, under
199// CAS. change reports whether it changed anything; a claim held by anyone
200// else, or none at all, is left alone and is not an error.
201func updateOwnClaim(ctx context.Context, s release.Store, domain, acct string, change func(*DomainClaim) bool) error {
202 for attempt := 0; ; attempt++ {
203 cur, etag, err := getDomainClaimETag(ctx, s, domain)
204 if errors.Is(err, os.ErrNotExist) {
205 return nil
206 }
207 if err != nil {
208 return err
209 }
210 if cur.Account != acct || !change(cur) {
211 return nil
212 }
213 body, err := json.Marshal(cur)
214 if err != nil {
215 return err
216 }
217 if _, err := s.Put(ctx, domainClaimKey(domain), body, etag); err == nil {
218 return nil
219 } else if !errors.Is(err, release.ErrCASConflict) || attempt >= 2 {
220 return err
221 }
222 }
223}
224
225// stampClaimIdentity records on acct's claim that its walk created the
226// identity in region.
227func stampClaimIdentity(ctx context.Context, s release.Store, domain, acct, region string) error {
228 return updateOwnClaim(ctx, s, domain, acct, func(c *DomainClaim) bool {
229 if c.createdIdentityIn(region) {
230 return false
231 }
232 c.IdentityRegions = append(c.IdentityRegions, region)
233 return true
234 })
235}
236
237// stampClaimVerified records the first time acct's claim was verified.
238func stampClaimVerified(ctx context.Context, s release.Store, domain, acct string, now time.Time) error {
239 return updateOwnClaim(ctx, s, domain, acct, func(c *DomainClaim) bool {
240 if c.VerifiedAt != nil {
241 return false
242 }
243 at := now.UTC()
244 c.VerifiedAt = &at
245 return true
246 })
247}
248
249// claimLapsed reports whether c, held by another account, has lapsed: older
250// than ClaimLapse and never verified by any of three witnesses — the claim's
251// own stamp, the holder's domain record, and SES itself. SES is asked
252// because a holder may have published its DNS and never run verify; such a
253// domain is in use, and handing it on would also hand on a verified
254// identity.
255func claimLapsed(ctx context.Context, s release.Store, p Provisioner, c *DomainClaim, region string, now time.Time) (bool, error) {
256 if c.VerifiedAt != nil || now.Sub(c.CreatedAt) < ClaimLapse {
257 return false, nil
258 }
259 rec, _, err := GetDomain(ctx, s, c.Account, c.Domain)
260 if err != nil {
261 return false, err
262 }
263 if rec != nil {
264 for _, st := range rec.Regions {
265 if st != nil && st.Status == StatusVerified {
266 return false, nil
267 }
268 }
269 }
270 got, err := p.IdentityStatus(ctx, region, c.Domain)
271 if err != nil {
272 return false, err
273 }
274 if got.DKIM == "SUCCESS" || got.Sending == "SUCCESS" {
275 return false, nil
276 }
277 return true, nil
278}
279
280// takeLapsedClaim is EnsureDomain's answer to ErrDomainClaimed: when the
281// other account's claim has lapsed, acct takes it over; otherwise the
282// refusal stands.
283//
284// The claim moves FIRST, under CAS against the etag the lapse was judged
285// on. A holder that verifies in the meantime stamps verified_at, which moves
286// the etag, so the CAS fails and the holder keeps everything. Once the claim
287// is acct's, the holder's VerifyDomain and teardown both refuse to act on
288// the domain, so its record can no longer verify into a grant.
289//
290// Only then does the lapsed holder's own half-made identity go — and only
291// when its claim says its walk created it, and SES, asked again right
292// before the delete, still has not verified it. Left in place, the new
293// holder would meet an identity it did not create and have to prove DNS
294// control for a domain whose only obstacle was an abandoned attempt. An
295// identity the lapsed claim did not create, or one SES has since verified,
296// is not touched: the new holder meets it as a foreign identity and needs
297// proof. A crash between the CAS and the delete leaves the same state.
298func takeLapsedClaim(ctx context.Context, s release.Store, p Provisioner, region, domain, acct, app string, now time.Time) error {
299 cur, etag, err := getDomainClaimETag(ctx, s, domain)
300 if errors.Is(err, os.ErrNotExist) {
301 return claimDomain(ctx, s, domain, acct, app, now) // released meanwhile
302 }
303 if err != nil {
304 return err
305 }
306 if cur.Account == acct {
307 return nil
308 }
309 lapsed, err := claimLapsed(ctx, s, p, cur, region, now)
310 if err != nil {
311 return err
312 }
313 if !lapsed {
314 return ErrDomainClaimed
315 }
316 body, err := json.Marshal(DomainClaim{Schema: 1, Domain: domain, Account: acct, App: app, CreatedAt: now})
317 if err != nil {
318 return err
319 }
320 if _, err := s.Put(ctx, domainClaimKey(domain), body, etag); err != nil {
321 if errors.Is(err, release.ErrCASConflict) {
322 return ErrDomainClaimed // the holder or another claimant moved first
323 }
324 return err
325 }
326 log.Printf("mail: claim on %s lapsed unverified (held since %s); taken by another account", domain, cur.CreatedAt.Format(time.RFC3339))
327
328 if !cur.createdIdentityIn(region) {
329 return nil
330 }
331 if st, err := p.IdentityStatus(ctx, region, domain); err != nil {
332 log.Printf("mail: lapsed claim %s: identity status: %v; leaving the old identity", domain, err)
333 return nil
334 } else if st.DKIM == "SUCCESS" || st.Sending == "SUCCESS" {
335 return nil
336 }
337 if old, _, err := GetDomain(ctx, s, cur.Account, domain); err == nil && old != nil &&
338 old.Regions[region] != nil && old.Regions[region].IdentityARN != "" {
339 // SES refuses to delete an associated identity.
340 if err := p.DisassociateTenantResource(ctx, TenantName(cur.Account), old.Regions[region].IdentityARN); err != nil {
341 log.Printf("mail: lapsed claim %s: disassociating: %v", domain, err)
342 }
343 }
344 // A failed delete is logged, not returned: the claim is already acct's,
345 // and the walk goes on to meet the identity as a foreign one.
346 if err := p.DeleteIdentity(ctx, region, domain); err != nil {
347 log.Printf("mail: lapsed claim %s: delete old identity: %v", domain, err)
348 }
349 return nil
350}
351
352// TXTResolver is the one DNS question the walk asks: the proof record for a
353// domain whose SES identity this account did not create. net.Resolver
354// satisfies it; tests inject a fake.
355type TXTResolver interface {
356 LookupTXT(ctx context.Context, name string) ([]string, error)
357}
358
359// ProofRecordName and ProofRecordValue are the TXT record a claimant
360// publishes to prove it controls a domain that already has an SES identity
361// (#216). The value names the account, so one account's proof is no use to
362// another; only somebody who can write the domain's DNS can publish it.
363func ProofRecordName(domain string) string { return "_carlos-mail." + domain }
364
365func ProofRecordValue(acct string) string { return "carlos-mail-account=" + acct }
366
367// provesControl reports whether domain publishes acct's proof record. A nil
368// resolver proves nothing, so a deployment that never wired one refuses
369// rather than adopts.
370func provesControl(ctx context.Context, r TXTResolver, domain, acct string) bool {
371 if r == nil {
372 return false
373 }
374 vals, err := r.LookupTXT(ctx, ProofRecordName(domain))
375 if err != nil {
376 return false
377 }
378 want := ProofRecordValue(acct)
379 for _, v := range vals {
380 if strings.TrimSpace(v) == want {
381 return true
382 }
383 }
384 return false
385}
386
387// releaseDomainClaim removes domain's claim, but only when it is held by
388// acct — the same owner-gated neutrality domains.DetachClaim observes, so
389// this can never be used to discover or disturb another account's claim.
390// A claim already absent is a quiet no-op: release, like the claim itself,
391// is idempotent.
392//
393// Callers: only ever the two CONDITIONS below, never a third. Releasing
394// outside them would let a stranger claim a domain still backed by a live
395// record.
396//
397// - right after the identity/DNS for the LAST referring app is torn down
398// (the shared resource is gone by then, so releasing first is safe) —
399// detachDomainApp, shared by Teardown and RemoveDomain;
400// - for a claim this account holds with NO backing record at all
401// (reachable when EnsureDomain fails after claiming but before ever
402// writing a record) — Teardown's orphan-claim reconciliation pass, and
403// RemoveDomain's removeOrphanedClaim for the same state named one
404// domain at a time.
405//
406// It was ONLY Teardown until `carlos email domains rm` (RemoveDomain) was
407// built, and that was the defect: Teardown is reached solely by
408// PERMANENTLY DELETING an app, so a domain claimed by the wrong app stayed
409// unavailable to every other account — its rightful owner included — for
410// the life of the deployment.
411func releaseDomainClaim(ctx context.Context, s release.Store, domain, acct string) error {
412 cur, err := getDomainClaim(ctx, s, domain)
413 if err != nil {
414 return err
415 }
416 if cur == nil || cur.Account != acct {
417 return nil
418 }
419 return s.Delete(ctx, domainClaimKey(domain))
420}
421
422// ListDomainClaims returns every valid domain claim in the bucket — global,
423// not scoped to any one account, mirroring domains.ListClaims. This is I2's
424// fix (review follow-up): before this existed, there was no way to see or
425// diagnose a claim that was not the caller's own short of reading the
426// bucket directly, which CLAUDE.md's operate-via-API rule treats as a
427// product gap to close rather than a workaround to reach for. It is also
428// what makes Teardown's orphan-claim reconciliation possible at all — that
429// pass has no other way to enumerate an account's claims.
430//
431// An unparseable or newer-schema object is logged and skipped, the same
432// discipline domains.ListClaims applies — one bad claim must never take
433// the whole listing down.
434//
435// The RELEASE half of the door this was written for now exists — `carlos
436// email domains rm` (RemoveDomain), which frees a claim without deleting
437// the app that holds it. A READ verb, listing claims an operator does not
438// hold, is still owed: this function remains the read path such a door
439// would call, and nothing but Teardown's own sweep calls it yet.
440func ListDomainClaims(ctx context.Context, s release.Store) ([]DomainClaim, error) {
441 keys, err := s.List(ctx, domainClaimsPrefix)
442 if err != nil {
443 return nil, fmt.Errorf("mail: list domain claims: %w", err)
444 }
445 var claims []DomainClaim
446 for _, key := range keys {
447 raw, _, err := s.Get(ctx, key)
448 if err != nil {
449 log.Printf("mail: domain claim %s: %v", key, err)
450 continue
451 }
452 var c DomainClaim
453 if err := json.Unmarshal(raw, &c); err != nil {
454 log.Printf("mail: domain claim %s: unparseable: %v", key, err)
455 continue
456 }
457 if c.Schema > 1 {
458 log.Printf("mail: domain claim %s: schema %d is newer than this binary understands, skipping", key, c.Schema)
459 continue
460 }
461 claims = append(claims, c)
462 }
463 return claims, nil
464}
465
466// ErrDomainInventory is a strict inventory's refusal: some object under the
467// listed prefix could not be read or did not parse as exactly what its key
468// names, so the listing cannot be called complete.
469var ErrDomainInventory = errors.New("mail: domain inventory incomplete")
470
471// StrictDomainClaims is ListDomainClaims for a caller that must see EVERY
472// claim (the enabled purge's readiness, capture and teardown, C6). Any
473// listed object that fails to read, does not parse, carries an unsupported
474// schema, or does not match its own key (domain, account) aborts the whole
475// inventory with ErrDomainInventory instead of being skipped: an unreadable
476// orphan claim must stop a purge, never survive it with its SES identity.
477func StrictDomainClaims(ctx context.Context, s release.Store) ([]DomainClaim, error) {
478 keys, err := s.List(ctx, domainClaimsPrefix)
479 if err != nil {
480 return nil, fmt.Errorf("mail: list domain claims: %w", err)
481 }
482 claims := make([]DomainClaim, 0, len(keys))
483 seen := make(map[string]struct{}, len(keys))
484 for _, key := range keys {
485 // A listing may repeat a key (paged or eventually consistent stores);
486 // one claim must count once, never twice.
487 if _, dup := seen[key]; dup {
488 continue
489 }
490 seen[key] = struct{}{}
491 domain, ok := strings.CutSuffix(strings.TrimPrefix(key, domainClaimsPrefix), ".json")
492 if !strings.HasPrefix(key, domainClaimsPrefix) || !ok || !ValidDomain(domain) || key != domainClaimKey(domain) {
493 return nil, fmt.Errorf("%w: unexpected claim key %q", ErrDomainInventory, key)
494 }
495 raw, _, err := s.Get(ctx, key)
496 if err != nil {
497 return nil, fmt.Errorf("%w: claim %s: %v", ErrDomainInventory, key, err)
498 }
499 var c DomainClaim
500 if err := json.Unmarshal(raw, &c); err != nil {
501 return nil, fmt.Errorf("%w: claim %s: unparseable: %v", ErrDomainInventory, key, err)
502 }
503 // Schema 0 is a claim written before the field existed, which the
504 // single-claim reader (getDomainClaimETag) also accepts. App is
505 // attribution and may be empty on such a claim; when present it must
506 // be a valid app name, since purge and teardown match on it.
507 if c.Schema < 0 || c.Schema > 1 || c.Domain != domain || !release.ValidAccount(c.Account) ||
508 (c.App != "" && !release.ValidApp(c.App)) {
509 return nil, fmt.Errorf("%w: claim %s: unsupported or mismatched body", ErrDomainInventory, key)
510 }
511 claims = append(claims, c)
512 }
513 return claims, nil
514}
515