carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The walks: the provisioning sequences every verb trusts. Each is
2// check-then-create, so each is re-runnable after a half-failure — the
3// same discipline internal/objstore/walk.go follows, for the same reason.
4package mail
5
6import (
7 "context"
8 "crypto/ecdh"
9 "errors"
10 "fmt"
11 "log"
12 "regexp"
13 "sort"
14 "strings"
15 "time"
16
17 "github.com/carlosframework/platform/internal/appconfig"
18 "github.com/carlosframework/platform/internal/dnsrec"
19 "github.com/carlosframework/platform/internal/release"
20)
21
22var (
23 // ErrNotDeclared is a provisioning verb for an app whose member never
24 // asked for email. The operator's verb never invents the declaration:
25 // the env prefix and the From address are the member's to choose.
26 ErrNotDeclared = errors.New("mail: app has not declared email")
27 // ErrNoDomains is a provisioning verb with no VERIFIED domain. A
28 // credential with no identity would be a principal that can send
29 // nothing, which is worse than no credential — it looks provisioned.
30 ErrNoDomains = errors.New("mail: no verified sending domain")
31 // ErrNotProvisioned is a rotation or pause aimed at an app that was
32 // never provisioned. These verbs edit an existing credential estate;
33 // they never create one.
34 ErrNotProvisioned = errors.New("mail: app has no provisioned credential")
35 // ErrNotOwnPlatformName is EnsureDomain's refusal of a platform-suffixed
36 // domain the caller does not own — see platformNameOwnedBy. Unlike
37 // ErrDomainClaimed, naming the caller's OWN account here is fine: this
38 // is not a cross-account leak, it is telling the caller which name IS
39 // theirs.
40 ErrNotOwnPlatformName = errors.New("mail: not this account's platform name")
41 // ErrDomainNotReferenced is RemoveDomain's refusal of a domain this app
42 // does not use — including one whose global claim belongs to somebody
43 // else, which reads identically on purpose so the door cannot be used
44 // as an oracle for who holds a domain the caller does not.
45 ErrDomainNotReferenced = errors.New("mail: app does not use this domain")
46 // ErrDomainIsFrom is RemoveDomain's refusal of the domain the app is
47 // currently sending FROM. Enable rejects a From that is not at a
48 // granted domain, so allowing this would break sending at the app's
49 // next re-enable rather than at the command that caused it — the
50 // failure moves from a refusal a person can read to silence a person
51 // has to debug.
52 ErrDomainIsFrom = errors.New("mail: domain is the app's From address")
53)
54
55// Deployment is the per-deployment configuration the walks need.
56//
57// DMARCRua is the aggregate-report mailto target and may be empty, in which
58// case no rua is published (spec §7). SNSTopicARN receives SES events; empty
59// means no configuration set is created and no counts arrive, which is a
60// degraded deployment rather than a broken one.
61type Deployment struct {
62 Region string
63 PlatformSuffix string // e.g. "oncarlos.com" — the zone CARLOS owns
64 DMARCRua string
65 SNSTopicARN string
66 // Resolver answers the proof-of-control lookup for a domain whose SES
67 // identity this account did not create (#216). nil means no proof can
68 // be checked, so such a domain is always refused.
69 Resolver TXTResolver
70}
71
72// DomainKindFor decides who publishes DNS for a domain.
73//
74// The suffix match is LABEL-AWARE: "evil-oncarlos.com" ends with
75// "oncarlos.com" as a string but is a different zone entirely, and treating
76// it as ours would have the walk try to write records into somebody else's
77// domain. Only an exact match or a true subdomain counts.
78func DomainKindFor(d Deployment, domain string) string {
79 if d.PlatformSuffix == "" {
80 return KindCustom
81 }
82 if domain == d.PlatformSuffix || strings.HasSuffix(domain, "."+d.PlatformSuffix) {
83 return KindPlatform
84 }
85 return KindCustom
86}
87
88// platformNameOwnedBy reports whether domain, a KindPlatform name, is
89// acct's to ask for.
90//
91// CORRECTION (spec §4a, 2026-08-19): this section originally claimed
92// platform names need no ownership check because they are "account-scoped
93// by construction". They are not — DomainKindFor matches only the
94// *suffix*, and nothing else compared the account to the name. Review
95// demonstrated account "bbb" claiming "harbourmail.aaa.oncarlos.com" and
96// getting a verified identity AND a published DNS record set for it,
97// without any DNS control of its own — CARLOS published the proof on the
98// attacker's behalf, which is strictly worse than the custom-domain case
99// the global claim exists for. The bare suffix itself (no account label at
100// all) was accepted too, writing a DMARC record at the platform's own zone
101// apex.
102//
103// The fix: for a platform name, the label immediately left of the suffix —
104// "aaa" in "harbourmail.aaa.oncarlos.com" — MUST equal the calling
105// account's sqid, and the bare suffix is refused outright (there is no
106// label to check, so there is no account it could belong to). This check
107// is INDEPENDENT of, and runs ALONGSIDE, the global claim below: the claim
108// is first-come-first-served, which is the right rule where DNS control is
109// the real proof (a custom domain) and no rule at all where CARLOS itself
110// supplies the proof — first-come-first-served over that would let "bbb"
111// squat "aaa"'s future platform name before "aaa" ever asks for it, even
112// with the claim gate fully working.
113func platformNameOwnedBy(d Deployment, domain, acct string) bool {
114 if domain == d.PlatformSuffix {
115 return false // the bare suffix belongs to no account
116 }
117 rest := strings.TrimSuffix(domain, "."+d.PlatformSuffix)
118 label := rest
119 if i := strings.LastIndex(rest, "."); i != -1 {
120 label = rest[i+1:]
121 }
122 return label == acct
123}
124
125// EnsureDomain brings a sending domain into existence for an app.
126//
127// It is one code path for both kinds because the record SET is identical;
128// only the publisher differs. For a platform domain the walk writes every
129// record and returns nothing pending. For a custom domain it writes nothing
130// and returns the whole set for the member to publish. Platform domains are
131// NOT exempt from the claim below, and — as of Task 17a's follow-up review
132// (C1) — they get a SECOND, independent gate the claim alone cannot
133// provide: platformNameOwnedBy. The claim is first-come-first-served,
134// which is fine when DNS control is the real proof; a platform name is
135// proven by CARLOS itself, so first-come-first-served over it would let
136// any account squat any other account's future platform name. See
137// platformNameOwnedBy's doc comment for the full story.
138//
139// The order, after validation: check platform-name ownership (no I/O, so
140// it costs nothing to do first and unconditionally), then CLAIM the
141// domain, then create the identity (which mints the DKIM tokens the
142// records name), then publish, then record. The claim is deliberately the
143// very first DURABLE side effect this walk performs — before EnsureTenant,
144// not just before EnsureIdentity. The ownership check precedes even the
145// claim only because it is pure and free, not because it is a second
146// claim-like gate in its own right. Nothing about the domain the caller
147// named is trustworthy until the claim says it is theirs, and EnsureTenant
148// is a real backend call; doing it (or anything else) for a request the
149// claim is about to refuse would mean a losing account's call still bought
150// it a wasted SES/IAM round trip. Once the claim is confirmed, the rest of
151// the walk is self-healing on a re-run (identity idempotent and
152// token-stable, DNS writes are upserts) exactly as before — this task
153// changes nothing about that repair story, it only closes what happens
154// before it starts.
155//
156// This ordering is spec §4a, added after review found the cross-tenant
157// hole this closes: SES identities are per-AWS-ACCOUNT, not per-tenant, so
158// any domain one account has verified is verified globally at SES. Without
159// the claim, a second account could call EnsureDomain on a domain another
160// account already controlled, walk straight to EnsureIdentity, and obtain
161// the SAME identity ARN with no proof of its own — Enable would then build
162// it an IAM policy granting ses:SendRawEmail at that domain. Writing the
163// claim AFTER EnsureIdentity (the reverse order) would not close this: two
164// accounts could each mint an identity and only discover the conflict from
165// the fleet, by which point both already hold a working ARN.
166func claimUnserializedEnsureDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer,
167 d Deployment, acct, app, domain string, now time.Time) (*DomainRecord, []dnsrec.Record, error) {
168
169 if !release.ValidAccount(acct) {
170 return nil, nil, fmt.Errorf("mail: invalid account %q", acct)
171 }
172 if !release.ValidApp(app) {
173 return nil, nil, fmt.Errorf("mail: invalid app %q", app)
174 }
175 if !ValidDomain(domain) {
176 return nil, nil, fmt.Errorf("mail: invalid domain %q", domain)
177 }
178 // Platform-name ownership is checked BEFORE the claim, and unconditionally
179 // — it costs no I/O, and the claim alone is the wrong gate for a name
180 // CARLOS itself supplies the proof for (platformNameOwnedBy's doc
181 // comment has the full story: this is C1 from Task 17a's follow-up
182 // review, and the claim by itself does not close it — a second account
183 // could claim its own platform-shaped lookalike name before the real
184 // owner ever asks for it).
185 if DomainKindFor(d, domain) == KindPlatform && !platformNameOwnedBy(d, domain, acct) {
186 return nil, nil, fmt.Errorf("mail: %q is not %s's platform name: %w", domain, acct, ErrNotOwnPlatformName)
187 }
188 err := claimDomain(ctx, s, domain, acct, app, now)
189 if errors.Is(err, ErrDomainClaimed) {
190 err = takeLapsedClaim(ctx, s, p, d.Region, domain, acct, app, now)
191 }
192 if err != nil {
193 // Wrapping preserves errors.Is(err, ErrDomainClaimed) for callers
194 // (the console door translates it into its own neutral refusal) —
195 // %w keeps the chain — and the added context is just the domain
196 // name the caller already supplied, never the account that holds
197 // the claim.
198 return nil, nil, fmt.Errorf("mail: claim domain %s: %w", domain, err)
199 }
200 // TenantName, not the bare acct: the SES tenant namespace is shared with
201 // other products in this AWS account (see its doc comment in policy.go).
202 tenant := TenantName(acct)
203 if err := p.EnsureTenant(ctx, tenant); err != nil {
204 return nil, nil, fmt.Errorf("mail: ensure tenant %s: %w", tenant, err)
205 }
206
207 rec, etag, err := GetDomain(ctx, s, acct, domain)
208 if err != nil {
209 return nil, nil, err
210 }
211 hadRecord := rec != nil
212 if rec == nil {
213 rec = &DomainRecord{
214 Domain: domain, Kind: DomainKindFor(d, domain),
215 Regions: map[string]*RegionState{}, CreatedAt: now,
216 }
217 }
218 if rec.Regions == nil {
219 rec.Regions = map[string]*RegionState{}
220 }
221 rec.AddApp(app)
222
223 // WHOSE IDENTITY IS IT (#216). SES identities are per AWS account, and
224 // this one is shared with every other product in it, so "already
225 // exists" used to mean "adopt": read back its DKIM tokens, rewrite its
226 // MAIL FROM, bind it to this account's configuration set, and delete it
227 // at teardown. A member could do that to tito.io, or to the operator's
228 // own sign-in domain, and get send rights for it the moment verify read
229 // SES's existing verdict.
230 //
231 // So an existing identity is this account's to keep only when CARLOS
232 // already knows it is: the account's record names it (created by an
233 // earlier run, by a sibling app, or adopted with proof before), or the
234 // claim says this account's walk created it and the record was never
235 // written. Anything else needs proof of DNS control, and is recorded as
236 // Adopted so nothing CARLOS does later deletes it.
237 mailFrom := MailFromDomain(domain, d.Region)
238 prev := rec.Regions[d.Region]
239 owned := prev != nil && prev.IdentityARN != ""
240 adopted := owned && prev.Adopted
241 if !owned {
242 claim, err := getDomainClaim(ctx, s, domain)
243 if err != nil {
244 return nil, nil, err
245 }
246 owned = claim != nil && claim.Account == acct && claim.createdIdentityIn(d.Region)
247 }
248 ident, err := p.EnsureIdentity(ctx, d.Region, domain, mailFrom, owned)
249 if errors.Is(err, ErrIdentityExists) {
250 if !provesControl(ctx, d.Resolver, domain, acct) {
251 // Nothing of this account's stands behind a claim with no
252 // record: let it go, so a refusal never blocks the domain's
253 // real owner, and no orphan sweep is ever pointed at it.
254 if !hadRecord {
255 if rerr := releaseDomainClaim(ctx, s, domain, acct); rerr != nil {
256 log.Printf("mail: releasing claim on refused %s: %v", domain, rerr)
257 }
258 }
259 return nil, nil, fmt.Errorf("mail: %s in %s: %w", domain, d.Region, ErrIdentityNotOwned)
260 }
261 adopted = true
262 ident, err = p.EnsureIdentity(ctx, d.Region, domain, mailFrom, true)
263 }
264 if err != nil {
265 return nil, nil, fmt.Errorf("mail: ensure identity %s in %s: %w", domain, d.Region, err)
266 }
267 if ident.Created {
268 // Stamped before anything else can fail, so a crash from here on
269 // leaves a claim that says this identity is CARLOS's: the re-run
270 // keeps it, and the orphan sweeps may delete it.
271 adopted = false
272 if err := stampClaimIdentity(ctx, s, domain, acct, d.Region); err != nil {
273 return nil, nil, fmt.Errorf("mail: record identity for %s: %w", domain, err)
274 }
275 }
276
277 // configSet stays empty when no SNS topic is configured — recording a
278 // name for a set that was never created would leave later sends
279 // failing with "Configuration set does not exist" instead of the
280 // degraded-but-working deployment the Deployment doc comment promises.
281 configSet := ""
282 if d.SNSTopicARN != "" {
283 configSet = ConfigSetName(acct)
284 if err := p.EnsureConfigSet(ctx, d.Region, configSet, d.SNSTopicARN); err != nil {
285 return nil, nil, fmt.Errorf("mail: ensure config set %s: %w", configSet, err)
286 }
287 // C1 (review follow-up): EnsureConfigSet only creates the SET and
288 // its SNS event destination — nothing before this bound the
289 // IDENTITY just ensured above to it, and SES only emits events for
290 // a message sent WITH a configuration set. Without this call every
291 // send/bounce/complaint count for this identity stays zero
292 // forever: the daily cap never enforces and the automatic abuse
293 // pause — the one thing stopping a single tenant's stale list
294 // putting the whole shared SES account under review — never
295 // fires. Bound HERE, inside the `d.SNSTopicARN != ""` branch: the
296 // SNS-less deployment deliberately records an empty configSet
297 // (this function's own doc comment on Deployment), and calling
298 // this with an empty name would either error at a real backend or
299 // silently bind to nothing.
300 //
301 // THE BIND ITSELF HAPPENS AT THE END OF THIS FUNCTION, not here,
302 // and the gap is the whole point. SES authorises a configuration
303 // set as a resource in its own right (#271), so an identity bound
304 // to a set that the app's IAM policy does not name refuses every
305 // send with a 554. Creating the set is harmless; binding it is not
306 // — so the order has to be create, record, GRANT, then bind. Bind
307 // here and there is a window, however short, in which this app
308 // cannot send at all.
309 }
310
311 records := RecordSet(domain, d.Region, ident.DKIMTokens, d.DMARCRua)
312 var pending []dnsrec.Record
313 // GATED ON w != nil, not on rec.Kind alone (C1/item-4, review
314 // follow-up round 2). Kind still answers truthfully from the
315 // CONFIGURED suffix — that is what platformNameOwnedBy's ownership
316 // check depends on above, and that check must run whether or not a
317 // publisher is wired up, or a deployment with no DNSWriter silently
318 // stops enforcing ownership on its own platform names (the exact bug
319 // this round closed: ownership and publishing are two different
320 // questions, and only publishing has any business asking about w).
321 // nil w degrades a platform-kind domain to pending exactly like a
322 // custom one — never a call on a nil interface, and never a "publish"
323 // into some throwaway writer nobody durable ever reads.
324 if rec.Kind == KindPlatform && w != nil {
325 for _, r := range records {
326 if err := w.EnsureRecord(ctx, r); err != nil {
327 // Deliberately BEFORE the record is written: a failed
328 // publish must leave the walk repairable by a re-run, not
329 // leave a record claiming DNS that is not there.
330 return nil, nil, fmt.Errorf("mail: publish %s %s: %w", r.Type, r.Name, err)
331 }
332 }
333 } else {
334 pending = records
335 }
336
337 // Preserve a status already reached: a verified region must not be
338 // knocked back to pending by a re-run. StatusFailed is NOT preserved —
339 // a re-run is a fresh attempt, so a previously failed region goes back
340 // to pending rather than staying stuck at failed.
341 status := StatusPending
342 if st, ok := rec.Regions[d.Region]; ok && st.Status == StatusVerified {
343 status = StatusVerified
344 }
345 rec.Regions[d.Region] = &RegionState{
346 IdentityARN: ident.ARN, DKIMTokens: ident.DKIMTokens,
347 MailFrom: mailFrom, ConfigSet: configSet,
348 Status: status, CheckedAt: now, Adopted: adopted,
349 }
350 if _, err := PutDomain(ctx, s, acct, rec, etag); err != nil {
351 return nil, nil, err
352 }
353
354 // The account's tenant gets this identity, and its configuration set
355 // (design §7). Association does not modify the resource and one resource
356 // may belong to many tenants, so this changes nothing anyone can observe
357 // today. It is wired now because SES validates the association on every
358 // tenant-attributed send and step 3 sends that way.
359 //
360 // AFTER PutDomain, not beside EnsureIdentity, and that ordering is the
361 // whole of what keeps Teardown's orphan-claim sweep working. An
362 // association is the one thing that stops an identity being deleted, and
363 // the sweep (walk.go's orphaned-claims loop, below) deletes the identity
364 // behind a claim that has NO record — so it never runs the disassociate
365 // in Teardown's region loop. Associate before the record is durable and
366 // a crash between the two leaves an identity nothing can ever reap.
367 if err := p.AssociateTenantResource(ctx, tenant, ident.ARN); err != nil {
368 return nil, nil, fmt.Errorf("mail: associate identity %s with tenant %s: %w", domain, tenant, err)
369 }
370 if configSet != "" {
371 if setARN, ok := ConfigSetARN(ident.ARN, configSet); ok {
372 if err := p.AssociateTenantResource(ctx, tenant, setARN); err != nil {
373 return nil, nil, fmt.Errorf("mail: associate configuration set %s with tenant %s: %w", configSet, tenant, err)
374 }
375 }
376 }
377
378 // GRANT BEFORE BIND. Every app that sends on this domain — and every
379 // standalone credential those apps hold — gets its policy recomputed
380 // from the record just written, so the configuration-set ARN is in
381 // place before anything is bound to it.
382 //
383 // This is not an optimisation, it is the difference between a repair
384 // and an outage. EnsureUser is the only place a policy is written, and
385 // it runs in Enable and CreateStandalone — never here. So before this,
386 // an app enabled BEFORE a config set existed (every app on a
387 // deployment that arms SNS later) got the set bound underneath it with
388 // a policy that never mentioned it: 554 on every send. `carlos email
389 // domains add` was the documented repair for missing events, and it
390 // was also the thing that broke sending.
391 if err := RefreshPolicies(ctx, s, p, acct, rec.Apps); err != nil {
392 return nil, nil, err
393 }
394 if configSet != "" {
395 if err := p.SetIdentityConfigSet(ctx, d.Region, domain, configSet); err != nil {
396 return nil, nil, fmt.Errorf("mail: bind identity %s to config set %s: %w", domain, configSet, err)
397 }
398 }
399 return rec, pending, nil
400}
401
402// RefreshPolicies rewrites the inline policy of every principal belonging to
403// each of apps, from the CURRENT granted domain set — the delivered
404// credential and every standalone one alike.
405//
406// It exists because the grant is a function of state that changes OUTSIDE
407// the walks that write it. Enable and CreateStandalone each recompute it for
408// one app at the moment they mint a key; nothing recomputed it when a
409// DOMAIN changed underneath an app that was already provisioned. Binding a
410// configuration set is exactly that kind of change, and so is a domain
411// joining or leaving the granted set.
412//
413// Silent on an app with nothing to grant: no record, no declaration, no
414// granted domains, or nothing provisioned yet all mean there is no principal
415// whose policy could be stale. An app that IS provisioned and whose grant
416// cannot be computed is an error, not a skip — that is a policy this
417// function was asked to refresh and could not.
418func claimUnserializedRefreshPolicies(ctx context.Context, s release.Store, p Provisioner, acct string, apps []string) error {
419 for _, app := range apps {
420 rec, _, err := Get(ctx, s, acct, app)
421 if err != nil {
422 return err
423 }
424 if rec == nil || rec.Declared == nil {
425 continue
426 }
427 if rec.Provisioned == nil && len(rec.Standalone) == 0 {
428 continue
429 }
430 domains, err := grantedDomains(ctx, s, acct, app)
431 if err != nil {
432 return err
433 }
434 if len(domains) == 0 {
435 continue
436 }
437 var arns, configSets, names []string
438 for _, dom := range domains {
439 arns = append(arns, dom.VerifiedARNs()...)
440 configSets = append(configSets, dom.VerifiedConfigSetARNs()...)
441 names = append(names, dom.Domain)
442 }
443 sort.Strings(arns)
444 sort.Strings(configSets)
445 sort.Strings(names)
446 if len(arns) == 0 {
447 // Granted domains exist but none is verified in any region, so
448 // SendPolicyJSON would refuse anyway. Nothing to write.
449 continue
450 }
451 if rec.Provisioned != nil {
452 user := UserName(acct, app)
453 if _, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names); err != nil {
454 return fmt.Errorf("mail: refresh policy for %s: %w", user, err)
455 }
456 }
457 for _, sa := range rec.Standalone {
458 user := StandaloneUserName(acct, app, sa.Name)
459 if _, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names); err != nil {
460 return fmt.Errorf("mail: refresh policy for %s: %w", user, err)
461 }
462 }
463 }
464 return nil
465}
466
467// ConfigSetName is the per-account SES configuration set that carries the
468// event destination feeding bounce, complaint and send counts (spec §9).
469func ConfigSetName(acct string) string { return "carlos-" + acct }
470
471// VerifyDomain re-reads SES's verdict for one (domain, region) and records
472// it.
473//
474// SES'S STATUS IS THE ONLY AUTHORITY. This function deliberately does NOT
475// do a DNS lookup to decide whether the records are live: a wildcard record
476// at a parent name can synthesise an answer for a name that is genuinely
477// absent, and a poller trusting its own lookup would flip a domain to
478// verified while SES still refuses to sign with it. Asking SES costs a round
479// trip and cannot be fooled that way.
480//
481// The status is recomputed from scratch each call rather than latched, so a
482// domain that SES later reports FAILED leaves verified, and one that
483// recovers leaves failed.
484func claimUnserializedVerifyDomain(ctx context.Context, s release.Store, p Provisioner, d Deployment,
485 acct, domain, region string, now time.Time) (*DomainRecord, error) {
486
487 rec, etag, err := GetDomain(ctx, s, acct, domain)
488 if err != nil {
489 return nil, err
490 }
491 if rec == nil {
492 return nil, fmt.Errorf("mail: no domain record for %s", domain)
493 }
494 st, ok := rec.Regions[region]
495 if !ok {
496 return nil, fmt.Errorf("mail: domain %s has no identity in %s", domain, region)
497 }
498
499 // A record whose claim another account now holds — it lapsed unverified
500 // and was taken (#216) — must not verify into a grant on what is now
501 // somebody else's identity. It reads failed, and SES is not asked. No
502 // claim at all is a record from before the claim gate, and verifies as
503 // it always did.
504 claim, err := getDomainClaim(ctx, s, domain)
505 if err != nil {
506 return nil, err
507 }
508 if claim != nil && claim.Account != acct {
509 wasVerified := st.Status == StatusVerified
510 st.Status, st.CheckedAt = StatusFailed, now
511 if _, err := PutDomain(ctx, s, acct, rec, etag); err != nil {
512 return nil, err
513 }
514 if wasVerified {
515 if err := RefreshPolicies(ctx, s, p, acct, rec.Apps); err != nil {
516 return nil, err
517 }
518 }
519 return nil, fmt.Errorf("mail: verify %s: %w", domain, ErrDomainClaimed)
520 }
521
522 got, err := p.IdentityStatus(ctx, region, domain)
523 if err != nil {
524 return nil, fmt.Errorf("mail: identity status for %s in %s: %w", domain, region, err)
525 }
526
527 switch {
528 case got.Verified():
529 st.Status = StatusVerified
530 case got.DKIM == "FAILED" || got.MailFrom == "FAILED":
531 // Terminal at SES until DNS changes. Recording it is what stops a
532 // member waiting on a spinner that will never resolve.
533 st.Status = StatusFailed
534 default:
535 st.Status = StatusPending
536 }
537 st.CheckedAt = now
538
539 // RECONCILE THE CONFIGURATION SET (#272). Neither the record nor SES is
540 // the source of truth here — deployment INTENT is: a deployment with an
541 // SNS topic wants every identity bound to its account's set, and one
542 // without wants none bound. Both stores are projections of that, and
543 // either can drift from it.
544 //
545 // Done here rather than on every status read because IdentityStatus has
546 // already asked SES about this identity: the drift check costs nothing
547 // extra. Reconciling on read would put an API call per domain on a path
548 // that answers from the bucket alone today, and would turn a working
549 // read into a failure whenever AWS is unreachable.
550 //
551 // The unbind direction is not symmetry for its own sake: a deployment
552 // that turns event ingest off, or whose set was deleted, leaves
553 // identities pointing at a destination that no longer exists, and a
554 // send through a missing configuration set fails outright.
555 want := ""
556 if d.SNSTopicARN != "" {
557 want = ConfigSetName(acct)
558 }
559 rebound := false
560 if got.ConfigSet != want {
561 if err := p.SetIdentityConfigSet(ctx, region, domain, want); err != nil {
562 return nil, fmt.Errorf("mail: rebind identity %s in %s: %w", domain, region, err)
563 }
564 rebound = true
565 }
566 st.ConfigSet = want
567
568 if _, err := PutDomain(ctx, s, acct, rec, etag); err != nil {
569 return nil, err
570 }
571 // Once verified, the claim never lapses. A failed stamp is only logged:
572 // the record's own verified status protects the claim just the same
573 // until the next verify retries it.
574 if st.Status == StatusVerified && claim != nil && claim.VerifiedAt == nil {
575 if err := stampClaimVerified(ctx, s, domain, acct, now); err != nil {
576 log.Printf("mail: stamping verified on claim %s: %v", domain, err)
577 }
578 }
579
580 // Grant after rebind, for #279's reason seen from the other side: a
581 // repair that fixes SES and the record but leaves the IAM policy naming
582 // no configuration set converts silent-no-events into a 554 on every
583 // send — strictly worse than the drift it repaired. Only on an actual
584 // rebind, so the ordinary verify stays the cheap read it was.
585 if rebound {
586 if err := RefreshPolicies(ctx, s, p, acct, rec.Apps); err != nil {
587 return nil, err
588 }
589 }
590 return rec, nil
591}
592
593// SMTPPort is the submission port every delivered credential uses. 587 with
594// STARTTLS is the whole of the delivered contract's transport (spec §1).
595const SMTPPort = "587"
596
597// SMTPHost is the submission endpoint for a deployment's region. v1 delivers
598// exactly ONE _HOST, fleet-wide, from d.Region — every instance of an app
599// gets the same host regardless of where it actually runs. Per-instance
600// resolution (a host that follows the instance's own region) is deferred,
601// and is recorded in the spec.
602//
603// When per-instance resolution lands, _PASS must move with it, not stay
604// behind: SMTPPassword is derived PER REGION, so a per-instance host paired
605// with today's fleet-wide password would be wrong for every instance
606// outside the region that password was derived for.
607func SMTPHost(region string) string { return "email-smtp." + region + ".amazonaws.com" }
608
609// Declare records the member's half. It does NOT validate the From address
610// against a domain, because a member may declare before any domain is
611// verified; Enable is where the two meet and where a mismatch is refused.
612func claimUnserializedDeclare(ctx context.Context, s release.Store, acct, app, envPrefix, from, actor string,
613 now time.Time) (*Record, error) {
614
615 if envPrefix == "" {
616 envPrefix = DefaultEnvPrefix
617 }
618 if !ValidEnvPrefix(envPrefix) {
619 return nil, fmt.Errorf("mail: invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$", envPrefix)
620 }
621 if !strings.Contains(from, "@") {
622 return nil, fmt.Errorf("mail: From %q is not an address", from)
623 }
624 // Checked here, before anything is spent: From is delivered verbatim as
625 // HARBOURMAIL_SMTP_FROM, and an unrenderable byte caught only inside
626 // writeSMTPEnv would fail AFTER MintKey has already run — stranding a
627 // key on every retry. The From address is not a secret, so it is safe
628 // to name in the error.
629 if err := appconfig.ValidVarValue(from); err != nil {
630 return nil, fmt.Errorf("mail: From %q is not EnvironmentFile-safe: %w", from, err)
631 }
632 rec, etag, err := Get(ctx, s, acct, app)
633 if err != nil {
634 return nil, err
635 }
636 if rec == nil {
637 rec = &Record{}
638 }
639 // The env prefix is LOCKED once delivery has happened: moving it would
640 // leave the old variables standing in every instance's config, and the
641 // app would keep reading the stale pair.
642 if rec.Declared != nil && rec.Provisioned != nil && rec.Declared.EnvPrefix != envPrefix {
643 return nil, fmt.Errorf("mail: env prefix is %q and cannot move after delivery",
644 rec.Declared.EnvPrefix)
645 }
646 rec.Declared = &Declared{EnvPrefix: envPrefix, From: from, At: now, By: actor}
647 if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
648 return nil, err
649 }
650 return rec, nil
651}
652
653// grantedDomains is every domain in the account whose record names this app
654// and which has at least one VERIFIED region.
655func grantedDomains(ctx context.Context, s release.Store, acct, app string) ([]*DomainRecord, error) {
656 // Enabled: a record that cannot be read must not silently drop a domain
657 // from a policy about to be written wholesale (a shrink). The preflight
658 // already read strictly; this keeps the read itself honest.
659 list := ListDomains
660 if _, enabled := s.(ClaimMailStore); enabled {
661 list = StrictListDomains
662 }
663 all, err := list(ctx, s, acct)
664 if err != nil {
665 return nil, err
666 }
667 var out []*DomainRecord
668 for _, d := range all {
669 if len(d.VerifiedARNs()) == 0 {
670 continue
671 }
672 for _, a := range d.Apps {
673 if a == app {
674 out = append(out, d)
675 break
676 }
677 }
678 }
679 return out, nil
680}
681
682// EnvDelivered reports which delivered variables are NOT present, of the
683// right kind, in the app's config. It is the walk's idempotence check and
684// the console's "what is missing" answer, and it exists because "the record
685// says provisioned" is not the same claim as "the app can actually send".
686//
687// wantKeyID is the access key id delivery is supposed to be carrying in
688// _USER — normally rec.Provisioned.KeyID. Pass "" to skip the value check
689// (there is no expected key yet). _USER's VALUE is compared, not just its
690// kind, because _USER is delivered unsealed (writeSMTPEnv) and is therefore
691// the one credential half this function can cheaply verify end to end: a
692// failed final Put after a successful mint+deliver would otherwise leave
693// the app running on a key no record names, reading as "delivered"
694// regardless — a disagreement that is permanent, because nothing else ever
695// re-checks it. _PASS is sealed, so its value cannot be compared this way;
696// Kind is the best this function can do for it.
697//
698// wantFrom is the From address delivery is supposed to be carrying in
699// _FROM — normally rec.Declared.From. Pass "" to skip the value check, the
700// same convention wantKeyID uses. _FROM's VALUE is compared for the
701// identical reason _USER's is (I1, review follow-up): Declare freely
702// overwrites Declared.From at any time (only the env PREFIX is locked once
703// delivered), and before this check nothing ever re-verified that a
704// changed From actually reached delivery — a re-declare followed by a
705// re-enable left the record and the delivered value disagreeing
706// permanently, the same failure class the _USER check exists to catch,
707// just for the one delivered variable it had not been applied to yet.
708func EnvDelivered(ctx context.Context, s release.Store, acct, app, prefix, wantKeyID, wantFrom string) ([]string, error) {
709 man, err := appconfig.Get(ctx, s, acct, app)
710 if err != nil {
711 return nil, err
712 }
713 host, port, user, pass, from := EnvNames(prefix)
714 var missing []string
715 check := func(name, wantKind string) {
716 v, ok := man.Vars[name]
717 if !ok || v.Kind != wantKind {
718 missing = append(missing, name)
719 }
720 }
721 check(host, "env")
722 check(port, "env")
723 check(pass, "secret")
724 if v, ok := man.Vars[user]; !ok || v.Kind != "env" || (wantKeyID != "" && v.Value != wantKeyID) {
725 missing = append(missing, user)
726 }
727 if v, ok := man.Vars[from]; !ok || v.Kind != "env" || (wantFrom != "" && v.Value != wantFrom) {
728 missing = append(missing, from)
729 }
730 sort.Strings(missing)
731 return missing, nil
732}
733
734// Enable provisions the declared app's credential and delivers it.
735//
736// Idempotent, but NOT all-or-nothing about it (C2, review follow-up): the
737// IAM user's policy is rewritten from the CURRENT granted domain set on
738// EVERY call, wholesale, cheaply — that is what makes attaching (or
739// losing) a domain take effect on a plain re-`enable` with no rotation.
740// Only the expensive half — mint a fresh key and re-deliver config — is
741// skipped when a healthy provisioned app makes it a no-op, because minting
742// on every pass would burn IAM's two-key ceiling and churn every
743// instance's config. "Healthy" means the record says provisioned AND every
744// delivered variable is present, of the right kind AND value where one is
745// checked; anything less and the rest of the walk runs too, because a
746// missing or stale variable means the app cannot send what the record
747// promises.
748func claimUnserializedEnable(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey,
749 d Deployment, acct, app string, now time.Time) (*Record, error) {
750
751 rec, etag, err := Get(ctx, s, acct, app)
752 if err != nil {
753 return nil, err
754 }
755 if rec == nil || rec.Declared == nil {
756 return nil, ErrNotDeclared
757 }
758
759 domains, err := grantedDomains(ctx, s, acct, app)
760 if err != nil {
761 return nil, err
762 }
763 if len(domains) == 0 {
764 return nil, ErrNoDomains
765 }
766 var arns, configSets, names []string
767 for _, dom := range domains {
768 arns = append(arns, dom.VerifiedARNs()...)
769 configSets = append(configSets, dom.VerifiedConfigSetARNs()...)
770 names = append(names, dom.Domain)
771 }
772 sort.Strings(arns)
773 sort.Strings(configSets)
774 sort.Strings(names)
775
776 // The From address must be at a granted domain, or the policy's
777 // ses:FromAddress condition refuses every send at RUNTIME — which
778 // presents as "email silently does not work" instead of as this refusal.
779 at := strings.LastIndex(rec.Declared.From, "@")
780 fromDomain := rec.Declared.From[at+1:]
781 if !containsString(names, fromDomain) {
782 return nil, fmt.Errorf("mail: From %q is not at any verified domain (%s)",
783 rec.Declared.From, strings.Join(names, ", "))
784 }
785
786 // C2 (review follow-up): EnsureUser runs UNCONDITIONALLY, before the
787 // idempotence short-circuit below — it is no longer gated behind it.
788 // EnsureUser is the ONLY place the delivered credential's policy is
789 // written (PutUserPolicy overwrites wholesale, spec §4), and arns/names
790 // above are recomputed from the CURRENT granted domain set on every
791 // call. Before this fix, the short-circuit returned before EnsureUser
792 // ever ran again: adding and verifying a second domain, then
793 // re-enabling, left the policy naming only the first domain forever —
794 // spec §4 promises the opposite ("attaching a custom domain adds an ARN
795 // and rewrites the policy without rotating a credential") — and the
796 // symmetric shrink (a domain leaving the granted set) left a departed
797 // ARN standing in the policy just as permanently. EnsureUser is cheap
798 // to re-run (idempotent CreateUser, one PutUserPolicy) — it is the reap
799 // and mint below that spend IAM's two-key ceiling, and THOSE are what
800 // the short-circuit still has to gate.
801 user := UserName(acct, app)
802 arn, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names)
803 if err != nil {
804 return nil, fmt.Errorf("mail: ensure user %s: %w", user, err)
805 }
806 // The reverse index, right after EnsureUser and before MintKey (design
807 // §5). EnsureUser runs on every enable, so a re-enable converges the
808 // index; putting it after MintKey would strand a sender forever when a
809 // crash landed between the two.
810 if err := PutCallerIndex(ctx, s, user, acct, app, SenderAppID, now); err != nil {
811 return nil, fmt.Errorf("mail: index caller %s: %w", user, err)
812 }
813
814 // Idempotence short-circuit: governs ONLY the expensive mint-and-deliver
815 // half from here down.
816 if rec.Provisioned != nil {
817 missing, err := EnvDelivered(ctx, s, acct, app, rec.Declared.EnvPrefix, rec.Provisioned.KeyID, rec.Declared.From)
818 if err != nil {
819 return nil, err
820 }
821 if len(missing) == 0 {
822 return rec, nil
823 }
824 }
825
826 // Active and PauseKind are carried forward, not reset. A re-walk that
827 // repairs missing delivery for a paused app is legitimate; silently
828 // un-pausing it is not — Active only ever moves from false to true
829 // through the pause verb itself (Task 13), never as a side effect of
830 // this walk running. PauseKind must travel with Active: dropping it
831 // here would turn a CAP pause into a kind-less one on the very next
832 // repair walk, which enforceMailLimits and the status-read auto-resume
833 // (R2) never resume, and which the CLI describes as needing an
834 // operator — resurrecting the exact defect PauseKind was added to fix,
835 // through this door instead of SetPaused. A first-time enable (no
836 // prior Provisioned block) starts active with no pause kind.
837 active, prev, pauseKind := true, "", ""
838 if rec.Provisioned != nil {
839 prev = rec.Provisioned.PrevKeyID
840 active = rec.Provisioned.Active
841 pauseKind = rec.Provisioned.PauseKind
842 }
843
844 // Reap before minting: reaching here means delivery is missing, so any
845 // current key is either undelivered (its secret died with a failed
846 // config write) or stranded by a crash between mint and record. No
847 // record names either; only list-and-delete finds them. A pending-finish
848 // key from an unfinished rotation is kept for its explicit finish.
849 var keep []string
850 if prev != "" {
851 keep = append(keep, prev)
852 }
853 if err := p.ReapKeys(ctx, user, keep); err != nil {
854 return nil, fmt.Errorf("mail: reap keys for %s: %w", user, err)
855 }
856 keyID, secret, err := p.MintKey(ctx, user)
857 if err != nil {
858 return nil, fmt.Errorf("mail: mint key for %s: %w", user, err)
859 }
860 // I1: same defect as C1, reached through the repair path — a re-walk
861 // that repairs missing delivery for a PAUSED app must not silently
862 // lift the pause at IAM just because it reaps and re-mints.
863 if err := enforceKeyActive(ctx, p, user, keyID, active); err != nil {
864 return nil, err
865 }
866
867 if err := writeSMTPEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix,
868 d.Region, keyID, secret, rec.Declared.From); err != nil {
869 return nil, err
870 }
871
872 rec.Provisioned = &Provisioned{
873 IAMUser: user, UserARN: arn, KeyID: keyID, PrevKeyID: prev, Active: active, PauseKind: pauseKind, At: now,
874 }
875 if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
876 return nil, err
877 }
878 return rec, nil
879}
880
881func containsString(xs []string, want string) bool {
882 for _, x := range xs {
883 if x == want {
884 return true
885 }
886 }
887 return false
888}
889
890// ApplyGrant records who authorised this app to send and at what cap.
891//
892// A TIER-sourced grant never overwrites an OPERATOR-sourced one. That is the
893// entire reason Source exists: an operator raising a cap is a decision, and
894// a nightly tier pass that flattened it back would revoke that decision
895// silently, days later, with nothing to point at.
896func claimUnserializedApplyGrant(ctx context.Context, s release.Store, acct, app string,
897 domains []string, dailyCap int, source, actor string, now time.Time) (*Record, error) {
898
899 rec, etag, err := Get(ctx, s, acct, app)
900 if err != nil {
901 return nil, err
902 }
903 if rec == nil || rec.Declared == nil {
904 return nil, ErrNotDeclared
905 }
906 if rec.Grant != nil && rec.Grant.Source == "operator" && source != "operator" {
907 return rec, nil
908 }
909 sort.Strings(domains)
910 rec.Grant = &GrantBlock{
911 Domains: domains, DailyCap: dailyCap, Source: source, At: now, By: actor,
912 }
913 if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
914 return nil, err
915 }
916 return rec, nil
917}
918
919// writeSMTPEnv delivers the five variables. The DERIVED password goes out,
920// never the IAM secret — delivering the secret would authenticate against
921// nothing and would put a live AWS credential into an app's environment.
922//
923// _USER (the access key id) is delivered as a plain "env" var, NOT sealed —
924// deliberately, reversing an earlier design here. It protects nothing:
925// rec.Provisioned.KeyID already sits in plaintext in email.json, in the
926// same bucket as config.json, so sealing it hides the value from nobody who
927// can't already read it there. Leaving it readable buys something real:
928// EnvDelivered can compare the delivered value against
929// rec.Provisioned.KeyID and catch the one failure mode that would
930// otherwise be permanent — a failed final Put after a successful
931// mint+deliver leaves the app running on a key no record names, the pause
932// aimed at a dead key, and the next rotation hitting IAM's two-key ceiling.
933// _PASS remains sealed — it IS the secret.
934func writeSMTPEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey,
935 acct, app, prefix, region, keyID, secret, from string) error {
936
937 hostName, portName, userName, passName, fromName := EnvNames(prefix)
938 password := SMTPPassword(secret, region)
939
940 // Validate BEFORE sealing/delivering. A sealed value reaches appconfig
941 // as an opaque envelope, so an unrenderable byte would sail through and
942 // stall the app's whole config render on every host, hours later and
943 // out of sight. The errors name the VARIABLE and never the value —
944 // these strings reach logs and HTTP bodies, and one half is a live
945 // credential.
946 if err := appconfig.ValidVarValue(keyID); err != nil {
947 return fmt.Errorf("mail: minted credential for %s is not EnvironmentFile-safe: %w", userName, err)
948 }
949 if err := appconfig.ValidVarValue(password); err != nil {
950 return fmt.Errorf("mail: minted credential for %s is not EnvironmentFile-safe: %w", passName, err)
951 }
952 env, err := appconfig.Seal(sealPub, appconfig.Binding{Account: acct, App: app}, []byte(password))
953 if err != nil {
954 return fmt.Errorf("mail: seal %s: %w", passName, err)
955 }
956
957 sets := map[string]appconfig.Var{
958 hostName: {Kind: "env", Value: SMTPHost(region)},
959 portName: {Kind: "env", Value: SMTPPort},
960 fromName: {Kind: "env", Value: from},
961 userName: {Kind: "env", Value: keyID},
962 passName: {Kind: "secret", Envelope: env},
963 }
964 if _, err := appconfig.Apply(ctx, s, acct, app, sets, nil); err != nil {
965 return fmt.Errorf("mail: deliver smtp config for %s/%s: %w", acct, app, err)
966 }
967 return nil
968}
969
970// StandaloneCreds is a displayed credential, returned ONCE. Nothing stores
971// the password: it is derived here and handed straight back.
972type StandaloneCreds struct {
973 Host, Port, Username, Password, From string
974}
975
976// enforceKeyActive sets a freshly minted key's IAM active state to match the
977// record's OWN Active flag. It is the single enforcement point every
978// mint-and-deliver path — Enable, Rotate, AND CreateStandalone — must call.
979//
980// MintKey always returns an ACTIVE key. Skipping this after a mint silently
981// lifts a standing pause at IAM even though the record keeps saying paused —
982// the console keeps showing paused, the audit log keeps showing the pause,
983// and the app is sending again. The call is UNCONDITIONAL, not gated behind
984// `if !active`: unconditional self-corrects in both directions (an active
985// record's freshly minted key is confirmed active, not just left alone) and
986// cannot drift out of sync with the record.
987func enforceKeyActive(ctx context.Context, p Provisioner, user, keyID string, active bool) error {
988 if err := p.SetKeyActive(ctx, user, keyID, active); err != nil {
989 return fmt.Errorf("mail: set key active for %s/%s: %w", user, keyID, err)
990 }
991 return nil
992}
993
994// Rotate mints a fresh key, delivers it, and leaves the OLD key ACTIVE.
995//
996// The overlap is the whole point. Adoption swaps a symlink; it does not
997// restart processes, so an instance may still be authenticating with the old
998// credential for minutes after delivery. Killing it at rotation time is an
999// outage with no upside. RotateFinish closes the window explicitly.
1000//
1001// The overlap consumes IAM's ceiling of two access keys per user, which is
1002// exactly why standalone credentials get their own principal.
1003func claimUnserializedRotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey,
1004 d Deployment, acct, app string, now time.Time) (*Record, error) {
1005
1006 rec, etag, err := Get(ctx, s, acct, app)
1007 if err != nil {
1008 return nil, err
1009 }
1010 if rec == nil || rec.Declared == nil {
1011 return nil, ErrNotDeclared
1012 }
1013 if rec.Provisioned == nil {
1014 return nil, ErrNotProvisioned
1015 }
1016 user := rec.Provisioned.IAMUser
1017
1018 // A pending key from an UNFINISHED earlier rotation goes first —
1019 // rotating twice without finishing is ordinary member behaviour, and
1020 // both slots cannot hold three keys.
1021 if rec.Provisioned.PrevKeyID != "" {
1022 if err := p.DeleteKey(ctx, user, rec.Provisioned.PrevKeyID); err != nil {
1023 return nil, err
1024 }
1025 }
1026 // Then reap anything the record does not name — a key stranded by a
1027 // crash between mint and record is nameable only by listing.
1028 if err := p.ReapKeys(ctx, user, []string{rec.Provisioned.KeyID}); err != nil {
1029 return nil, err
1030 }
1031 keyID, secret, err := p.MintKey(ctx, user)
1032 if err != nil {
1033 return nil, fmt.Errorf("mail: mint key for %s: %w", user, err)
1034 }
1035 // C1: a rotate against a PAUSED app must not silently lift the pause at
1036 // IAM. MintKey returns an active key regardless; this brings it back in
1037 // line with what the record already says.
1038 if err := enforceKeyActive(ctx, p, user, keyID, rec.Provisioned.Active); err != nil {
1039 return nil, err
1040 }
1041 if err := writeSMTPEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix,
1042 d.Region, keyID, secret, rec.Declared.From); err != nil {
1043 return nil, err
1044 }
1045 rec.Provisioned.PrevKeyID = rec.Provisioned.KeyID
1046 rec.Provisioned.KeyID = keyID
1047 rec.Provisioned.At = now
1048 if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
1049 return nil, err
1050 }
1051 return rec, nil
1052}
1053
1054// RotateFinish closes the two-key window. This is the only walk that
1055// destroys a credential an app might still be using, which is why it is a
1056// separate, explicit verb rather than the tail of Rotate.
1057func claimUnserializedRotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app string) (*Record, error) {
1058 rec, etag, err := Get(ctx, s, acct, app)
1059 if err != nil {
1060 return nil, err
1061 }
1062 if rec == nil || rec.Provisioned == nil {
1063 return nil, ErrNotProvisioned
1064 }
1065 if rec.Provisioned.PrevKeyID == "" {
1066 return rec, nil // nothing to finish; idempotent
1067 }
1068 if err := p.DeleteKey(ctx, rec.Provisioned.IAMUser, rec.Provisioned.PrevKeyID); err != nil {
1069 return nil, err
1070 }
1071 rec.Provisioned.PrevKeyID = ""
1072 if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
1073 return nil, err
1074 }
1075 return rec, nil
1076}
1077
1078// ErrPerInstance refuses a resume aimed at an app sender whose app has moved
1079// to per-instance senders (design section 6.3). Nothing sets PerInstance in this
1080// release; the refusal exists so that nothing can set it later without this
1081// guard already being in place and tested.
1082var ErrPerInstance = errors.New("mail: the app sender is in per-instance mode")
1083
1084// SetPaused stops or resumes the APP sender, which is every sender an app
1085// has until instance senders exist. Its signature is unchanged so the
1086// operator door and the limiter keep calling it exactly as they did.
1087func claimUnserializedSetPaused(ctx context.Context, s release.Store, p Provisioner,
1088 acct, app string, paused bool, kind string, now time.Time) (*Record, error) {
1089
1090 sn, err := SetSenderPaused(ctx, s, p, acct, app, SenderAppID, paused, kind, now)
1091 if err != nil {
1092 return nil, err
1093 }
1094 return sn.record(app), nil
1095}
1096
1097// SetSenderPaused toggles one sender's keys, never its policy. A policy
1098// rewrite would have to be undone correctly to resume, and a half-failed
1099// rewrite leaves a grant that is neither the old one nor the new one;
1100// deactivating a key is atomic at IAM and trivially reversible.
1101//
1102// BOTH of the delivered user's keys are toggled, because an app
1103// mid-rotation still holds the previous credential, and every standalone
1104// credential of this sender is toggled too — each is its own IAM user, so
1105// there is no two-key ceiling interaction, and skipping them would leave a
1106// tenant paused for abuse still sending through a laptop credential.
1107//
1108// kind records WHICH automatic pause this is (mail.PauseKindCap or
1109// mail.PauseKindRate) so mailcounts.go's enforceMailLimits can tell a
1110// per-day cap pause — which it auto-resumes on the UTC day roll — from a
1111// rate pause or a manual one, which it never does (I3, review follow-up).
1112// Pass "" for a manual pause (the operator door) and for every resume
1113// (paused == false): PauseKind is always cleared back to "" on resume,
1114// whatever resumed it, so a record never carries a stale kind from a pause
1115// that is already lifted.
1116//
1117// A resume is refused while PerInstance is set: the app's instances still
1118// hold this credential, and re-arming it is exactly what per-instance mode
1119// exists to prevent. A pause is always allowed.
1120func claimUnserializedSetSenderPaused(ctx context.Context, s release.Store, p Provisioner,
1121 acct, app, sender string, paused bool, kind string, now time.Time) (*Sender, error) {
1122
1123 sn, etag, err := GetSender(ctx, s, acct, app, sender)
1124 if err != nil {
1125 return nil, err
1126 }
1127 if sn == nil || sn.Provisioned == nil {
1128 return nil, ErrNotProvisioned
1129 }
1130 if !paused && sn.PerInstance {
1131 return nil, fmt.Errorf("mail: %s/%s: resume its instance senders instead: %w", acct, app, ErrPerInstance)
1132 }
1133 user := sn.Provisioned.IAMUser
1134 for _, id := range []string{sn.Provisioned.KeyID, sn.Provisioned.PrevKeyID} {
1135 if id == "" {
1136 continue
1137 }
1138 if err := p.SetKeyActive(ctx, user, id, !paused); err != nil {
1139 return nil, err
1140 }
1141 }
1142 for _, sa := range sn.Standalone {
1143 if err := p.SetKeyActive(ctx, sa.IAMUser, sa.KeyID, !paused); err != nil {
1144 return nil, err
1145 }
1146 }
1147 sn.Provisioned.Active = !paused
1148 if paused {
1149 sn.Provisioned.PauseKind = kind
1150 } else {
1151 sn.Provisioned.PauseKind = ""
1152 }
1153 sn.Provisioned.At = now
1154 if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil {
1155 return nil, err
1156 }
1157 return sn, nil
1158}
1159
1160// CreateStandalone mints a displayed credential on its OWN IAM principal.
1161func claimUnserializedCreateStandalone(ctx context.Context, s release.Store, p Provisioner, d Deployment,
1162 acct, app, name, actor string, now time.Time) (StandaloneCreds, error) {
1163
1164 // A credential name is not an env prefix — this is just
1165 // validStandaloneName, nothing else (controller ruling F5).
1166 if !validStandaloneName(name) {
1167 return StandaloneCreds{}, fmt.Errorf("mail: invalid credential name %q", name)
1168 }
1169 rec, etag, err := Get(ctx, s, acct, app)
1170 if err != nil {
1171 return StandaloneCreds{}, err
1172 }
1173 if rec == nil || rec.Declared == nil {
1174 return StandaloneCreds{}, ErrNotDeclared
1175 }
1176 domains, err := grantedDomains(ctx, s, acct, app)
1177 if err != nil {
1178 return StandaloneCreds{}, err
1179 }
1180 if len(domains) == 0 {
1181 return StandaloneCreds{}, ErrNoDomains
1182 }
1183 var arns, configSets, names []string
1184 for _, dom := range domains {
1185 arns = append(arns, dom.VerifiedARNs()...)
1186 configSets = append(configSets, dom.VerifiedConfigSetARNs()...)
1187 names = append(names, dom.Domain)
1188 }
1189 sort.Strings(arns)
1190 sort.Strings(configSets)
1191 sort.Strings(names)
1192
1193 senderNames, err := SenderNames(acct, app, SenderAppID, "")
1194 if err != nil {
1195 return StandaloneCreds{}, err
1196 }
1197 user, err := senderNames.StandaloneUser(acct, app, name)
1198 if err != nil {
1199 return StandaloneCreds{}, err
1200 }
1201 if _, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names); err != nil {
1202 return StandaloneCreds{}, err
1203 }
1204 // A standalone credential sends for the app sender, so its caller name
1205 // resolves to the same sender the delivered credential does.
1206 if err := PutCallerIndex(ctx, s, user, acct, app, SenderAppID, now); err != nil {
1207 return StandaloneCreds{}, fmt.Errorf("mail: index caller %s: %w", user, err)
1208 }
1209 // A re-mint for the same name replaces the credential rather than
1210 // stacking one: two slots is still the ceiling here too.
1211 if err := p.ReapKeys(ctx, user, nil); err != nil {
1212 return StandaloneCreds{}, err
1213 }
1214 keyID, secret, err := p.MintKey(ctx, user)
1215 if err != nil {
1216 return StandaloneCreds{}, err
1217 }
1218 // A THIRD mint path, and enforceKeyActive's contract applies here just
1219 // as it does to Enable and Rotate: minting a standalone credential for a
1220 // PAUSED app must not hand back a live key just because the mint went
1221 // through a different member verb. An app with no Provisioned block yet
1222 // (declared and granted, never enabled) has nothing to be paused, so it
1223 // defaults active.
1224 active := true
1225 if rec.Provisioned != nil {
1226 active = rec.Provisioned.Active
1227 }
1228 if err := enforceKeyActive(ctx, p, user, keyID, active); err != nil {
1229 return StandaloneCreds{}, err
1230 }
1231
1232 replaced := false
1233 for _, sa := range rec.Standalone {
1234 if sa.Name == name {
1235 sa.IAMUser, sa.KeyID, sa.At, sa.By = user, keyID, now, actor
1236 replaced = true
1237 }
1238 }
1239 if !replaced {
1240 rec.Standalone = append(rec.Standalone,
1241 &Standalone{Name: name, IAMUser: user, KeyID: keyID, At: now, By: actor})
1242 }
1243 if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
1244 return StandaloneCreds{}, err
1245 }
1246 return StandaloneCreds{
1247 Host: SMTPHost(d.Region), Port: SMTPPort,
1248 Username: keyID, Password: SMTPPassword(secret, d.Region),
1249 From: rec.Declared.From,
1250 }, nil
1251}
1252
1253var standaloneNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,30}$`)
1254
1255func validStandaloneName(n string) bool { return standaloneNameRe.MatchString(n) }
1256
1257// ValidStandaloneName is validStandaloneName for callers outside the package.
1258func ValidStandaloneName(n string) bool { return validStandaloneName(n) }
1259
1260// RevokeStandalone deletes one displayed credential and its principal. It
1261// cannot disturb delivery, which is the point of the separate user.
1262func claimUnserializedRevokeStandalone(ctx context.Context, s release.Store, p Provisioner, acct, app, name string) error {
1263 rec, etag, err := Get(ctx, s, acct, app)
1264 if err != nil {
1265 return err
1266 }
1267 if rec == nil {
1268 return nil
1269 }
1270 kept := rec.Standalone[:0]
1271 found := false
1272 for _, sa := range rec.Standalone {
1273 if sa.Name == name {
1274 found = true
1275 if err := DeleteCallerIndex(ctx, s, sa.IAMUser, acct, app, SenderAppID); err != nil {
1276 return err
1277 }
1278 if err := p.DeleteUser(ctx, sa.IAMUser); err != nil {
1279 return err
1280 }
1281 continue
1282 }
1283 kept = append(kept, sa)
1284 }
1285 if !found {
1286 return nil
1287 }
1288 rec.Standalone = kept
1289 _, err = Put(ctx, s, acct, app, rec, etag)
1290 return err
1291}
1292
1293// domainDetach says what detachDomainApp did to one domain record.
1294type domainDetach int
1295
1296const (
1297 // detachNone decided nothing. It is only ever paired with a non-nil
1298 // error, so a caller that checks the error first never sees it.
1299 detachNone domainDetach = iota
1300 // detachNotAReferrer: this app never referred to the domain, or had
1301 // already stopped. Nothing was written and nothing was destroyed.
1302 detachNotAReferrer
1303 // detachKept: other apps still refer to the domain, so the record was
1304 // rewritten without this app and the shared estate was left alone.
1305 detachKept
1306 // detachTornDown: this app was the LAST referrer, so the published DNS
1307 // (platform domains only), the SES identity, the global claim and the
1308 // account's own domain record are all gone.
1309 detachTornDown
1310)
1311
1312// detachDomainApp stops one app referring to one domain, and — only when
1313// that leaves NO app referring to it — dismantles the domain.
1314//
1315// It is the body of Teardown's per-domain loop, lifted out so `carlos email
1316// domains rm` (RemoveDomain) executes the identical sequence rather than a
1317// second copy of it. The order below is load-bearing in three separate
1318// places, each documented where it happens; two copies of it would be two
1319// chances to get one of them subtly wrong.
1320//
1321// cur must be a FRESH read (with its etag), not a ListDomains snapshot —
1322// see Teardown's own comment at the call site for the two reasons.
1323//
1324// The orphaned record set comes back rather than being deleted for a CUSTOM
1325// domain: those records live in the customer's zone and are the customer's
1326// to remove. Returning them is what lets a door tell an operator exactly
1327// which names are now stale.
1328func detachDomainApp(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer,
1329 d Deployment, acct, app string, cur *DomainRecord, curEtag string) (domainDetach, []dnsrec.Record, error) {
1330
1331 if !cur.RemoveApp(app) {
1332 return detachNotAReferrer, nil, nil
1333 }
1334 if len(cur.Apps) > 0 {
1335 // A sibling app still sends from this domain. Its identity, its
1336 // DKIM tokens, its DNS and the global claim ALL stay exactly as
1337 // they are — getting this backwards revokes a sibling app's
1338 // verified domain silently, with the customer's own DNS still
1339 // pointing at an identity that no longer exists.
1340 if _, err := PutDomain(ctx, s, acct, cur, curEtag); err != nil {
1341 return detachNone, nil, err
1342 }
1343 return detachKept, nil, nil
1344 }
1345 // Last referrer: remove the identity and, for a platform domain, the
1346 // records CARLOS published. A custom domain's records are the
1347 // customer's to remove, so they come back to the caller instead.
1348 //
1349 // Except where the identity is not this account's to remove (#216): one
1350 // it adopted with proof, or one whose claim lapsed and now belongs to
1351 // another account. Those keep their identity and their DNS; only this
1352 // account's tenant association goes, so the owner can still delete the
1353 // identity later.
1354 //
1355 // The identity itself is deleted only when the claim says this
1356 // account's walk CREATED it, the same rule the orphan sweeps use. A
1357 // record written before that stamp existed may have adopted somebody
1358 // else's identity (the #216 attack itself, or an operator domain
1359 // shared with another product), and nothing on it says which; such an
1360 // identity is left in SES. Leaking it is the safe direction.
1361 claim, err := getDomainClaim(ctx, s, cur.Domain)
1362 if err != nil {
1363 return detachNone, nil, err
1364 }
1365 ours := claim == nil || claim.Account == acct
1366 var orphaned []dnsrec.Record
1367 for region, st := range cur.Regions {
1368 if !ours || st.Adopted {
1369 if st.IdentityARN != "" {
1370 if err := p.DisassociateTenantResource(ctx, TenantName(acct), st.IdentityARN); err != nil {
1371 log.Printf("mail: disassociating tenant resource for %s/%s: %v", acct, cur.Domain, err)
1372 }
1373 }
1374 continue
1375 }
1376 records := RecordSet(cur.Domain, region, st.DKIMTokens, d.DMARCRua)
1377 // Same w != nil guard as EnsureDomain's publish branch, for the
1378 // identical reason: a caller with no DNSWriter configured must not
1379 // panic on a nil interface here either. A platform domain with no
1380 // writer degrades to "orphaned" exactly like a custom one — the
1381 // records exist somewhere this process cannot reach, which is the
1382 // honest thing to report.
1383 if cur.Kind == KindPlatform && w != nil {
1384 for _, r := range records {
1385 if err := w.DeleteRecord(ctx, r); err != nil {
1386 return detachNone, nil, err
1387 }
1388 }
1389 } else {
1390 orphaned = append(orphaned, records...)
1391 }
1392 // SES refuses to delete an identity while a tenant association
1393 // exists, so the association is attempted first. The account's
1394 // configuration set is NOT disassociated: it is per account and
1395 // outlives any one app.
1396 //
1397 // A failed disassociate is logged and NOT a hard error (I2, review
1398 // follow-up, reversed from that task's first cut). The control-plane
1399 // role needs ses:DeleteTenantResourceAssociation (spec §4.2) and
1400 // that grant does not live in this repo; if it is missing,
1401 // AccessDenied is neither AlreadyExists nor NotFound, so a
1402 // `return err` here would fail EVERY release where this app is the
1403 // last referrer of a domain, and — worse — leave releaseDomainClaim
1404 // and s.Delete below unreached, which strands the domain's global
1405 // claim: nobody, including this domain's own owner, could ever claim
1406 // it again until the grant landed. That is the exact permanent
1407 // stranding `carlos email domains rm` exists to end, so this door
1408 // must not reintroduce it. DeleteIdentity is the real protection SES
1409 // itself provides (confirmed against the live API, spec §4.1: it
1410 // refuses to delete an identity that still has a tenant
1411 // association), so it stays a hard failure below and does the job
1412 // this disassociate call cannot be trusted to gate.
1413 if st.IdentityARN != "" {
1414 if err := p.DisassociateTenantResource(ctx, TenantName(acct), st.IdentityARN); err != nil {
1415 log.Printf("mail: disassociating tenant resource for %s/%s: %v", acct, cur.Domain, err)
1416 }
1417 }
1418 if claim != nil && claim.createdIdentityIn(region) {
1419 if err := p.DeleteIdentity(ctx, region, cur.Domain); err != nil {
1420 return detachNone, nil, err
1421 }
1422 }
1423 }
1424 // The global claim (Task 17a, spec §4a) releases HERE, in the same
1425 // branch as deleting the account's own domain record — the exact
1426 // moment the last app referencing this domain has gone. Releasing any
1427 // earlier (e.g. at the top of Teardown, or keyed off "this app is
1428 // going" rather than "the domain record is going") would let a stranger
1429 // claim a domain a sibling app is still sending from — the same class
1430 // of bug the `len(cur.Apps) > 0` branch above guards.
1431 //
1432 // RELEASE BEFORE DELETE (I1, review follow-up), reversed from this
1433 // task's first cut. The shared resource the claim protects is the SES
1434 // identity, and by this point in the branch it is ALREADY destroyed
1435 // (DeleteIdentity, above) — so releasing the claim before the record is
1436 // gone hands nothing live to a winner. The reverse order
1437 // (delete-then-release, the original cut) has a real defect: if the
1438 // process dies between the two, the record is gone, so
1439 // ListDomains(acct) on any later Teardown run never lists this domain
1440 // again — the release call becomes permanently unreachable, and the
1441 // claim is stranded forever, un-reclaimable even by its own owner's
1442 // account under a different app. Releasing FIRST means a crash before
1443 // the record delete just repeats a no-op release (idempotent) and a
1444 // retriable s.Delete on the next run — self-healing again, matching
1445 // every other step here.
1446 if err := releaseDomainClaim(ctx, s, cur.Domain, acct); err != nil {
1447 return detachNone, nil, err
1448 }
1449 if err := s.Delete(ctx, DomainKey(acct, cur.Domain)); err != nil {
1450 return detachNone, nil, err
1451 }
1452 return detachTornDown, orphaned, nil
1453}
1454
1455// Teardown dismantles an app's email estate: credentials, principals, and
1456// its claim on every domain it referenced.
1457//
1458// A domain's SES identity and DNS are removed only when the LAST app
1459// referencing it goes. Getting that backwards revokes a sibling app's
1460// verified domain — silently, and with the customer's own DNS still
1461// pointing at an identity that no longer exists.
1462//
1463// NOT SELF-HEALING across a crash (I5): a crash between the DeleteUser calls
1464// below and the domain loop leaves a zombie — the IAM user is gone, the
1465// record still says Provisioned, and Teardown never unset the app's SMTP
1466// env, so the stale delivered *_SMTP_USER still equals rec.Provisioned.KeyID
1467// and EnvDelivered stays satisfied on that comparison. Enable therefore
1468// short-circuits rather than recreate the user, and Rotate fails at MintKey
1469// because there is no user left to mint against. Nothing re-runs Teardown
1470// automatically on a half-failure; the caller must retry it to completion.
1471// A reconciler that notices and repairs a half-torn-down app is out of
1472// scope here.
1473func claimUnserializedTeardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer,
1474 d Deployment, acct, app string) error {
1475
1476 // Enabled: complete inventories (C6). A record or claim that cannot be
1477 // read must stop the teardown, not be skipped and outlive it. Checked
1478 // before the first effect, and read again where each list is used.
1479 _, strict := s.(ClaimMailStore)
1480 listDomains, listClaims := ListDomains, ListDomainClaims
1481 if strict {
1482 listDomains = StrictListDomains
1483 listClaims = func(ctx context.Context, s release.Store) ([]DomainClaim, error) {
1484 return StrictDomainClaims(ctx, s)
1485 }
1486 if _, err := listDomains(ctx, s, acct); err != nil {
1487 return err
1488 }
1489 if _, err := listClaims(ctx, s); err != nil {
1490 return err
1491 }
1492 }
1493
1494 rec, _, err := Get(ctx, s, acct, app)
1495 if err != nil {
1496 return err
1497 }
1498 if rec != nil {
1499 if rec.Provisioned != nil {
1500 // The index sits outside apps/<acct>/<app>/, so PurgeApp's
1501 // prefix sweep never reaches it. Without this delete a stale
1502 // entry would attribute SES events to a purged app and insert
1503 // counts for it.
1504 if err := DeleteCallerIndex(ctx, s, rec.Provisioned.IAMUser, acct, app, SenderAppID); err != nil {
1505 return err
1506 }
1507 if err := p.DeleteUser(ctx, rec.Provisioned.IAMUser); err != nil {
1508 return err
1509 }
1510 }
1511 for _, sa := range rec.Standalone {
1512 if err := DeleteCallerIndex(ctx, s, sa.IAMUser, acct, app, SenderAppID); err != nil {
1513 return err
1514 }
1515 if err := p.DeleteUser(ctx, sa.IAMUser); err != nil {
1516 return err
1517 }
1518 }
1519 }
1520
1521 // The console's own sending domain (ConsoleMailApp) is never torn down
1522 // with an app: its record, claim, identity and DNS are the console's,
1523 // and an app that reached the reserved name by a move or a migrate is
1524 // not their owner. Its credentials above and its records below still go.
1525 if isConsoleMail(acct, app) {
1526 return deleteMailRecords(ctx, s, acct, app, rec)
1527 }
1528
1529 domains, err := listDomains(ctx, s, acct)
1530 if err != nil {
1531 return err
1532 }
1533 for _, dom := range domains {
1534 // The re-read happens ONCE, before any branch, and every decision
1535 // below is made on the fresh copy (cur), never on the stale one
1536 // (dom) ListDomains returned. Two things depend on that:
1537 //
1538 // - F6: an empty etag is CREATE-ONLY, and every one of these
1539 // records already exists, so a CAS position has to come from a
1540 // fresh read.
1541 // - I4: without a fresh read, the DESTROY branch below had no CAS
1542 // and no re-check at all — s.Delete was unconditional. A sibling
1543 // app joining this domain between the ListDomains call and this
1544 // decision would have its identity, DKIM tokens and (for a
1545 // custom domain) hand-published DNS destroyed out from under it.
1546 // Deciding on cur.Apps closes that window: a join that lands
1547 // before this read is seen and the domain survives instead.
1548 cur, curEtag, err := GetDomain(ctx, s, acct, dom.Domain)
1549 if err != nil {
1550 return err
1551 }
1552 if cur == nil {
1553 continue // gone since the list; nothing to do
1554 }
1555 // Everything from here — the sibling check, the identity and DNS,
1556 // the claim, the record — is detachDomainApp's, shared verbatim
1557 // with RemoveDomain so the two release paths cannot drift on an
1558 // ordering that is load-bearing and subtle. A domain this app never
1559 // referred to is that function's own no-op, which is what the
1560 // `continue` used to be.
1561 if _, _, err := detachDomainApp(ctx, s, p, w, d, acct, app, cur, curEtag); err != nil {
1562 return err
1563 }
1564 }
1565
1566 // Orphaned claims (I1's second, far more ordinary strand): a claim can
1567 // exist with NO backing domain record whenever EnsureDomain fails
1568 // AFTER claimDomain but before PutDomain ever runs — an SES throttle,
1569 // a tenant limit, a bad region, anything. That domain is never listed
1570 // by ListDomains(acct) above (there is no record), so the loop just
1571 // run never sees it, and without this pass it would never be
1572 // reachable by any release verb at all: the member's only recourse is
1573 // Teardown, and Teardown had nothing to walk it from.
1574 //
1575 // SCOPED TO THIS APP (I1 item 1, review round 2), corrected from this
1576 // task's first cut, which swept every claim the ACCOUNT held with no
1577 // record, regardless of which app attempted it. That is wrong: an
1578 // unrelated Teardown(acct, someOtherApp) would sweep up a DIFFERENT
1579 // app's claim that is not abandoned at all — just waiting on an
1580 // ordinary retry. The window where this matters is NOT a concurrency
1581 // race (microseconds between goroutines); it is however long the
1582 // member takes to retry — a quota bump or a support ticket, hours, not
1583 // microseconds — and nothing durable told the sweep "abandoned" from
1584 // "about to be retried" apart from which app the claim belongs to.
1585 // DomainClaim.App now carries that attribution (set once, at claimDomain's
1586 // first write, never rewritten by a later idempotent re-claim), so the
1587 // sweep only ever releases a claim THIS app created.
1588 claims, err := listClaims(ctx, s)
1589 if err != nil {
1590 return err
1591 }
1592 for _, c := range claims {
1593 if c.Account != acct || c.App != app {
1594 continue
1595 }
1596 dr, _, err := GetDomain(ctx, s, acct, c.Domain)
1597 if err != nil {
1598 return err
1599 }
1600 if dr != nil {
1601 continue // a record exists; the domain loop above already handles it
1602 }
1603 // DESTROY THE IDENTITY BEFORE RELEASING (I1 item 2, review round
1604 // 2). If the crash that left this claim orphaned landed AFTER
1605 // EnsureIdentity but BEFORE PutDomain, a live SES identity exists
1606 // with no record naming it — and releasing the claim without
1607 // deleting it hands a stranger that identity's ARN and its
1608 // already-minted DKIM tokens the moment they claim the domain,
1609 // with no DNS proof of their own. EnsureDomain only ever mints an
1610 // identity in d.Region (SMTPHost's own doc comment: one region,
1611 // fleet-wide), so that is the only region this needs to check.
1612 // DeleteIdentity is idempotent — "already gone" is success — so
1613 // this is free when no identity was ever actually created.
1614 //
1615 // Only an identity the claim says this walk CREATED (#216). One it
1616 // found already there was never adopted (EnsureDomain refuses and
1617 // releases such a claim), so it is somebody else's.
1618 if c.createdIdentityIn(d.Region) {
1619 if err := p.DeleteIdentity(ctx, d.Region, c.Domain); err != nil {
1620 return err
1621 }
1622 }
1623 if err := releaseDomainClaim(ctx, s, c.Domain, acct); err != nil {
1624 return err
1625 }
1626 }
1627
1628 return deleteMailRecords(ctx, s, acct, app, rec)
1629}
1630
1631// deleteMailRecords is Teardown's last step: the app's own email records.
1632func deleteMailRecords(ctx context.Context, s release.Store, acct, app string, rec *Record) error {
1633 if rec != nil {
1634 // Both objects: the sender the app is on, and the legacy one the
1635 // migration deliberately leaves standing until the sweep. Leaving
1636 // email.json behind here would resurrect the app's whole email
1637 // record on the next Get.
1638 if err := s.Delete(ctx, Key(acct, app)); err != nil {
1639 return err
1640 }
1641 if err := s.Delete(ctx, LegacyKey(acct, app)); err != nil {
1642 return err
1643 }
1644 }
1645 return nil
1646}
1647
1648// DomainRemoval is what RemoveDomain did, so a door can describe it without
1649// re-reading anything the walk has just deleted.
1650type DomainRemoval struct {
1651 Domain string
1652 Kind string
1653 // TornDown is true when this app was the LAST referrer: the SES
1654 // identity, the account's domain record and the global claim are all
1655 // gone, and the domain is free for any account to claim again.
1656 TornDown bool
1657 // Remaining is the apps that still refer to the domain — non-empty
1658 // exactly when TornDown is false, and the reason nothing else was
1659 // touched.
1660 Remaining []string
1661 // Orphaned is the record set nobody deleted: a custom domain's DNS
1662 // lives in the customer's zone and is the customer's to remove
1663 // (detachDomainApp's rule, which this inherits from Teardown). Empty
1664 // for a platform domain, whose records CARLOS published and has now
1665 // deleted itself.
1666 //
1667 // Only the DKIM CNAMEs in here are token-derived and therefore dead:
1668 // the MX, SPF and DMARC records RecordSet builds are deterministic
1669 // functions of the domain and region, so they stay valid if the domain
1670 // is ever re-created.
1671 Orphaned []dnsrec.Record
1672 // ClaimOnly is the orphaned-claim repair: there was no domain record at
1673 // all — only a global claim stranded by an EnsureDomain that failed
1674 // after claimDomain and before PutDomain — and releasing it is the
1675 // whole of what happened.
1676 ClaimOnly bool
1677}
1678
1679// RemoveDomain is the release door the claim always needed: it stops app
1680// referring to domain and, when that was the last reference, dismantles the
1681// domain and releases its global claim.
1682//
1683// Before this existed, Teardown — the PERMANENT deletion of a whole app —
1684// was the only thing that could ever release a claim, so a domain claimed
1685// by the wrong app was unreachable to every other account, including the
1686// one that actually controls its DNS, for the life of the deployment.
1687//
1688// It shares detachDomainApp with Teardown rather than repeating it, so the
1689// safety properties are the same objects, not the same intentions:
1690//
1691// - a domain a sibling app still refers to keeps its identity, its DNS
1692// and its claim; only the referrer set shrinks;
1693// - the claim is released only after DeleteIdentity, and only before the
1694// record delete (see detachDomainApp for why each of those is the way
1695// round it is);
1696// - a custom domain's DNS is never deleted, it is reported back.
1697//
1698// Two refusals are its own, and neither is Teardown's business:
1699//
1700// - ErrDomainIsFrom, the domain the app currently sends FROM. Enable
1701// refuses a From that is not at a granted domain, so removing it would
1702// turn a working app into one that fails at its next re-enable — a
1703// breakage discovered at runtime instead of here. Move the From first.
1704// - ErrDomainNotReferenced, a domain this app does not use. Teardown
1705// walks an app's whole estate and a non-referrer is simply skipped;
1706// here the operator NAMED this domain, so silence would be a lie.
1707//
1708// OWNER-GATING IS UNCHANGED. Nothing here reads, probes or releases another
1709// account's claim: releaseDomainClaim refuses a claim held by anyone else
1710// as a quiet no-op, the orphan branch below compares the claim's account
1711// itself, and no error message ever names a holder.
1712//
1713// A non-nil *DomainRemoval WITH a non-nil error means one specific thing:
1714// the removal itself landed, and only the departing app's IAM policy shrink
1715// (RefreshPolicies) failed. Re-running this verb would then answer
1716// ErrDomainNotReferenced, correctly — the owed repair is `carlos email
1717// enable`, which rewrites that policy wholesale on every call.
1718func claimUnserializedRemoveDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer,
1719 d Deployment, acct, app, domain string) (*DomainRemoval, error) {
1720
1721 if !release.ValidAccount(acct) {
1722 return nil, fmt.Errorf("mail: invalid account %q", acct)
1723 }
1724 if !release.ValidApp(app) {
1725 return nil, fmt.Errorf("mail: invalid app %q", app)
1726 }
1727 if !ValidDomain(domain) {
1728 return nil, fmt.Errorf("mail: invalid domain %q", domain)
1729 }
1730 // The console's own referrer never releases its domain (see Teardown).
1731 if isConsoleMail(acct, app) {
1732 return nil, fmt.Errorf("mail: %s: %w", domain, ErrConsoleMailReserved)
1733 }
1734
1735 // The From check runs FIRST, before anything is read about the domain
1736 // and long before anything is written: a refusal must cost the caller
1737 // nothing and leave no half-step behind. It looks at THIS app's From
1738 // only — a sibling app keeps its own referral, so its From is still at
1739 // a domain it is granted.
1740 rec, _, err := Get(ctx, s, acct, app)
1741 if err != nil {
1742 return nil, err
1743 }
1744 if rec != nil && rec.Declared != nil {
1745 if at := strings.LastIndex(rec.Declared.From, "@"); at >= 0 && rec.Declared.From[at+1:] == domain {
1746 return nil, fmt.Errorf("mail: %s still sends as %s: %w", app, rec.Declared.From, ErrDomainIsFrom)
1747 }
1748 }
1749
1750 cur, curEtag, err := GetDomain(ctx, s, acct, domain)
1751 if err != nil {
1752 return nil, err
1753 }
1754 if cur == nil {
1755 return removeOrphanedClaim(ctx, s, p, d, acct, domain)
1756 }
1757
1758 res, orphaned, err := detachDomainApp(ctx, s, p, w, d, acct, app, cur, curEtag)
1759 if err != nil {
1760 return nil, err
1761 }
1762 if res == detachNotAReferrer {
1763 return nil, fmt.Errorf("mail: %s does not send from %s: %w", app, domain, ErrDomainNotReferenced)
1764 }
1765 out := &DomainRemoval{Domain: domain, Kind: cur.Kind, TornDown: res == detachTornDown, Orphaned: orphaned}
1766 if res == detachKept {
1767 out.Remaining = append([]string(nil), cur.Apps...)
1768 }
1769 // SHRINK THE DEPARTING APP'S POLICY, the mirror of EnsureDomain's own
1770 // RefreshPolicies call. EnsureUser writes a principal's policy
1771 // wholesale from the CURRENT granted domain set, and nothing else
1772 // recomputes it when a domain LEAVES that set — so without this, an app
1773 // that drops a domain a sibling still keeps alive goes on holding
1774 // ses:SendRawEmail at a live identity ARN it no longer has any claim
1775 // to, indefinitely. That is a real over-grant, not a stale string.
1776 if err := RefreshPolicies(ctx, s, p, acct, []string{app}); err != nil {
1777 return out, err
1778 }
1779 return out, nil
1780}
1781
1782// removeOrphanedClaim is RemoveDomain's branch for a claim with NO backing
1783// domain record — the state EnsureDomain leaves behind when it fails after
1784// claimDomain and before PutDomain (an SES throttle, a tenant limit, a bad
1785// region). Before this, only Teardown could sweep one, and only for the app
1786// that created it; an operator who hit it on an app they did not want to
1787// delete had no recourse at all.
1788//
1789// The app in the path is a MEMBERSHIP GATE here, not an ownership filter —
1790// the same reading handleAPIEmailDomainVerify documents, and appropriate
1791// for the same reason: the claim is account-scoped, not app-scoped, and
1792// unlike Teardown's blind sweep the operator has NAMED this exact domain,
1793// so there is no "a sibling app is merely about to retry" ambiguity for the
1794// App attribution to resolve.
1795//
1796// DESTROY THE IDENTITY BEFORE RELEASING (I1 item 2), exactly as Teardown's
1797// own sweep does: if the crash that orphaned this claim landed after
1798// EnsureIdentity but before PutDomain, a live SES identity exists that no
1799// record names, and releasing without deleting it hands a stranger that
1800// identity's ARN and its already-minted DKIM tokens the moment they claim
1801// the domain. EnsureDomain only ever mints in d.Region, so that is the only
1802// region to check, and DeleteIdentity is idempotent.
1803//
1804// NO DISASSOCIATE HERE, unlike detachDomainApp's region loop, and that is
1805// not an omission. EnsureDomain associates the identity with the account's
1806// tenant only AFTER PutDomain — deliberately, so that a claim with no
1807// record can never have an association behind it (see the ordering comment
1808// at that call). An identity reachable from this branch is therefore always
1809// unassociated, and DeleteIdentity takes it.
1810func removeOrphanedClaim(ctx context.Context, s release.Store, p Provisioner,
1811 d Deployment, acct, domain string) (*DomainRemoval, error) {
1812
1813 claim, err := getDomainClaim(ctx, s, domain)
1814 if err != nil {
1815 return nil, err
1816 }
1817 // A claim held by ANOTHER account reads exactly like no claim at all —
1818 // the same refusal, the same words. Anything else would make this door
1819 // an oracle for who holds a domain the caller does not.
1820 if claim == nil || claim.Account != acct {
1821 return nil, fmt.Errorf("mail: no sending domain %s for this account: %w", domain, ErrDomainNotReferenced)
1822 }
1823 // The console's own claim with no record is an ensure the console will
1824 // retry; releasing it here would delete the identity from under it.
1825 if isConsoleMail(acct, claim.App) {
1826 return nil, fmt.Errorf("mail: %s: %w", domain, ErrConsoleMailReserved)
1827 }
1828 // Only an identity this claim's walk created, as in Teardown's sweep.
1829 if claim.createdIdentityIn(d.Region) {
1830 if err := p.DeleteIdentity(ctx, d.Region, domain); err != nil {
1831 return nil, err
1832 }
1833 }
1834 if err := releaseDomainClaim(ctx, s, domain, acct); err != nil {
1835 return nil, err
1836 }
1837 return &DomainRemoval{
1838 Domain: domain, Kind: DomainKindFor(d, domain), TornDown: true, ClaimOnly: true,
1839 }, nil
1840}
1841
1842func EnsureDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer,
1843 d Deployment, acct, app, domain string, now time.Time) (*DomainRecord, []dnsrec.Record, error) {
1844 if _, enabled := s.(ClaimMailStore); !enabled {
1845 return claimUnserializedEnsureDomain(ctx, s, p, w, d, acct, app, domain, now)
1846 }
1847 var claimResult0 *DomainRecord
1848 var claimResult1 []dnsrec.Record
1849 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
1850 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}, Domain: domain, Refresh: true}, func(ctx context.Context) error {
1851 if p != nil {
1852 p = claimMarkedProvisioner{raw: p}
1853 }
1854 if w != nil {
1855 w = claimMarkedWriter{raw: w}
1856 }
1857 var err error
1858 claimResult0, claimResult1, err = claimUnserializedEnsureDomain(ctx, s, p, w, d, acct, app, domain, now)
1859 return err
1860 })
1861 })
1862 return claimResult0, claimResult1, claimErr
1863}
1864
1865func RefreshPolicies(ctx context.Context, s release.Store, p Provisioner, acct string, apps []string) error {
1866 if _, enabled := s.(ClaimMailStore); !enabled {
1867 return claimUnserializedRefreshPolicies(ctx, s, p, acct, apps)
1868 }
1869 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
1870 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: apps, Refresh: true}, func(ctx context.Context) error {
1871 if p != nil {
1872 p = claimMarkedProvisioner{raw: p}
1873 }
1874 return claimUnserializedRefreshPolicies(ctx, s, p, acct, apps)
1875 })
1876 })
1877 return claimErr
1878}
1879
1880func VerifyDomain(ctx context.Context, s release.Store, p Provisioner, d Deployment,
1881 acct, domain, region string, now time.Time) (*DomainRecord, error) {
1882 if _, enabled := s.(ClaimMailStore); !enabled {
1883 return claimUnserializedVerifyDomain(ctx, s, p, d, acct, domain, region, now)
1884 }
1885 var claimResult0 *DomainRecord
1886 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
1887 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Domain: domain, Refresh: true}, func(ctx context.Context) error {
1888 if p != nil {
1889 p = claimMarkedProvisioner{raw: p}
1890 }
1891 var err error
1892 claimResult0, err = claimUnserializedVerifyDomain(ctx, s, p, d, acct, domain, region, now)
1893 return err
1894 })
1895 })
1896 return claimResult0, claimErr
1897}
1898
1899func Declare(ctx context.Context, s release.Store, acct, app, envPrefix, from, actor string,
1900 now time.Time) (*Record, error) {
1901 if _, enabled := s.(ClaimMailStore); !enabled {
1902 return claimUnserializedDeclare(ctx, s, acct, app, envPrefix, from, actor, now)
1903 }
1904 var claimResult0 *Record
1905 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
1906 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error {
1907 var err error
1908 claimResult0, err = claimUnserializedDeclare(ctx, s, acct, app, envPrefix, from, actor, now)
1909 return err
1910 })
1911 })
1912 return claimResult0, claimErr
1913}
1914
1915func Enable(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey,
1916 d Deployment, acct, app string, now time.Time) (*Record, error) {
1917 if _, enabled := s.(ClaimMailStore); !enabled {
1918 return claimUnserializedEnable(ctx, s, p, sealPub, d, acct, app, now)
1919 }
1920 var claimResult0 *Record
1921 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
1922 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}, Refresh: true}, func(ctx context.Context) error {
1923 if p != nil {
1924 p = claimMarkedProvisioner{raw: p}
1925 }
1926 var err error
1927 claimResult0, err = claimUnserializedEnable(ctx, s, p, sealPub, d, acct, app, now)
1928 return err
1929 })
1930 })
1931 return claimResult0, claimErr
1932}
1933
1934func ApplyGrant(ctx context.Context, s release.Store, acct, app string,
1935 domains []string, dailyCap int, source, actor string, now time.Time) (*Record, error) {
1936 if _, enabled := s.(ClaimMailStore); !enabled {
1937 return claimUnserializedApplyGrant(ctx, s, acct, app, domains, dailyCap, source, actor, now)
1938 }
1939 var claimResult0 *Record
1940 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
1941 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error {
1942 var err error
1943 claimResult0, err = claimUnserializedApplyGrant(ctx, s, acct, app, domains, dailyCap, source, actor, now)
1944 return err
1945 })
1946 })
1947 return claimResult0, claimErr
1948}
1949
1950func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey,
1951 d Deployment, acct, app string, now time.Time) (*Record, error) {
1952 if _, enabled := s.(ClaimMailStore); !enabled {
1953 return claimUnserializedRotate(ctx, s, p, sealPub, d, acct, app, now)
1954 }
1955 var claimResult0 *Record
1956 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
1957 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error {
1958 if p != nil {
1959 p = claimMarkedProvisioner{raw: p}
1960 }
1961 var err error
1962 claimResult0, err = claimUnserializedRotate(ctx, s, p, sealPub, d, acct, app, now)
1963 return err
1964 })
1965 })
1966 return claimResult0, claimErr
1967}
1968
1969func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app string) (*Record, error) {
1970 if _, enabled := s.(ClaimMailStore); !enabled {
1971 return claimUnserializedRotateFinish(ctx, s, p, acct, app)
1972 }
1973 var claimResult0 *Record
1974 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
1975 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error {
1976 if p != nil {
1977 p = claimMarkedProvisioner{raw: p}
1978 }
1979 var err error
1980 claimResult0, err = claimUnserializedRotateFinish(ctx, s, p, acct, app)
1981 return err
1982 })
1983 })
1984 return claimResult0, claimErr
1985}
1986
1987func SetPaused(ctx context.Context, s release.Store, p Provisioner,
1988 acct, app string, paused bool, kind string, now time.Time) (*Record, error) {
1989 if _, enabled := s.(ClaimMailStore); !enabled {
1990 return claimUnserializedSetPaused(ctx, s, p, acct, app, paused, kind, now)
1991 }
1992 var claimResult0 *Record
1993 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
1994 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error {
1995 if p != nil {
1996 p = claimMarkedProvisioner{raw: p}
1997 }
1998 var err error
1999 claimResult0, err = claimUnserializedSetPaused(ctx, s, p, acct, app, paused, kind, now)
2000 return err
2001 })
2002 })
2003 return claimResult0, claimErr
2004}
2005
2006func SetSenderPaused(ctx context.Context, s release.Store, p Provisioner,
2007 acct, app, sender string, paused bool, kind string, now time.Time) (*Sender, error) {
2008 if _, enabled := s.(ClaimMailStore); !enabled {
2009 return claimUnserializedSetSenderPaused(ctx, s, p, acct, app, sender, paused, kind, now)
2010 }
2011 var claimResult0 *Sender
2012 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
2013 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error {
2014 if p != nil {
2015 p = claimMarkedProvisioner{raw: p}
2016 }
2017 var err error
2018 claimResult0, err = claimUnserializedSetSenderPaused(ctx, s, p, acct, app, sender, paused, kind, now)
2019 return err
2020 })
2021 })
2022 return claimResult0, claimErr
2023}
2024
2025func CreateStandalone(ctx context.Context, s release.Store, p Provisioner, d Deployment,
2026 acct, app, name, actor string, now time.Time) (StandaloneCreds, error) {
2027 if _, enabled := s.(ClaimMailStore); !enabled {
2028 return claimUnserializedCreateStandalone(ctx, s, p, d, acct, app, name, actor, now)
2029 }
2030 var claimResult0 StandaloneCreds
2031 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
2032 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}, Refresh: true}, func(ctx context.Context) error {
2033 if p != nil {
2034 p = claimMarkedProvisioner{raw: p}
2035 }
2036 var err error
2037 claimResult0, err = claimUnserializedCreateStandalone(ctx, s, p, d, acct, app, name, actor, now)
2038 return err
2039 })
2040 })
2041 return claimResult0, claimErr
2042}
2043
2044func RevokeStandalone(ctx context.Context, s release.Store, p Provisioner, acct, app, name string) error {
2045 if _, enabled := s.(ClaimMailStore); !enabled {
2046 return claimUnserializedRevokeStandalone(ctx, s, p, acct, app, name)
2047 }
2048 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
2049 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error {
2050 if p != nil {
2051 p = claimMarkedProvisioner{raw: p}
2052 }
2053 return claimUnserializedRevokeStandalone(ctx, s, p, acct, app, name)
2054 })
2055 })
2056 return claimErr
2057}
2058
2059func Teardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer,
2060 d Deployment, acct, app string) error {
2061 if _, enabled := s.(ClaimMailStore); !enabled {
2062 return claimUnserializedTeardown(ctx, s, p, w, d, acct, app)
2063 }
2064 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
2065 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}, AppDomains: app}, func(ctx context.Context) error {
2066 if p != nil {
2067 p = claimMarkedProvisioner{raw: p}
2068 }
2069 if w != nil {
2070 w = claimMarkedWriter{raw: w}
2071 }
2072 return claimUnserializedTeardown(ctx, s, p, w, d, acct, app)
2073 })
2074 })
2075 return claimErr
2076}
2077
2078func RemoveDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer,
2079 d Deployment, acct, app, domain string) (*DomainRemoval, error) {
2080 if _, enabled := s.(ClaimMailStore); !enabled {
2081 return claimUnserializedRemoveDomain(ctx, s, p, w, d, acct, app, domain)
2082 }
2083 var claimResult0 *DomainRemoval
2084 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
2085 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}, Domain: domain, Refresh: true}, func(ctx context.Context) error {
2086 if p != nil {
2087 p = claimMarkedProvisioner{raw: p}
2088 }
2089 if w != nil {
2090 w = claimMarkedWriter{raw: w}
2091 }
2092 var err error
2093 claimResult0, err = claimUnserializedRemoveDomain(ctx, s, p, w, d, acct, app, domain)
2094 return err
2095 })
2096 })
2097 return claimResult0, claimErr
2098}
2099