// The walks: the provisioning sequences every verb trusts. Each is // check-then-create, so each is re-runnable after a half-failure — the // same discipline internal/objstore/walk.go follows, for the same reason. package mail import ( "context" "crypto/ecdh" "errors" "fmt" "log" "regexp" "sort" "strings" "time" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/dnsrec" "github.com/carlosframework/platform/internal/release" ) var ( // ErrNotDeclared is a provisioning verb for an app whose member never // asked for email. The operator's verb never invents the declaration: // the env prefix and the From address are the member's to choose. ErrNotDeclared = errors.New("mail: app has not declared email") // ErrNoDomains is a provisioning verb with no VERIFIED domain. A // credential with no identity would be a principal that can send // nothing, which is worse than no credential — it looks provisioned. ErrNoDomains = errors.New("mail: no verified sending domain") // ErrNotProvisioned is a rotation or pause aimed at an app that was // never provisioned. These verbs edit an existing credential estate; // they never create one. ErrNotProvisioned = errors.New("mail: app has no provisioned credential") // ErrNotOwnPlatformName is EnsureDomain's refusal of a platform-suffixed // domain the caller does not own — see platformNameOwnedBy. Unlike // ErrDomainClaimed, naming the caller's OWN account here is fine: this // is not a cross-account leak, it is telling the caller which name IS // theirs. ErrNotOwnPlatformName = errors.New("mail: not this account's platform name") // ErrDomainNotReferenced is RemoveDomain's refusal of a domain this app // does not use — including one whose global claim belongs to somebody // else, which reads identically on purpose so the door cannot be used // as an oracle for who holds a domain the caller does not. ErrDomainNotReferenced = errors.New("mail: app does not use this domain") // ErrDomainIsFrom is RemoveDomain's refusal of the domain the app is // currently sending FROM. Enable rejects a From that is not at a // granted domain, so allowing this would break sending at the app's // next re-enable rather than at the command that caused it — the // failure moves from a refusal a person can read to silence a person // has to debug. ErrDomainIsFrom = errors.New("mail: domain is the app's From address") ) // Deployment is the per-deployment configuration the walks need. // // DMARCRua is the aggregate-report mailto target and may be empty, in which // case no rua is published (spec §7). SNSTopicARN receives SES events; empty // means no configuration set is created and no counts arrive, which is a // degraded deployment rather than a broken one. type Deployment struct { Region string PlatformSuffix string // e.g. "oncarlos.com" — the zone CARLOS owns DMARCRua string SNSTopicARN string // Resolver answers the proof-of-control lookup for a domain whose SES // identity this account did not create (#216). nil means no proof can // be checked, so such a domain is always refused. Resolver TXTResolver } // DomainKindFor decides who publishes DNS for a domain. // // The suffix match is LABEL-AWARE: "evil-oncarlos.com" ends with // "oncarlos.com" as a string but is a different zone entirely, and treating // it as ours would have the walk try to write records into somebody else's // domain. Only an exact match or a true subdomain counts. func DomainKindFor(d Deployment, domain string) string { if d.PlatformSuffix == "" { return KindCustom } if domain == d.PlatformSuffix || strings.HasSuffix(domain, "."+d.PlatformSuffix) { return KindPlatform } return KindCustom } // platformNameOwnedBy reports whether domain, a KindPlatform name, is // acct's to ask for. // // CORRECTION (spec §4a, 2026-08-19): this section originally claimed // platform names need no ownership check because they are "account-scoped // by construction". They are not — DomainKindFor matches only the // *suffix*, and nothing else compared the account to the name. Review // demonstrated account "bbb" claiming "harbourmail.aaa.oncarlos.com" and // getting a verified identity AND a published DNS record set for it, // without any DNS control of its own — CARLOS published the proof on the // attacker's behalf, which is strictly worse than the custom-domain case // the global claim exists for. The bare suffix itself (no account label at // all) was accepted too, writing a DMARC record at the platform's own zone // apex. // // The fix: for a platform name, the label immediately left of the suffix — // "aaa" in "harbourmail.aaa.oncarlos.com" — MUST equal the calling // account's sqid, and the bare suffix is refused outright (there is no // label to check, so there is no account it could belong to). This check // is INDEPENDENT of, and runs ALONGSIDE, the global claim below: the claim // is first-come-first-served, which is the right rule where DNS control is // the real proof (a custom domain) and no rule at all where CARLOS itself // supplies the proof — first-come-first-served over that would let "bbb" // squat "aaa"'s future platform name before "aaa" ever asks for it, even // with the claim gate fully working. func platformNameOwnedBy(d Deployment, domain, acct string) bool { if domain == d.PlatformSuffix { return false // the bare suffix belongs to no account } rest := strings.TrimSuffix(domain, "."+d.PlatformSuffix) label := rest if i := strings.LastIndex(rest, "."); i != -1 { label = rest[i+1:] } return label == acct } // EnsureDomain brings a sending domain into existence for an app. // // It is one code path for both kinds because the record SET is identical; // only the publisher differs. For a platform domain the walk writes every // record and returns nothing pending. For a custom domain it writes nothing // and returns the whole set for the member to publish. Platform domains are // NOT exempt from the claim below, and — as of Task 17a's follow-up review // (C1) — they get a SECOND, independent gate the claim alone cannot // provide: platformNameOwnedBy. The claim is first-come-first-served, // which is fine when DNS control is the real proof; a platform name is // proven by CARLOS itself, so first-come-first-served over it would let // any account squat any other account's future platform name. See // platformNameOwnedBy's doc comment for the full story. // // The order, after validation: check platform-name ownership (no I/O, so // it costs nothing to do first and unconditionally), then CLAIM the // domain, then create the identity (which mints the DKIM tokens the // records name), then publish, then record. The claim is deliberately the // very first DURABLE side effect this walk performs — before EnsureTenant, // not just before EnsureIdentity. The ownership check precedes even the // claim only because it is pure and free, not because it is a second // claim-like gate in its own right. Nothing about the domain the caller // named is trustworthy until the claim says it is theirs, and EnsureTenant // is a real backend call; doing it (or anything else) for a request the // claim is about to refuse would mean a losing account's call still bought // it a wasted SES/IAM round trip. Once the claim is confirmed, the rest of // the walk is self-healing on a re-run (identity idempotent and // token-stable, DNS writes are upserts) exactly as before — this task // changes nothing about that repair story, it only closes what happens // before it starts. // // This ordering is spec §4a, added after review found the cross-tenant // hole this closes: SES identities are per-AWS-ACCOUNT, not per-tenant, so // any domain one account has verified is verified globally at SES. Without // the claim, a second account could call EnsureDomain on a domain another // account already controlled, walk straight to EnsureIdentity, and obtain // the SAME identity ARN with no proof of its own — Enable would then build // it an IAM policy granting ses:SendRawEmail at that domain. Writing the // claim AFTER EnsureIdentity (the reverse order) would not close this: two // accounts could each mint an identity and only discover the conflict from // the fleet, by which point both already hold a working ARN. func claimUnserializedEnsureDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, d Deployment, acct, app, domain string, now time.Time) (*DomainRecord, []dnsrec.Record, error) { if !release.ValidAccount(acct) { return nil, nil, fmt.Errorf("mail: invalid account %q", acct) } if !release.ValidApp(app) { return nil, nil, fmt.Errorf("mail: invalid app %q", app) } if !ValidDomain(domain) { return nil, nil, fmt.Errorf("mail: invalid domain %q", domain) } // Platform-name ownership is checked BEFORE the claim, and unconditionally // — it costs no I/O, and the claim alone is the wrong gate for a name // CARLOS itself supplies the proof for (platformNameOwnedBy's doc // comment has the full story: this is C1 from Task 17a's follow-up // review, and the claim by itself does not close it — a second account // could claim its own platform-shaped lookalike name before the real // owner ever asks for it). if DomainKindFor(d, domain) == KindPlatform && !platformNameOwnedBy(d, domain, acct) { return nil, nil, fmt.Errorf("mail: %q is not %s's platform name: %w", domain, acct, ErrNotOwnPlatformName) } err := claimDomain(ctx, s, domain, acct, app, now) if errors.Is(err, ErrDomainClaimed) { err = takeLapsedClaim(ctx, s, p, d.Region, domain, acct, app, now) } if err != nil { // Wrapping preserves errors.Is(err, ErrDomainClaimed) for callers // (the console door translates it into its own neutral refusal) — // %w keeps the chain — and the added context is just the domain // name the caller already supplied, never the account that holds // the claim. return nil, nil, fmt.Errorf("mail: claim domain %s: %w", domain, err) } // TenantName, not the bare acct: the SES tenant namespace is shared with // other products in this AWS account (see its doc comment in policy.go). tenant := TenantName(acct) if err := p.EnsureTenant(ctx, tenant); err != nil { return nil, nil, fmt.Errorf("mail: ensure tenant %s: %w", tenant, err) } rec, etag, err := GetDomain(ctx, s, acct, domain) if err != nil { return nil, nil, err } hadRecord := rec != nil if rec == nil { rec = &DomainRecord{ Domain: domain, Kind: DomainKindFor(d, domain), Regions: map[string]*RegionState{}, CreatedAt: now, } } if rec.Regions == nil { rec.Regions = map[string]*RegionState{} } rec.AddApp(app) // WHOSE IDENTITY IS IT (#216). SES identities are per AWS account, and // this one is shared with every other product in it, so "already // exists" used to mean "adopt": read back its DKIM tokens, rewrite its // MAIL FROM, bind it to this account's configuration set, and delete it // at teardown. A member could do that to tito.io, or to the operator's // own sign-in domain, and get send rights for it the moment verify read // SES's existing verdict. // // So an existing identity is this account's to keep only when CARLOS // already knows it is: the account's record names it (created by an // earlier run, by a sibling app, or adopted with proof before), or the // claim says this account's walk created it and the record was never // written. Anything else needs proof of DNS control, and is recorded as // Adopted so nothing CARLOS does later deletes it. mailFrom := MailFromDomain(domain, d.Region) prev := rec.Regions[d.Region] owned := prev != nil && prev.IdentityARN != "" adopted := owned && prev.Adopted if !owned { claim, err := getDomainClaim(ctx, s, domain) if err != nil { return nil, nil, err } owned = claim != nil && claim.Account == acct && claim.createdIdentityIn(d.Region) } ident, err := p.EnsureIdentity(ctx, d.Region, domain, mailFrom, owned) if errors.Is(err, ErrIdentityExists) { if !provesControl(ctx, d.Resolver, domain, acct) { // Nothing of this account's stands behind a claim with no // record: let it go, so a refusal never blocks the domain's // real owner, and no orphan sweep is ever pointed at it. if !hadRecord { if rerr := releaseDomainClaim(ctx, s, domain, acct); rerr != nil { log.Printf("mail: releasing claim on refused %s: %v", domain, rerr) } } return nil, nil, fmt.Errorf("mail: %s in %s: %w", domain, d.Region, ErrIdentityNotOwned) } adopted = true ident, err = p.EnsureIdentity(ctx, d.Region, domain, mailFrom, true) } if err != nil { return nil, nil, fmt.Errorf("mail: ensure identity %s in %s: %w", domain, d.Region, err) } if ident.Created { // Stamped before anything else can fail, so a crash from here on // leaves a claim that says this identity is CARLOS's: the re-run // keeps it, and the orphan sweeps may delete it. adopted = false if err := stampClaimIdentity(ctx, s, domain, acct, d.Region); err != nil { return nil, nil, fmt.Errorf("mail: record identity for %s: %w", domain, err) } } // configSet stays empty when no SNS topic is configured — recording a // name for a set that was never created would leave later sends // failing with "Configuration set does not exist" instead of the // degraded-but-working deployment the Deployment doc comment promises. configSet := "" if d.SNSTopicARN != "" { configSet = ConfigSetName(acct) if err := p.EnsureConfigSet(ctx, d.Region, configSet, d.SNSTopicARN); err != nil { return nil, nil, fmt.Errorf("mail: ensure config set %s: %w", configSet, err) } // C1 (review follow-up): EnsureConfigSet only creates the SET and // its SNS event destination — nothing before this bound the // IDENTITY just ensured above to it, and SES only emits events for // a message sent WITH a configuration set. Without this call every // send/bounce/complaint count for this identity stays zero // forever: the daily cap never enforces and the automatic abuse // pause — the one thing stopping a single tenant's stale list // putting the whole shared SES account under review — never // fires. Bound HERE, inside the `d.SNSTopicARN != ""` branch: the // SNS-less deployment deliberately records an empty configSet // (this function's own doc comment on Deployment), and calling // this with an empty name would either error at a real backend or // silently bind to nothing. // // THE BIND ITSELF HAPPENS AT THE END OF THIS FUNCTION, not here, // and the gap is the whole point. SES authorises a configuration // set as a resource in its own right (#271), so an identity bound // to a set that the app's IAM policy does not name refuses every // send with a 554. Creating the set is harmless; binding it is not // — so the order has to be create, record, GRANT, then bind. Bind // here and there is a window, however short, in which this app // cannot send at all. } records := RecordSet(domain, d.Region, ident.DKIMTokens, d.DMARCRua) var pending []dnsrec.Record // GATED ON w != nil, not on rec.Kind alone (C1/item-4, review // follow-up round 2). Kind still answers truthfully from the // CONFIGURED suffix — that is what platformNameOwnedBy's ownership // check depends on above, and that check must run whether or not a // publisher is wired up, or a deployment with no DNSWriter silently // stops enforcing ownership on its own platform names (the exact bug // this round closed: ownership and publishing are two different // questions, and only publishing has any business asking about w). // nil w degrades a platform-kind domain to pending exactly like a // custom one — never a call on a nil interface, and never a "publish" // into some throwaway writer nobody durable ever reads. if rec.Kind == KindPlatform && w != nil { for _, r := range records { if err := w.EnsureRecord(ctx, r); err != nil { // Deliberately BEFORE the record is written: a failed // publish must leave the walk repairable by a re-run, not // leave a record claiming DNS that is not there. return nil, nil, fmt.Errorf("mail: publish %s %s: %w", r.Type, r.Name, err) } } } else { pending = records } // Preserve a status already reached: a verified region must not be // knocked back to pending by a re-run. StatusFailed is NOT preserved — // a re-run is a fresh attempt, so a previously failed region goes back // to pending rather than staying stuck at failed. status := StatusPending if st, ok := rec.Regions[d.Region]; ok && st.Status == StatusVerified { status = StatusVerified } rec.Regions[d.Region] = &RegionState{ IdentityARN: ident.ARN, DKIMTokens: ident.DKIMTokens, MailFrom: mailFrom, ConfigSet: configSet, Status: status, CheckedAt: now, Adopted: adopted, } if _, err := PutDomain(ctx, s, acct, rec, etag); err != nil { return nil, nil, err } // The account's tenant gets this identity, and its configuration set // (design §7). Association does not modify the resource and one resource // may belong to many tenants, so this changes nothing anyone can observe // today. It is wired now because SES validates the association on every // tenant-attributed send and step 3 sends that way. // // AFTER PutDomain, not beside EnsureIdentity, and that ordering is the // whole of what keeps Teardown's orphan-claim sweep working. An // association is the one thing that stops an identity being deleted, and // the sweep (walk.go's orphaned-claims loop, below) deletes the identity // behind a claim that has NO record — so it never runs the disassociate // in Teardown's region loop. Associate before the record is durable and // a crash between the two leaves an identity nothing can ever reap. if err := p.AssociateTenantResource(ctx, tenant, ident.ARN); err != nil { return nil, nil, fmt.Errorf("mail: associate identity %s with tenant %s: %w", domain, tenant, err) } if configSet != "" { if setARN, ok := ConfigSetARN(ident.ARN, configSet); ok { if err := p.AssociateTenantResource(ctx, tenant, setARN); err != nil { return nil, nil, fmt.Errorf("mail: associate configuration set %s with tenant %s: %w", configSet, tenant, err) } } } // GRANT BEFORE BIND. Every app that sends on this domain — and every // standalone credential those apps hold — gets its policy recomputed // from the record just written, so the configuration-set ARN is in // place before anything is bound to it. // // This is not an optimisation, it is the difference between a repair // and an outage. EnsureUser is the only place a policy is written, and // it runs in Enable and CreateStandalone — never here. So before this, // an app enabled BEFORE a config set existed (every app on a // deployment that arms SNS later) got the set bound underneath it with // a policy that never mentioned it: 554 on every send. `carlos email // domains add` was the documented repair for missing events, and it // was also the thing that broke sending. if err := RefreshPolicies(ctx, s, p, acct, rec.Apps); err != nil { return nil, nil, err } if configSet != "" { if err := p.SetIdentityConfigSet(ctx, d.Region, domain, configSet); err != nil { return nil, nil, fmt.Errorf("mail: bind identity %s to config set %s: %w", domain, configSet, err) } } return rec, pending, nil } // RefreshPolicies rewrites the inline policy of every principal belonging to // each of apps, from the CURRENT granted domain set — the delivered // credential and every standalone one alike. // // It exists because the grant is a function of state that changes OUTSIDE // the walks that write it. Enable and CreateStandalone each recompute it for // one app at the moment they mint a key; nothing recomputed it when a // DOMAIN changed underneath an app that was already provisioned. Binding a // configuration set is exactly that kind of change, and so is a domain // joining or leaving the granted set. // // Silent on an app with nothing to grant: no record, no declaration, no // granted domains, or nothing provisioned yet all mean there is no principal // whose policy could be stale. An app that IS provisioned and whose grant // cannot be computed is an error, not a skip — that is a policy this // function was asked to refresh and could not. func claimUnserializedRefreshPolicies(ctx context.Context, s release.Store, p Provisioner, acct string, apps []string) error { for _, app := range apps { rec, _, err := Get(ctx, s, acct, app) if err != nil { return err } if rec == nil || rec.Declared == nil { continue } if rec.Provisioned == nil && len(rec.Standalone) == 0 { continue } domains, err := grantedDomains(ctx, s, acct, app) if err != nil { return err } if len(domains) == 0 { continue } var arns, configSets, names []string for _, dom := range domains { arns = append(arns, dom.VerifiedARNs()...) configSets = append(configSets, dom.VerifiedConfigSetARNs()...) names = append(names, dom.Domain) } sort.Strings(arns) sort.Strings(configSets) sort.Strings(names) if len(arns) == 0 { // Granted domains exist but none is verified in any region, so // SendPolicyJSON would refuse anyway. Nothing to write. continue } if rec.Provisioned != nil { user := UserName(acct, app) if _, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names); err != nil { return fmt.Errorf("mail: refresh policy for %s: %w", user, err) } } for _, sa := range rec.Standalone { user := StandaloneUserName(acct, app, sa.Name) if _, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names); err != nil { return fmt.Errorf("mail: refresh policy for %s: %w", user, err) } } } return nil } // ConfigSetName is the per-account SES configuration set that carries the // event destination feeding bounce, complaint and send counts (spec §9). func ConfigSetName(acct string) string { return "carlos-" + acct } // VerifyDomain re-reads SES's verdict for one (domain, region) and records // it. // // SES'S STATUS IS THE ONLY AUTHORITY. This function deliberately does NOT // do a DNS lookup to decide whether the records are live: a wildcard record // at a parent name can synthesise an answer for a name that is genuinely // absent, and a poller trusting its own lookup would flip a domain to // verified while SES still refuses to sign with it. Asking SES costs a round // trip and cannot be fooled that way. // // The status is recomputed from scratch each call rather than latched, so a // domain that SES later reports FAILED leaves verified, and one that // recovers leaves failed. func claimUnserializedVerifyDomain(ctx context.Context, s release.Store, p Provisioner, d Deployment, acct, domain, region string, now time.Time) (*DomainRecord, error) { rec, etag, err := GetDomain(ctx, s, acct, domain) if err != nil { return nil, err } if rec == nil { return nil, fmt.Errorf("mail: no domain record for %s", domain) } st, ok := rec.Regions[region] if !ok { return nil, fmt.Errorf("mail: domain %s has no identity in %s", domain, region) } // A record whose claim another account now holds — it lapsed unverified // and was taken (#216) — must not verify into a grant on what is now // somebody else's identity. It reads failed, and SES is not asked. No // claim at all is a record from before the claim gate, and verifies as // it always did. claim, err := getDomainClaim(ctx, s, domain) if err != nil { return nil, err } if claim != nil && claim.Account != acct { wasVerified := st.Status == StatusVerified st.Status, st.CheckedAt = StatusFailed, now if _, err := PutDomain(ctx, s, acct, rec, etag); err != nil { return nil, err } if wasVerified { if err := RefreshPolicies(ctx, s, p, acct, rec.Apps); err != nil { return nil, err } } return nil, fmt.Errorf("mail: verify %s: %w", domain, ErrDomainClaimed) } got, err := p.IdentityStatus(ctx, region, domain) if err != nil { return nil, fmt.Errorf("mail: identity status for %s in %s: %w", domain, region, err) } switch { case got.Verified(): st.Status = StatusVerified case got.DKIM == "FAILED" || got.MailFrom == "FAILED": // Terminal at SES until DNS changes. Recording it is what stops a // member waiting on a spinner that will never resolve. st.Status = StatusFailed default: st.Status = StatusPending } st.CheckedAt = now // RECONCILE THE CONFIGURATION SET (#272). Neither the record nor SES is // the source of truth here — deployment INTENT is: a deployment with an // SNS topic wants every identity bound to its account's set, and one // without wants none bound. Both stores are projections of that, and // either can drift from it. // // Done here rather than on every status read because IdentityStatus has // already asked SES about this identity: the drift check costs nothing // extra. Reconciling on read would put an API call per domain on a path // that answers from the bucket alone today, and would turn a working // read into a failure whenever AWS is unreachable. // // The unbind direction is not symmetry for its own sake: a deployment // that turns event ingest off, or whose set was deleted, leaves // identities pointing at a destination that no longer exists, and a // send through a missing configuration set fails outright. want := "" if d.SNSTopicARN != "" { want = ConfigSetName(acct) } rebound := false if got.ConfigSet != want { if err := p.SetIdentityConfigSet(ctx, region, domain, want); err != nil { return nil, fmt.Errorf("mail: rebind identity %s in %s: %w", domain, region, err) } rebound = true } st.ConfigSet = want if _, err := PutDomain(ctx, s, acct, rec, etag); err != nil { return nil, err } // Once verified, the claim never lapses. A failed stamp is only logged: // the record's own verified status protects the claim just the same // until the next verify retries it. if st.Status == StatusVerified && claim != nil && claim.VerifiedAt == nil { if err := stampClaimVerified(ctx, s, domain, acct, now); err != nil { log.Printf("mail: stamping verified on claim %s: %v", domain, err) } } // Grant after rebind, for #279's reason seen from the other side: a // repair that fixes SES and the record but leaves the IAM policy naming // no configuration set converts silent-no-events into a 554 on every // send — strictly worse than the drift it repaired. Only on an actual // rebind, so the ordinary verify stays the cheap read it was. if rebound { if err := RefreshPolicies(ctx, s, p, acct, rec.Apps); err != nil { return nil, err } } return rec, nil } // SMTPPort is the submission port every delivered credential uses. 587 with // STARTTLS is the whole of the delivered contract's transport (spec §1). const SMTPPort = "587" // SMTPHost is the submission endpoint for a deployment's region. v1 delivers // exactly ONE _HOST, fleet-wide, from d.Region — every instance of an app // gets the same host regardless of where it actually runs. Per-instance // resolution (a host that follows the instance's own region) is deferred, // and is recorded in the spec. // // When per-instance resolution lands, _PASS must move with it, not stay // behind: SMTPPassword is derived PER REGION, so a per-instance host paired // with today's fleet-wide password would be wrong for every instance // outside the region that password was derived for. func SMTPHost(region string) string { return "email-smtp." + region + ".amazonaws.com" } // Declare records the member's half. It does NOT validate the From address // against a domain, because a member may declare before any domain is // verified; Enable is where the two meet and where a mismatch is refused. func claimUnserializedDeclare(ctx context.Context, s release.Store, acct, app, envPrefix, from, actor string, now time.Time) (*Record, error) { if envPrefix == "" { envPrefix = DefaultEnvPrefix } if !ValidEnvPrefix(envPrefix) { return nil, fmt.Errorf("mail: invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$", envPrefix) } if !strings.Contains(from, "@") { return nil, fmt.Errorf("mail: From %q is not an address", from) } // Checked here, before anything is spent: From is delivered verbatim as // HARBOURMAIL_SMTP_FROM, and an unrenderable byte caught only inside // writeSMTPEnv would fail AFTER MintKey has already run — stranding a // key on every retry. The From address is not a secret, so it is safe // to name in the error. if err := appconfig.ValidVarValue(from); err != nil { return nil, fmt.Errorf("mail: From %q is not EnvironmentFile-safe: %w", from, err) } rec, etag, err := Get(ctx, s, acct, app) if err != nil { return nil, err } if rec == nil { rec = &Record{} } // The env prefix is LOCKED once delivery has happened: moving it would // leave the old variables standing in every instance's config, and the // app would keep reading the stale pair. if rec.Declared != nil && rec.Provisioned != nil && rec.Declared.EnvPrefix != envPrefix { return nil, fmt.Errorf("mail: env prefix is %q and cannot move after delivery", rec.Declared.EnvPrefix) } rec.Declared = &Declared{EnvPrefix: envPrefix, From: from, At: now, By: actor} if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { return nil, err } return rec, nil } // grantedDomains is every domain in the account whose record names this app // and which has at least one VERIFIED region. func grantedDomains(ctx context.Context, s release.Store, acct, app string) ([]*DomainRecord, error) { // Enabled: a record that cannot be read must not silently drop a domain // from a policy about to be written wholesale (a shrink). The preflight // already read strictly; this keeps the read itself honest. list := ListDomains if _, enabled := s.(ClaimMailStore); enabled { list = StrictListDomains } all, err := list(ctx, s, acct) if err != nil { return nil, err } var out []*DomainRecord for _, d := range all { if len(d.VerifiedARNs()) == 0 { continue } for _, a := range d.Apps { if a == app { out = append(out, d) break } } } return out, nil } // EnvDelivered reports which delivered variables are NOT present, of the // right kind, in the app's config. It is the walk's idempotence check and // the console's "what is missing" answer, and it exists because "the record // says provisioned" is not the same claim as "the app can actually send". // // wantKeyID is the access key id delivery is supposed to be carrying in // _USER — normally rec.Provisioned.KeyID. Pass "" to skip the value check // (there is no expected key yet). _USER's VALUE is compared, not just its // kind, because _USER is delivered unsealed (writeSMTPEnv) and is therefore // the one credential half this function can cheaply verify end to end: a // failed final Put after a successful mint+deliver would otherwise leave // the app running on a key no record names, reading as "delivered" // regardless — a disagreement that is permanent, because nothing else ever // re-checks it. _PASS is sealed, so its value cannot be compared this way; // Kind is the best this function can do for it. // // wantFrom is the From address delivery is supposed to be carrying in // _FROM — normally rec.Declared.From. Pass "" to skip the value check, the // same convention wantKeyID uses. _FROM's VALUE is compared for the // identical reason _USER's is (I1, review follow-up): Declare freely // overwrites Declared.From at any time (only the env PREFIX is locked once // delivered), and before this check nothing ever re-verified that a // changed From actually reached delivery — a re-declare followed by a // re-enable left the record and the delivered value disagreeing // permanently, the same failure class the _USER check exists to catch, // just for the one delivered variable it had not been applied to yet. func EnvDelivered(ctx context.Context, s release.Store, acct, app, prefix, wantKeyID, wantFrom string) ([]string, error) { man, err := appconfig.Get(ctx, s, acct, app) if err != nil { return nil, err } host, port, user, pass, from := EnvNames(prefix) var missing []string check := func(name, wantKind string) { v, ok := man.Vars[name] if !ok || v.Kind != wantKind { missing = append(missing, name) } } check(host, "env") check(port, "env") check(pass, "secret") if v, ok := man.Vars[user]; !ok || v.Kind != "env" || (wantKeyID != "" && v.Value != wantKeyID) { missing = append(missing, user) } if v, ok := man.Vars[from]; !ok || v.Kind != "env" || (wantFrom != "" && v.Value != wantFrom) { missing = append(missing, from) } sort.Strings(missing) return missing, nil } // Enable provisions the declared app's credential and delivers it. // // Idempotent, but NOT all-or-nothing about it (C2, review follow-up): the // IAM user's policy is rewritten from the CURRENT granted domain set on // EVERY call, wholesale, cheaply — that is what makes attaching (or // losing) a domain take effect on a plain re-`enable` with no rotation. // Only the expensive half — mint a fresh key and re-deliver config — is // skipped when a healthy provisioned app makes it a no-op, because minting // on every pass would burn IAM's two-key ceiling and churn every // instance's config. "Healthy" means the record says provisioned AND every // delivered variable is present, of the right kind AND value where one is // checked; anything less and the rest of the walk runs too, because a // missing or stale variable means the app cannot send what the record // promises. func claimUnserializedEnable(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, d Deployment, acct, app string, now time.Time) (*Record, error) { rec, etag, err := Get(ctx, s, acct, app) if err != nil { return nil, err } if rec == nil || rec.Declared == nil { return nil, ErrNotDeclared } domains, err := grantedDomains(ctx, s, acct, app) if err != nil { return nil, err } if len(domains) == 0 { return nil, ErrNoDomains } var arns, configSets, names []string for _, dom := range domains { arns = append(arns, dom.VerifiedARNs()...) configSets = append(configSets, dom.VerifiedConfigSetARNs()...) names = append(names, dom.Domain) } sort.Strings(arns) sort.Strings(configSets) sort.Strings(names) // The From address must be at a granted domain, or the policy's // ses:FromAddress condition refuses every send at RUNTIME — which // presents as "email silently does not work" instead of as this refusal. at := strings.LastIndex(rec.Declared.From, "@") fromDomain := rec.Declared.From[at+1:] if !containsString(names, fromDomain) { return nil, fmt.Errorf("mail: From %q is not at any verified domain (%s)", rec.Declared.From, strings.Join(names, ", ")) } // C2 (review follow-up): EnsureUser runs UNCONDITIONALLY, before the // idempotence short-circuit below — it is no longer gated behind it. // EnsureUser is the ONLY place the delivered credential's policy is // written (PutUserPolicy overwrites wholesale, spec §4), and arns/names // above are recomputed from the CURRENT granted domain set on every // call. Before this fix, the short-circuit returned before EnsureUser // ever ran again: adding and verifying a second domain, then // re-enabling, left the policy naming only the first domain forever — // spec §4 promises the opposite ("attaching a custom domain adds an ARN // and rewrites the policy without rotating a credential") — and the // symmetric shrink (a domain leaving the granted set) left a departed // ARN standing in the policy just as permanently. EnsureUser is cheap // to re-run (idempotent CreateUser, one PutUserPolicy) — it is the reap // and mint below that spend IAM's two-key ceiling, and THOSE are what // the short-circuit still has to gate. user := UserName(acct, app) arn, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names) if err != nil { return nil, fmt.Errorf("mail: ensure user %s: %w", user, err) } // The reverse index, right after EnsureUser and before MintKey (design // §5). EnsureUser runs on every enable, so a re-enable converges the // index; putting it after MintKey would strand a sender forever when a // crash landed between the two. if err := PutCallerIndex(ctx, s, user, acct, app, SenderAppID, now); err != nil { return nil, fmt.Errorf("mail: index caller %s: %w", user, err) } // Idempotence short-circuit: governs ONLY the expensive mint-and-deliver // half from here down. if rec.Provisioned != nil { missing, err := EnvDelivered(ctx, s, acct, app, rec.Declared.EnvPrefix, rec.Provisioned.KeyID, rec.Declared.From) if err != nil { return nil, err } if len(missing) == 0 { return rec, nil } } // Active and PauseKind are carried forward, not reset. A re-walk that // repairs missing delivery for a paused app is legitimate; silently // un-pausing it is not — Active only ever moves from false to true // through the pause verb itself (Task 13), never as a side effect of // this walk running. PauseKind must travel with Active: dropping it // here would turn a CAP pause into a kind-less one on the very next // repair walk, which enforceMailLimits and the status-read auto-resume // (R2) never resume, and which the CLI describes as needing an // operator — resurrecting the exact defect PauseKind was added to fix, // through this door instead of SetPaused. A first-time enable (no // prior Provisioned block) starts active with no pause kind. active, prev, pauseKind := true, "", "" if rec.Provisioned != nil { prev = rec.Provisioned.PrevKeyID active = rec.Provisioned.Active pauseKind = rec.Provisioned.PauseKind } // Reap before minting: reaching here means delivery is missing, so any // current key is either undelivered (its secret died with a failed // config write) or stranded by a crash between mint and record. No // record names either; only list-and-delete finds them. A pending-finish // key from an unfinished rotation is kept for its explicit finish. var keep []string if prev != "" { keep = append(keep, prev) } if err := p.ReapKeys(ctx, user, keep); err != nil { return nil, fmt.Errorf("mail: reap keys for %s: %w", user, err) } keyID, secret, err := p.MintKey(ctx, user) if err != nil { return nil, fmt.Errorf("mail: mint key for %s: %w", user, err) } // I1: same defect as C1, reached through the repair path — a re-walk // that repairs missing delivery for a PAUSED app must not silently // lift the pause at IAM just because it reaps and re-mints. if err := enforceKeyActive(ctx, p, user, keyID, active); err != nil { return nil, err } if err := writeSMTPEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, d.Region, keyID, secret, rec.Declared.From); err != nil { return nil, err } rec.Provisioned = &Provisioned{ IAMUser: user, UserARN: arn, KeyID: keyID, PrevKeyID: prev, Active: active, PauseKind: pauseKind, At: now, } if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { return nil, err } return rec, nil } func containsString(xs []string, want string) bool { for _, x := range xs { if x == want { return true } } return false } // ApplyGrant records who authorised this app to send and at what cap. // // A TIER-sourced grant never overwrites an OPERATOR-sourced one. That is the // entire reason Source exists: an operator raising a cap is a decision, and // a nightly tier pass that flattened it back would revoke that decision // silently, days later, with nothing to point at. func claimUnserializedApplyGrant(ctx context.Context, s release.Store, acct, app string, domains []string, dailyCap int, source, actor string, now time.Time) (*Record, error) { rec, etag, err := Get(ctx, s, acct, app) if err != nil { return nil, err } if rec == nil || rec.Declared == nil { return nil, ErrNotDeclared } if rec.Grant != nil && rec.Grant.Source == "operator" && source != "operator" { return rec, nil } sort.Strings(domains) rec.Grant = &GrantBlock{ Domains: domains, DailyCap: dailyCap, Source: source, At: now, By: actor, } if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { return nil, err } return rec, nil } // writeSMTPEnv delivers the five variables. The DERIVED password goes out, // never the IAM secret — delivering the secret would authenticate against // nothing and would put a live AWS credential into an app's environment. // // _USER (the access key id) is delivered as a plain "env" var, NOT sealed — // deliberately, reversing an earlier design here. It protects nothing: // rec.Provisioned.KeyID already sits in plaintext in email.json, in the // same bucket as config.json, so sealing it hides the value from nobody who // can't already read it there. Leaving it readable buys something real: // EnvDelivered can compare the delivered value against // rec.Provisioned.KeyID and catch the one failure mode that would // otherwise be permanent — a failed final Put after a successful // mint+deliver leaves the app running on a key no record names, the pause // aimed at a dead key, and the next rotation hitting IAM's two-key ceiling. // _PASS remains sealed — it IS the secret. func writeSMTPEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey, acct, app, prefix, region, keyID, secret, from string) error { hostName, portName, userName, passName, fromName := EnvNames(prefix) password := SMTPPassword(secret, region) // Validate BEFORE sealing/delivering. A sealed value reaches appconfig // as an opaque envelope, so an unrenderable byte would sail through and // stall the app's whole config render on every host, hours later and // out of sight. The errors name the VARIABLE and never the value — // these strings reach logs and HTTP bodies, and one half is a live // credential. if err := appconfig.ValidVarValue(keyID); err != nil { return fmt.Errorf("mail: minted credential for %s is not EnvironmentFile-safe: %w", userName, err) } if err := appconfig.ValidVarValue(password); err != nil { return fmt.Errorf("mail: minted credential for %s is not EnvironmentFile-safe: %w", passName, err) } env, err := appconfig.Seal(sealPub, appconfig.Binding{Account: acct, App: app}, []byte(password)) if err != nil { return fmt.Errorf("mail: seal %s: %w", passName, err) } sets := map[string]appconfig.Var{ hostName: {Kind: "env", Value: SMTPHost(region)}, portName: {Kind: "env", Value: SMTPPort}, fromName: {Kind: "env", Value: from}, userName: {Kind: "env", Value: keyID}, passName: {Kind: "secret", Envelope: env}, } if _, err := appconfig.Apply(ctx, s, acct, app, sets, nil); err != nil { return fmt.Errorf("mail: deliver smtp config for %s/%s: %w", acct, app, err) } return nil } // StandaloneCreds is a displayed credential, returned ONCE. Nothing stores // the password: it is derived here and handed straight back. type StandaloneCreds struct { Host, Port, Username, Password, From string } // enforceKeyActive sets a freshly minted key's IAM active state to match the // record's OWN Active flag. It is the single enforcement point every // mint-and-deliver path — Enable, Rotate, AND CreateStandalone — must call. // // MintKey always returns an ACTIVE key. Skipping this after a mint silently // lifts a standing pause at IAM even though the record keeps saying paused — // the console keeps showing paused, the audit log keeps showing the pause, // and the app is sending again. The call is UNCONDITIONAL, not gated behind // `if !active`: unconditional self-corrects in both directions (an active // record's freshly minted key is confirmed active, not just left alone) and // cannot drift out of sync with the record. func enforceKeyActive(ctx context.Context, p Provisioner, user, keyID string, active bool) error { if err := p.SetKeyActive(ctx, user, keyID, active); err != nil { return fmt.Errorf("mail: set key active for %s/%s: %w", user, keyID, err) } return nil } // Rotate mints a fresh key, delivers it, and leaves the OLD key ACTIVE. // // The overlap is the whole point. Adoption swaps a symlink; it does not // restart processes, so an instance may still be authenticating with the old // credential for minutes after delivery. Killing it at rotation time is an // outage with no upside. RotateFinish closes the window explicitly. // // The overlap consumes IAM's ceiling of two access keys per user, which is // exactly why standalone credentials get their own principal. func claimUnserializedRotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, d Deployment, acct, app string, now time.Time) (*Record, error) { rec, etag, err := Get(ctx, s, acct, app) if err != nil { return nil, err } if rec == nil || rec.Declared == nil { return nil, ErrNotDeclared } if rec.Provisioned == nil { return nil, ErrNotProvisioned } user := rec.Provisioned.IAMUser // A pending key from an UNFINISHED earlier rotation goes first — // rotating twice without finishing is ordinary member behaviour, and // both slots cannot hold three keys. if rec.Provisioned.PrevKeyID != "" { if err := p.DeleteKey(ctx, user, rec.Provisioned.PrevKeyID); err != nil { return nil, err } } // Then reap anything the record does not name — a key stranded by a // crash between mint and record is nameable only by listing. if err := p.ReapKeys(ctx, user, []string{rec.Provisioned.KeyID}); err != nil { return nil, err } keyID, secret, err := p.MintKey(ctx, user) if err != nil { return nil, fmt.Errorf("mail: mint key for %s: %w", user, err) } // C1: a rotate against a PAUSED app must not silently lift the pause at // IAM. MintKey returns an active key regardless; this brings it back in // line with what the record already says. if err := enforceKeyActive(ctx, p, user, keyID, rec.Provisioned.Active); err != nil { return nil, err } if err := writeSMTPEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, d.Region, keyID, secret, rec.Declared.From); err != nil { return nil, err } rec.Provisioned.PrevKeyID = rec.Provisioned.KeyID rec.Provisioned.KeyID = keyID rec.Provisioned.At = now if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { return nil, err } return rec, nil } // RotateFinish closes the two-key window. This is the only walk that // destroys a credential an app might still be using, which is why it is a // separate, explicit verb rather than the tail of Rotate. func claimUnserializedRotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app string) (*Record, error) { rec, etag, err := Get(ctx, s, acct, app) if err != nil { return nil, err } if rec == nil || rec.Provisioned == nil { return nil, ErrNotProvisioned } if rec.Provisioned.PrevKeyID == "" { return rec, nil // nothing to finish; idempotent } if err := p.DeleteKey(ctx, rec.Provisioned.IAMUser, rec.Provisioned.PrevKeyID); err != nil { return nil, err } rec.Provisioned.PrevKeyID = "" if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { return nil, err } return rec, nil } // ErrPerInstance refuses a resume aimed at an app sender whose app has moved // to per-instance senders (design section 6.3). Nothing sets PerInstance in this // release; the refusal exists so that nothing can set it later without this // guard already being in place and tested. var ErrPerInstance = errors.New("mail: the app sender is in per-instance mode") // SetPaused stops or resumes the APP sender, which is every sender an app // has until instance senders exist. Its signature is unchanged so the // operator door and the limiter keep calling it exactly as they did. func claimUnserializedSetPaused(ctx context.Context, s release.Store, p Provisioner, acct, app string, paused bool, kind string, now time.Time) (*Record, error) { sn, err := SetSenderPaused(ctx, s, p, acct, app, SenderAppID, paused, kind, now) if err != nil { return nil, err } return sn.record(app), nil } // SetSenderPaused toggles one sender's keys, never its policy. A policy // rewrite would have to be undone correctly to resume, and a half-failed // rewrite leaves a grant that is neither the old one nor the new one; // deactivating a key is atomic at IAM and trivially reversible. // // BOTH of the delivered user's keys are toggled, because an app // mid-rotation still holds the previous credential, and every standalone // credential of this sender is toggled too — each is its own IAM user, so // there is no two-key ceiling interaction, and skipping them would leave a // tenant paused for abuse still sending through a laptop credential. // // kind records WHICH automatic pause this is (mail.PauseKindCap or // mail.PauseKindRate) so mailcounts.go's enforceMailLimits can tell a // per-day cap pause — which it auto-resumes on the UTC day roll — from a // rate pause or a manual one, which it never does (I3, review follow-up). // Pass "" for a manual pause (the operator door) and for every resume // (paused == false): PauseKind is always cleared back to "" on resume, // whatever resumed it, so a record never carries a stale kind from a pause // that is already lifted. // // A resume is refused while PerInstance is set: the app's instances still // hold this credential, and re-arming it is exactly what per-instance mode // exists to prevent. A pause is always allowed. func claimUnserializedSetSenderPaused(ctx context.Context, s release.Store, p Provisioner, acct, app, sender string, paused bool, kind string, now time.Time) (*Sender, error) { sn, etag, err := GetSender(ctx, s, acct, app, sender) if err != nil { return nil, err } if sn == nil || sn.Provisioned == nil { return nil, ErrNotProvisioned } if !paused && sn.PerInstance { return nil, fmt.Errorf("mail: %s/%s: resume its instance senders instead: %w", acct, app, ErrPerInstance) } user := sn.Provisioned.IAMUser for _, id := range []string{sn.Provisioned.KeyID, sn.Provisioned.PrevKeyID} { if id == "" { continue } if err := p.SetKeyActive(ctx, user, id, !paused); err != nil { return nil, err } } for _, sa := range sn.Standalone { if err := p.SetKeyActive(ctx, sa.IAMUser, sa.KeyID, !paused); err != nil { return nil, err } } sn.Provisioned.Active = !paused if paused { sn.Provisioned.PauseKind = kind } else { sn.Provisioned.PauseKind = "" } sn.Provisioned.At = now if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil { return nil, err } return sn, nil } // CreateStandalone mints a displayed credential on its OWN IAM principal. func claimUnserializedCreateStandalone(ctx context.Context, s release.Store, p Provisioner, d Deployment, acct, app, name, actor string, now time.Time) (StandaloneCreds, error) { // A credential name is not an env prefix — this is just // validStandaloneName, nothing else (controller ruling F5). if !validStandaloneName(name) { return StandaloneCreds{}, fmt.Errorf("mail: invalid credential name %q", name) } rec, etag, err := Get(ctx, s, acct, app) if err != nil { return StandaloneCreds{}, err } if rec == nil || rec.Declared == nil { return StandaloneCreds{}, ErrNotDeclared } domains, err := grantedDomains(ctx, s, acct, app) if err != nil { return StandaloneCreds{}, err } if len(domains) == 0 { return StandaloneCreds{}, ErrNoDomains } var arns, configSets, names []string for _, dom := range domains { arns = append(arns, dom.VerifiedARNs()...) configSets = append(configSets, dom.VerifiedConfigSetARNs()...) names = append(names, dom.Domain) } sort.Strings(arns) sort.Strings(configSets) sort.Strings(names) senderNames, err := SenderNames(acct, app, SenderAppID, "") if err != nil { return StandaloneCreds{}, err } user, err := senderNames.StandaloneUser(acct, app, name) if err != nil { return StandaloneCreds{}, err } if _, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names); err != nil { return StandaloneCreds{}, err } // A standalone credential sends for the app sender, so its caller name // resolves to the same sender the delivered credential does. if err := PutCallerIndex(ctx, s, user, acct, app, SenderAppID, now); err != nil { return StandaloneCreds{}, fmt.Errorf("mail: index caller %s: %w", user, err) } // A re-mint for the same name replaces the credential rather than // stacking one: two slots is still the ceiling here too. if err := p.ReapKeys(ctx, user, nil); err != nil { return StandaloneCreds{}, err } keyID, secret, err := p.MintKey(ctx, user) if err != nil { return StandaloneCreds{}, err } // A THIRD mint path, and enforceKeyActive's contract applies here just // as it does to Enable and Rotate: minting a standalone credential for a // PAUSED app must not hand back a live key just because the mint went // through a different member verb. An app with no Provisioned block yet // (declared and granted, never enabled) has nothing to be paused, so it // defaults active. active := true if rec.Provisioned != nil { active = rec.Provisioned.Active } if err := enforceKeyActive(ctx, p, user, keyID, active); err != nil { return StandaloneCreds{}, err } replaced := false for _, sa := range rec.Standalone { if sa.Name == name { sa.IAMUser, sa.KeyID, sa.At, sa.By = user, keyID, now, actor replaced = true } } if !replaced { rec.Standalone = append(rec.Standalone, &Standalone{Name: name, IAMUser: user, KeyID: keyID, At: now, By: actor}) } if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { return StandaloneCreds{}, err } return StandaloneCreds{ Host: SMTPHost(d.Region), Port: SMTPPort, Username: keyID, Password: SMTPPassword(secret, d.Region), From: rec.Declared.From, }, nil } var standaloneNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,30}$`) func validStandaloneName(n string) bool { return standaloneNameRe.MatchString(n) } // ValidStandaloneName is validStandaloneName for callers outside the package. func ValidStandaloneName(n string) bool { return validStandaloneName(n) } // RevokeStandalone deletes one displayed credential and its principal. It // cannot disturb delivery, which is the point of the separate user. func claimUnserializedRevokeStandalone(ctx context.Context, s release.Store, p Provisioner, acct, app, name string) error { rec, etag, err := Get(ctx, s, acct, app) if err != nil { return err } if rec == nil { return nil } kept := rec.Standalone[:0] found := false for _, sa := range rec.Standalone { if sa.Name == name { found = true if err := DeleteCallerIndex(ctx, s, sa.IAMUser, acct, app, SenderAppID); err != nil { return err } if err := p.DeleteUser(ctx, sa.IAMUser); err != nil { return err } continue } kept = append(kept, sa) } if !found { return nil } rec.Standalone = kept _, err = Put(ctx, s, acct, app, rec, etag) return err } // domainDetach says what detachDomainApp did to one domain record. type domainDetach int const ( // detachNone decided nothing. It is only ever paired with a non-nil // error, so a caller that checks the error first never sees it. detachNone domainDetach = iota // detachNotAReferrer: this app never referred to the domain, or had // already stopped. Nothing was written and nothing was destroyed. detachNotAReferrer // detachKept: other apps still refer to the domain, so the record was // rewritten without this app and the shared estate was left alone. detachKept // detachTornDown: this app was the LAST referrer, so the published DNS // (platform domains only), the SES identity, the global claim and the // account's own domain record are all gone. detachTornDown ) // detachDomainApp stops one app referring to one domain, and — only when // that leaves NO app referring to it — dismantles the domain. // // It is the body of Teardown's per-domain loop, lifted out so `carlos email // domains rm` (RemoveDomain) executes the identical sequence rather than a // second copy of it. The order below is load-bearing in three separate // places, each documented where it happens; two copies of it would be two // chances to get one of them subtly wrong. // // cur must be a FRESH read (with its etag), not a ListDomains snapshot — // see Teardown's own comment at the call site for the two reasons. // // The orphaned record set comes back rather than being deleted for a CUSTOM // domain: those records live in the customer's zone and are the customer's // to remove. Returning them is what lets a door tell an operator exactly // which names are now stale. func detachDomainApp(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, d Deployment, acct, app string, cur *DomainRecord, curEtag string) (domainDetach, []dnsrec.Record, error) { if !cur.RemoveApp(app) { return detachNotAReferrer, nil, nil } if len(cur.Apps) > 0 { // A sibling app still sends from this domain. Its identity, its // DKIM tokens, its DNS and the global claim ALL stay exactly as // they are — getting this backwards revokes a sibling app's // verified domain silently, with the customer's own DNS still // pointing at an identity that no longer exists. if _, err := PutDomain(ctx, s, acct, cur, curEtag); err != nil { return detachNone, nil, err } return detachKept, nil, nil } // Last referrer: remove the identity and, for a platform domain, the // records CARLOS published. A custom domain's records are the // customer's to remove, so they come back to the caller instead. // // Except where the identity is not this account's to remove (#216): one // it adopted with proof, or one whose claim lapsed and now belongs to // another account. Those keep their identity and their DNS; only this // account's tenant association goes, so the owner can still delete the // identity later. // // The identity itself is deleted only when the claim says this // account's walk CREATED it, the same rule the orphan sweeps use. A // record written before that stamp existed may have adopted somebody // else's identity (the #216 attack itself, or an operator domain // shared with another product), and nothing on it says which; such an // identity is left in SES. Leaking it is the safe direction. claim, err := getDomainClaim(ctx, s, cur.Domain) if err != nil { return detachNone, nil, err } ours := claim == nil || claim.Account == acct var orphaned []dnsrec.Record for region, st := range cur.Regions { if !ours || st.Adopted { if st.IdentityARN != "" { if err := p.DisassociateTenantResource(ctx, TenantName(acct), st.IdentityARN); err != nil { log.Printf("mail: disassociating tenant resource for %s/%s: %v", acct, cur.Domain, err) } } continue } records := RecordSet(cur.Domain, region, st.DKIMTokens, d.DMARCRua) // Same w != nil guard as EnsureDomain's publish branch, for the // identical reason: a caller with no DNSWriter configured must not // panic on a nil interface here either. A platform domain with no // writer degrades to "orphaned" exactly like a custom one — the // records exist somewhere this process cannot reach, which is the // honest thing to report. if cur.Kind == KindPlatform && w != nil { for _, r := range records { if err := w.DeleteRecord(ctx, r); err != nil { return detachNone, nil, err } } } else { orphaned = append(orphaned, records...) } // SES refuses to delete an identity while a tenant association // exists, so the association is attempted first. The account's // configuration set is NOT disassociated: it is per account and // outlives any one app. // // A failed disassociate is logged and NOT a hard error (I2, review // follow-up, reversed from that task's first cut). The control-plane // role needs ses:DeleteTenantResourceAssociation (spec §4.2) and // that grant does not live in this repo; if it is missing, // AccessDenied is neither AlreadyExists nor NotFound, so a // `return err` here would fail EVERY release where this app is the // last referrer of a domain, and — worse — leave releaseDomainClaim // and s.Delete below unreached, which strands the domain's global // claim: nobody, including this domain's own owner, could ever claim // it again until the grant landed. That is the exact permanent // stranding `carlos email domains rm` exists to end, so this door // must not reintroduce it. DeleteIdentity is the real protection SES // itself provides (confirmed against the live API, spec §4.1: it // refuses to delete an identity that still has a tenant // association), so it stays a hard failure below and does the job // this disassociate call cannot be trusted to gate. if st.IdentityARN != "" { if err := p.DisassociateTenantResource(ctx, TenantName(acct), st.IdentityARN); err != nil { log.Printf("mail: disassociating tenant resource for %s/%s: %v", acct, cur.Domain, err) } } if claim != nil && claim.createdIdentityIn(region) { if err := p.DeleteIdentity(ctx, region, cur.Domain); err != nil { return detachNone, nil, err } } } // The global claim (Task 17a, spec §4a) releases HERE, in the same // branch as deleting the account's own domain record — the exact // moment the last app referencing this domain has gone. Releasing any // earlier (e.g. at the top of Teardown, or keyed off "this app is // going" rather than "the domain record is going") would let a stranger // claim a domain a sibling app is still sending from — the same class // of bug the `len(cur.Apps) > 0` branch above guards. // // RELEASE BEFORE DELETE (I1, review follow-up), reversed from this // task's first cut. The shared resource the claim protects is the SES // identity, and by this point in the branch it is ALREADY destroyed // (DeleteIdentity, above) — so releasing the claim before the record is // gone hands nothing live to a winner. The reverse order // (delete-then-release, the original cut) has a real defect: if the // process dies between the two, the record is gone, so // ListDomains(acct) on any later Teardown run never lists this domain // again — the release call becomes permanently unreachable, and the // claim is stranded forever, un-reclaimable even by its own owner's // account under a different app. Releasing FIRST means a crash before // the record delete just repeats a no-op release (idempotent) and a // retriable s.Delete on the next run — self-healing again, matching // every other step here. if err := releaseDomainClaim(ctx, s, cur.Domain, acct); err != nil { return detachNone, nil, err } if err := s.Delete(ctx, DomainKey(acct, cur.Domain)); err != nil { return detachNone, nil, err } return detachTornDown, orphaned, nil } // Teardown dismantles an app's email estate: credentials, principals, and // its claim on every domain it referenced. // // A domain's SES identity and DNS are removed only when the LAST app // referencing it goes. Getting that backwards revokes a sibling app's // verified domain — silently, and with the customer's own DNS still // pointing at an identity that no longer exists. // // NOT SELF-HEALING across a crash (I5): a crash between the DeleteUser calls // below and the domain loop leaves a zombie — the IAM user is gone, the // record still says Provisioned, and Teardown never unset the app's SMTP // env, so the stale delivered *_SMTP_USER still equals rec.Provisioned.KeyID // and EnvDelivered stays satisfied on that comparison. Enable therefore // short-circuits rather than recreate the user, and Rotate fails at MintKey // because there is no user left to mint against. Nothing re-runs Teardown // automatically on a half-failure; the caller must retry it to completion. // A reconciler that notices and repairs a half-torn-down app is out of // scope here. func claimUnserializedTeardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, d Deployment, acct, app string) error { // Enabled: complete inventories (C6). A record or claim that cannot be // read must stop the teardown, not be skipped and outlive it. Checked // before the first effect, and read again where each list is used. _, strict := s.(ClaimMailStore) listDomains, listClaims := ListDomains, ListDomainClaims if strict { listDomains = StrictListDomains listClaims = func(ctx context.Context, s release.Store) ([]DomainClaim, error) { return StrictDomainClaims(ctx, s) } if _, err := listDomains(ctx, s, acct); err != nil { return err } if _, err := listClaims(ctx, s); err != nil { return err } } rec, _, err := Get(ctx, s, acct, app) if err != nil { return err } if rec != nil { if rec.Provisioned != nil { // The index sits outside apps///, so PurgeApp's // prefix sweep never reaches it. Without this delete a stale // entry would attribute SES events to a purged app and insert // counts for it. if err := DeleteCallerIndex(ctx, s, rec.Provisioned.IAMUser, acct, app, SenderAppID); err != nil { return err } if err := p.DeleteUser(ctx, rec.Provisioned.IAMUser); err != nil { return err } } for _, sa := range rec.Standalone { if err := DeleteCallerIndex(ctx, s, sa.IAMUser, acct, app, SenderAppID); err != nil { return err } if err := p.DeleteUser(ctx, sa.IAMUser); err != nil { return err } } } // The console's own sending domain (ConsoleMailApp) is never torn down // with an app: its record, claim, identity and DNS are the console's, // and an app that reached the reserved name by a move or a migrate is // not their owner. Its credentials above and its records below still go. if isConsoleMail(acct, app) { return deleteMailRecords(ctx, s, acct, app, rec) } domains, err := listDomains(ctx, s, acct) if err != nil { return err } for _, dom := range domains { // The re-read happens ONCE, before any branch, and every decision // below is made on the fresh copy (cur), never on the stale one // (dom) ListDomains returned. Two things depend on that: // // - F6: an empty etag is CREATE-ONLY, and every one of these // records already exists, so a CAS position has to come from a // fresh read. // - I4: without a fresh read, the DESTROY branch below had no CAS // and no re-check at all — s.Delete was unconditional. A sibling // app joining this domain between the ListDomains call and this // decision would have its identity, DKIM tokens and (for a // custom domain) hand-published DNS destroyed out from under it. // Deciding on cur.Apps closes that window: a join that lands // before this read is seen and the domain survives instead. cur, curEtag, err := GetDomain(ctx, s, acct, dom.Domain) if err != nil { return err } if cur == nil { continue // gone since the list; nothing to do } // Everything from here — the sibling check, the identity and DNS, // the claim, the record — is detachDomainApp's, shared verbatim // with RemoveDomain so the two release paths cannot drift on an // ordering that is load-bearing and subtle. A domain this app never // referred to is that function's own no-op, which is what the // `continue` used to be. if _, _, err := detachDomainApp(ctx, s, p, w, d, acct, app, cur, curEtag); err != nil { return err } } // Orphaned claims (I1's second, far more ordinary strand): a claim can // exist with NO backing domain record whenever EnsureDomain fails // AFTER claimDomain but before PutDomain ever runs — an SES throttle, // a tenant limit, a bad region, anything. That domain is never listed // by ListDomains(acct) above (there is no record), so the loop just // run never sees it, and without this pass it would never be // reachable by any release verb at all: the member's only recourse is // Teardown, and Teardown had nothing to walk it from. // // SCOPED TO THIS APP (I1 item 1, review round 2), corrected from this // task's first cut, which swept every claim the ACCOUNT held with no // record, regardless of which app attempted it. That is wrong: an // unrelated Teardown(acct, someOtherApp) would sweep up a DIFFERENT // app's claim that is not abandoned at all — just waiting on an // ordinary retry. The window where this matters is NOT a concurrency // race (microseconds between goroutines); it is however long the // member takes to retry — a quota bump or a support ticket, hours, not // microseconds — and nothing durable told the sweep "abandoned" from // "about to be retried" apart from which app the claim belongs to. // DomainClaim.App now carries that attribution (set once, at claimDomain's // first write, never rewritten by a later idempotent re-claim), so the // sweep only ever releases a claim THIS app created. claims, err := listClaims(ctx, s) if err != nil { return err } for _, c := range claims { if c.Account != acct || c.App != app { continue } dr, _, err := GetDomain(ctx, s, acct, c.Domain) if err != nil { return err } if dr != nil { continue // a record exists; the domain loop above already handles it } // DESTROY THE IDENTITY BEFORE RELEASING (I1 item 2, review round // 2). If the crash that left this claim orphaned landed AFTER // EnsureIdentity but BEFORE PutDomain, a live SES identity exists // with no record naming it — and releasing the claim without // deleting it hands a stranger that identity's ARN and its // already-minted DKIM tokens the moment they claim the domain, // with no DNS proof of their own. EnsureDomain only ever mints an // identity in d.Region (SMTPHost's own doc comment: one region, // fleet-wide), so that is the only region this needs to check. // DeleteIdentity is idempotent — "already gone" is success — so // this is free when no identity was ever actually created. // // Only an identity the claim says this walk CREATED (#216). One it // found already there was never adopted (EnsureDomain refuses and // releases such a claim), so it is somebody else's. if c.createdIdentityIn(d.Region) { if err := p.DeleteIdentity(ctx, d.Region, c.Domain); err != nil { return err } } if err := releaseDomainClaim(ctx, s, c.Domain, acct); err != nil { return err } } return deleteMailRecords(ctx, s, acct, app, rec) } // deleteMailRecords is Teardown's last step: the app's own email records. func deleteMailRecords(ctx context.Context, s release.Store, acct, app string, rec *Record) error { if rec != nil { // Both objects: the sender the app is on, and the legacy one the // migration deliberately leaves standing until the sweep. Leaving // email.json behind here would resurrect the app's whole email // record on the next Get. if err := s.Delete(ctx, Key(acct, app)); err != nil { return err } if err := s.Delete(ctx, LegacyKey(acct, app)); err != nil { return err } } return nil } // DomainRemoval is what RemoveDomain did, so a door can describe it without // re-reading anything the walk has just deleted. type DomainRemoval struct { Domain string Kind string // TornDown is true when this app was the LAST referrer: the SES // identity, the account's domain record and the global claim are all // gone, and the domain is free for any account to claim again. TornDown bool // Remaining is the apps that still refer to the domain — non-empty // exactly when TornDown is false, and the reason nothing else was // touched. Remaining []string // Orphaned is the record set nobody deleted: a custom domain's DNS // lives in the customer's zone and is the customer's to remove // (detachDomainApp's rule, which this inherits from Teardown). Empty // for a platform domain, whose records CARLOS published and has now // deleted itself. // // Only the DKIM CNAMEs in here are token-derived and therefore dead: // the MX, SPF and DMARC records RecordSet builds are deterministic // functions of the domain and region, so they stay valid if the domain // is ever re-created. Orphaned []dnsrec.Record // ClaimOnly is the orphaned-claim repair: there was no domain record at // all — only a global claim stranded by an EnsureDomain that failed // after claimDomain and before PutDomain — and releasing it is the // whole of what happened. ClaimOnly bool } // RemoveDomain is the release door the claim always needed: it stops app // referring to domain and, when that was the last reference, dismantles the // domain and releases its global claim. // // Before this existed, Teardown — the PERMANENT deletion of a whole app — // was the only thing that could ever release a claim, so a domain claimed // by the wrong app was unreachable to every other account, including the // one that actually controls its DNS, for the life of the deployment. // // It shares detachDomainApp with Teardown rather than repeating it, so the // safety properties are the same objects, not the same intentions: // // - a domain a sibling app still refers to keeps its identity, its DNS // and its claim; only the referrer set shrinks; // - the claim is released only after DeleteIdentity, and only before the // record delete (see detachDomainApp for why each of those is the way // round it is); // - a custom domain's DNS is never deleted, it is reported back. // // Two refusals are its own, and neither is Teardown's business: // // - ErrDomainIsFrom, the domain the app currently sends FROM. Enable // refuses a From that is not at a granted domain, so removing it would // turn a working app into one that fails at its next re-enable — a // breakage discovered at runtime instead of here. Move the From first. // - ErrDomainNotReferenced, a domain this app does not use. Teardown // walks an app's whole estate and a non-referrer is simply skipped; // here the operator NAMED this domain, so silence would be a lie. // // OWNER-GATING IS UNCHANGED. Nothing here reads, probes or releases another // account's claim: releaseDomainClaim refuses a claim held by anyone else // as a quiet no-op, the orphan branch below compares the claim's account // itself, and no error message ever names a holder. // // A non-nil *DomainRemoval WITH a non-nil error means one specific thing: // the removal itself landed, and only the departing app's IAM policy shrink // (RefreshPolicies) failed. Re-running this verb would then answer // ErrDomainNotReferenced, correctly — the owed repair is `carlos email // enable`, which rewrites that policy wholesale on every call. func claimUnserializedRemoveDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, d Deployment, acct, app, domain string) (*DomainRemoval, error) { if !release.ValidAccount(acct) { return nil, fmt.Errorf("mail: invalid account %q", acct) } if !release.ValidApp(app) { return nil, fmt.Errorf("mail: invalid app %q", app) } if !ValidDomain(domain) { return nil, fmt.Errorf("mail: invalid domain %q", domain) } // The console's own referrer never releases its domain (see Teardown). if isConsoleMail(acct, app) { return nil, fmt.Errorf("mail: %s: %w", domain, ErrConsoleMailReserved) } // The From check runs FIRST, before anything is read about the domain // and long before anything is written: a refusal must cost the caller // nothing and leave no half-step behind. It looks at THIS app's From // only — a sibling app keeps its own referral, so its From is still at // a domain it is granted. rec, _, err := Get(ctx, s, acct, app) if err != nil { return nil, err } if rec != nil && rec.Declared != nil { if at := strings.LastIndex(rec.Declared.From, "@"); at >= 0 && rec.Declared.From[at+1:] == domain { return nil, fmt.Errorf("mail: %s still sends as %s: %w", app, rec.Declared.From, ErrDomainIsFrom) } } cur, curEtag, err := GetDomain(ctx, s, acct, domain) if err != nil { return nil, err } if cur == nil { return removeOrphanedClaim(ctx, s, p, d, acct, domain) } res, orphaned, err := detachDomainApp(ctx, s, p, w, d, acct, app, cur, curEtag) if err != nil { return nil, err } if res == detachNotAReferrer { return nil, fmt.Errorf("mail: %s does not send from %s: %w", app, domain, ErrDomainNotReferenced) } out := &DomainRemoval{Domain: domain, Kind: cur.Kind, TornDown: res == detachTornDown, Orphaned: orphaned} if res == detachKept { out.Remaining = append([]string(nil), cur.Apps...) } // SHRINK THE DEPARTING APP'S POLICY, the mirror of EnsureDomain's own // RefreshPolicies call. EnsureUser writes a principal's policy // wholesale from the CURRENT granted domain set, and nothing else // recomputes it when a domain LEAVES that set — so without this, an app // that drops a domain a sibling still keeps alive goes on holding // ses:SendRawEmail at a live identity ARN it no longer has any claim // to, indefinitely. That is a real over-grant, not a stale string. if err := RefreshPolicies(ctx, s, p, acct, []string{app}); err != nil { return out, err } return out, nil } // removeOrphanedClaim is RemoveDomain's branch for a claim with NO backing // domain record — the state EnsureDomain leaves behind when it fails after // claimDomain and before PutDomain (an SES throttle, a tenant limit, a bad // region). Before this, only Teardown could sweep one, and only for the app // that created it; an operator who hit it on an app they did not want to // delete had no recourse at all. // // The app in the path is a MEMBERSHIP GATE here, not an ownership filter — // the same reading handleAPIEmailDomainVerify documents, and appropriate // for the same reason: the claim is account-scoped, not app-scoped, and // unlike Teardown's blind sweep the operator has NAMED this exact domain, // so there is no "a sibling app is merely about to retry" ambiguity for the // App attribution to resolve. // // DESTROY THE IDENTITY BEFORE RELEASING (I1 item 2), exactly as Teardown's // own sweep does: if the crash that orphaned this claim landed after // EnsureIdentity but before PutDomain, a live SES identity exists that no // record names, and releasing without deleting it hands a stranger that // identity's ARN and its already-minted DKIM tokens the moment they claim // the domain. EnsureDomain only ever mints in d.Region, so that is the only // region to check, and DeleteIdentity is idempotent. // // NO DISASSOCIATE HERE, unlike detachDomainApp's region loop, and that is // not an omission. EnsureDomain associates the identity with the account's // tenant only AFTER PutDomain — deliberately, so that a claim with no // record can never have an association behind it (see the ordering comment // at that call). An identity reachable from this branch is therefore always // unassociated, and DeleteIdentity takes it. func removeOrphanedClaim(ctx context.Context, s release.Store, p Provisioner, d Deployment, acct, domain string) (*DomainRemoval, error) { claim, err := getDomainClaim(ctx, s, domain) if err != nil { return nil, err } // A claim held by ANOTHER account reads exactly like no claim at all — // the same refusal, the same words. Anything else would make this door // an oracle for who holds a domain the caller does not. if claim == nil || claim.Account != acct { return nil, fmt.Errorf("mail: no sending domain %s for this account: %w", domain, ErrDomainNotReferenced) } // The console's own claim with no record is an ensure the console will // retry; releasing it here would delete the identity from under it. if isConsoleMail(acct, claim.App) { return nil, fmt.Errorf("mail: %s: %w", domain, ErrConsoleMailReserved) } // Only an identity this claim's walk created, as in Teardown's sweep. if claim.createdIdentityIn(d.Region) { if err := p.DeleteIdentity(ctx, d.Region, domain); err != nil { return nil, err } } if err := releaseDomainClaim(ctx, s, domain, acct); err != nil { return nil, err } return &DomainRemoval{ Domain: domain, Kind: DomainKindFor(d, domain), TornDown: true, ClaimOnly: true, }, nil } func EnsureDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, d Deployment, acct, app, domain string, now time.Time) (*DomainRecord, []dnsrec.Record, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedEnsureDomain(ctx, s, p, w, d, acct, app, domain, now) } var claimResult0 *DomainRecord var claimResult1 []dnsrec.Record claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}, Domain: domain, Refresh: true}, func(ctx context.Context) error { if p != nil { p = claimMarkedProvisioner{raw: p} } if w != nil { w = claimMarkedWriter{raw: w} } var err error claimResult0, claimResult1, err = claimUnserializedEnsureDomain(ctx, s, p, w, d, acct, app, domain, now) return err }) }) return claimResult0, claimResult1, claimErr } func RefreshPolicies(ctx context.Context, s release.Store, p Provisioner, acct string, apps []string) error { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedRefreshPolicies(ctx, s, p, acct, apps) } claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: apps, Refresh: true}, func(ctx context.Context) error { if p != nil { p = claimMarkedProvisioner{raw: p} } return claimUnserializedRefreshPolicies(ctx, s, p, acct, apps) }) }) return claimErr } func VerifyDomain(ctx context.Context, s release.Store, p Provisioner, d Deployment, acct, domain, region string, now time.Time) (*DomainRecord, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedVerifyDomain(ctx, s, p, d, acct, domain, region, now) } var claimResult0 *DomainRecord claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Domain: domain, Refresh: true}, func(ctx context.Context) error { if p != nil { p = claimMarkedProvisioner{raw: p} } var err error claimResult0, err = claimUnserializedVerifyDomain(ctx, s, p, d, acct, domain, region, now) return err }) }) return claimResult0, claimErr } func Declare(ctx context.Context, s release.Store, acct, app, envPrefix, from, actor string, now time.Time) (*Record, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedDeclare(ctx, s, acct, app, envPrefix, from, actor, now) } var claimResult0 *Record claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { var err error claimResult0, err = claimUnserializedDeclare(ctx, s, acct, app, envPrefix, from, actor, now) return err }) }) return claimResult0, claimErr } func Enable(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, d Deployment, acct, app string, now time.Time) (*Record, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedEnable(ctx, s, p, sealPub, d, acct, app, now) } var claimResult0 *Record claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}, Refresh: true}, func(ctx context.Context) error { if p != nil { p = claimMarkedProvisioner{raw: p} } var err error claimResult0, err = claimUnserializedEnable(ctx, s, p, sealPub, d, acct, app, now) return err }) }) return claimResult0, claimErr } func ApplyGrant(ctx context.Context, s release.Store, acct, app string, domains []string, dailyCap int, source, actor string, now time.Time) (*Record, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedApplyGrant(ctx, s, acct, app, domains, dailyCap, source, actor, now) } var claimResult0 *Record claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { var err error claimResult0, err = claimUnserializedApplyGrant(ctx, s, acct, app, domains, dailyCap, source, actor, now) return err }) }) return claimResult0, claimErr } func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, d Deployment, acct, app string, now time.Time) (*Record, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedRotate(ctx, s, p, sealPub, d, acct, app, now) } var claimResult0 *Record claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { if p != nil { p = claimMarkedProvisioner{raw: p} } var err error claimResult0, err = claimUnserializedRotate(ctx, s, p, sealPub, d, acct, app, now) return err }) }) return claimResult0, claimErr } func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app string) (*Record, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedRotateFinish(ctx, s, p, acct, app) } var claimResult0 *Record claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { if p != nil { p = claimMarkedProvisioner{raw: p} } var err error claimResult0, err = claimUnserializedRotateFinish(ctx, s, p, acct, app) return err }) }) return claimResult0, claimErr } func SetPaused(ctx context.Context, s release.Store, p Provisioner, acct, app string, paused bool, kind string, now time.Time) (*Record, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedSetPaused(ctx, s, p, acct, app, paused, kind, now) } var claimResult0 *Record claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { if p != nil { p = claimMarkedProvisioner{raw: p} } var err error claimResult0, err = claimUnserializedSetPaused(ctx, s, p, acct, app, paused, kind, now) return err }) }) return claimResult0, claimErr } func SetSenderPaused(ctx context.Context, s release.Store, p Provisioner, acct, app, sender string, paused bool, kind string, now time.Time) (*Sender, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedSetSenderPaused(ctx, s, p, acct, app, sender, paused, kind, now) } var claimResult0 *Sender claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { if p != nil { p = claimMarkedProvisioner{raw: p} } var err error claimResult0, err = claimUnserializedSetSenderPaused(ctx, s, p, acct, app, sender, paused, kind, now) return err }) }) return claimResult0, claimErr } func CreateStandalone(ctx context.Context, s release.Store, p Provisioner, d Deployment, acct, app, name, actor string, now time.Time) (StandaloneCreds, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedCreateStandalone(ctx, s, p, d, acct, app, name, actor, now) } var claimResult0 StandaloneCreds claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}, Refresh: true}, func(ctx context.Context) error { if p != nil { p = claimMarkedProvisioner{raw: p} } var err error claimResult0, err = claimUnserializedCreateStandalone(ctx, s, p, d, acct, app, name, actor, now) return err }) }) return claimResult0, claimErr } func RevokeStandalone(ctx context.Context, s release.Store, p Provisioner, acct, app, name string) error { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedRevokeStandalone(ctx, s, p, acct, app, name) } claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { if p != nil { p = claimMarkedProvisioner{raw: p} } return claimUnserializedRevokeStandalone(ctx, s, p, acct, app, name) }) }) return claimErr } func Teardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, d Deployment, acct, app string) error { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedTeardown(ctx, s, p, w, d, acct, app) } claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}, AppDomains: app}, func(ctx context.Context) error { if p != nil { p = claimMarkedProvisioner{raw: p} } if w != nil { w = claimMarkedWriter{raw: w} } return claimUnserializedTeardown(ctx, s, p, w, d, acct, app) }) }) return claimErr } func RemoveDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, d Deployment, acct, app, domain string) (*DomainRemoval, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedRemoveDomain(ctx, s, p, w, d, acct, app, domain) } var claimResult0 *DomainRemoval claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}, Domain: domain, Refresh: true}, func(ctx context.Context) error { if p != nil { p = claimMarkedProvisioner{raw: p} } if w != nil { w = claimMarkedWriter{raw: w} } var err error claimResult0, err = claimUnserializedRemoveDomain(ctx, s, p, w, d, acct, app, domain) return err }) }) return claimResult0, claimErr }