| 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. |
| 25 | package mail |
| 26 | |
| 27 | import ( |
| 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. |
| 43 | const domainClaimsPrefix = "email/domain-claims/" |
| 44 | |
| 45 | func 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. |
| 50 | func 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. |
| 61 | type 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. |
| 89 | func (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. |
| 101 | func (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. |
| 108 | var 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. |
| 117 | var 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. |
| 125 | const 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. |
| 130 | func 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 | |
| 138 | func 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). |
| 173 | func 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. |
| 201 | func 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. |
| 227 | func 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. |
| 238 | func 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. |
| 255 | func 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. |
| 298 | func 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. |
| 355 | type 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. |
| 363 | func ProofRecordName(domain string) string { return "_carlos-mail." + domain } |
| 364 | |
| 365 | func 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. |
| 370 | func 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. |
| 411 | func 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. |
| 440 | func 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. |
| 469 | var 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. |
| 477 | func 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 | |