| 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. |
| 4 | package mail |
| 5 | |
| 6 | import ( |
| 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 | |
| 22 | var ( |
| 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. |
| 61 | type 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. |
| 78 | func 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. |
| 113 | func 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. |
| 166 | func 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. |
| 418 | func 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). |
| 469 | func 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. |
| 484 | func 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). |
| 595 | const 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. |
| 607 | func 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. |
| 612 | func 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. |
| 655 | func 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. |
| 708 | func 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. |
| 748 | func 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 | |
| 881 | func 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. |
| 896 | func 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. |
| 934 | func 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. |
| 972 | type 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. |
| 987 | func 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. |
| 1003 | func 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. |
| 1057 | func 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. |
| 1082 | var 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. |
| 1087 | func 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. |
| 1120 | func 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. |
| 1161 | func 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 | |
| 1253 | var standaloneNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,30}$`) |
| 1254 | |
| 1255 | func validStandaloneName(n string) bool { return standaloneNameRe.MatchString(n) } |
| 1256 | |
| 1257 | // ValidStandaloneName is validStandaloneName for callers outside the package. |
| 1258 | func 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. |
| 1262 | func 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. |
| 1294 | type domainDetach int |
| 1295 | |
| 1296 | const ( |
| 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. |
| 1328 | func 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. |
| 1473 | func 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. |
| 1632 | func 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. |
| 1650 | type 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. |
| 1718 | func 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. |
| 1810 | func 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 | |
| 1842 | func 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 | |
| 1865 | func 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 | |
| 1880 | func 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 | |
| 1899 | func 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 | |
| 1915 | func 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 | |
| 1934 | func 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 | |
| 1950 | func 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 | |
| 1969 | func 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 | |
| 1987 | func 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 | |
| 2006 | func 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 | |
| 2025 | func 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 | |
| 2044 | func 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 | |
| 2059 | func 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 | |
| 2078 | func 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 | |