// The sender (senders design section 5): a From address, a credential, a // cap, a pause and a set of counts, owned by one record. // // Before this file an app had exactly one implicit sender and everything was // keyed by (account, app): email.json, the IAM user, the DailyCap, the pause, // the mail_counts rows. Two requests do not fit that shape (#147 sends as an // address under an identity CARLOS does not own, #148 gives one instance per // customer its own domain, credential, cap and pause), so the sender becomes // a thing with a name. This step gives every app exactly ONE sender, "app", // which is what it already had. Nothing here creates a second one. package mail import ( "context" "crypto/rand" "crypto/sha256" "encoding/base32" "encoding/hex" "encoding/json" "errors" "fmt" "log" "os" "regexp" "strings" "time" "github.com/carlosframework/platform/internal/release" ) // The two scopes and the two sources a sender can have. Scope says what the // sender belongs to; Source says where its authority to send comes from. An // app sender on a domain the account verified is the only combination this // release produces; the others are named here because SenderNames, the // status reply and the caller index all branch on them, and a closed set // that is written down once is a set nothing invents a third member of. const ( ScopeApp = "app" ScopeInstance = "instance" SourceDomain = "domain" SourceAddress = "address" ) // SenderAppID is the id of the sender every app has always had. It is a // constant rather than a literal because it is written into every migrated // record, read back by SenderOfCaller and compared in a dozen places. const SenderAppID = "app" // PauseKindPerInstance is the pause an app sender carries once its app has // moved to per-instance senders (design section 6.3). It is never a cap pause and // never auto-resumes: resumeStaleCapPause reactivates every key it touches, // and re-arming the app credential that every instance still holds is // exactly what per-instance mode exists to prevent. Nothing in this release // sets it; the refusals that read it are in place so step 5 cannot land // without them. const PauseKindPerInstance = "per-instance" // Sender is apps///email/senders/.json, one object per // sender, each with its own CAS etag so two writers never contend over a // sibling's state. type Sender struct { Schema int `json:"schema"` ID string `json:"id"` // "app", or provision.InstanceID(host) Slug string `json:"slug"` // the AWS-safe name component, see SenderNames Scope string `json:"scope"` // ScopeApp | ScopeInstance Host string `json:"host,omitempty"` Source string `json:"source"` // SourceDomain | SourceAddress // The four blocks keep their current shapes and move inside the sender // unchanged, which is what lets a legacy email.json decode straight into // one and what keeps every walk in walk.go compiling against Record. Declared *Declared `json:"declared,omitempty"` Grant *GrantBlock `json:"grant,omitempty"` Provisioned *Provisioned `json:"provisioned,omitempty"` Standalone []*Standalone `json:"standalone,omitempty"` Identity string `json:"identity,omitempty"` // external identity ARN (#147, step 3) Address string `json:"address,omitempty"` // the exact address, lowercased (#147, step 3) TenantARN string `json:"tenant_arn,omitempty"` // from CreateTenant // PerInstance marks an app sender whose app has moved to per-instance // senders (design section 6.3). Nothing in this release sets it. It lives on // the app sender rather than being derived from a prefix listing // because a listing cannot decide the question without racing an // app-sender enable, and the transition has to be one CAS write. PerInstance bool `json:"per_instance,omitempty"` // Salt disambiguates a slug collision. The slug is 40 bits of a hash, so // two senders can in principle want the same IAM user name; the caller // index detects that and ResolveSenderNames recomputes the slug with a // fresh salt, which has to be stored or the name would not be stable // across the next enable. Salt string `json:"salt,omitempty"` } // SendersPrefix is where an app's senders live. Inside apps/// on // purpose: release.PurgeApp's prefix sweep reaches it, so a purged app // leaves no sender behind. func SendersPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "email/senders/" } func SenderKey(acct, app, id string) string { return SendersPrefix(acct, app) + id + ".json" } // senderIDRe is deliberately stricter than a host name: this string becomes // a bucket key segment and half an IAM user name, and the only safe place to // refuse a separator or a traversal is the one gate every caller passes // through. It accepts a host (provision.InstanceID returns the host itself // when it is short enough) and the underscore-prefixed digest form // InstanceID falls back to for a long one. var senderIDRe = regexp.MustCompile(`^_?[a-z0-9]([a-z0-9.-]{0,62}[a-z0-9])?$`) // ValidSenderID reports whether id is usable as a sender id. ".json" is // refused along with every other dotted suffix that would collide with the // key's own extension only incidentally: what actually refuses it is the // check below, which is why it is spelled out. func ValidSenderID(id string) bool { if id == "" || len(id) > 64 || id == "." || id == ".." { return false } if !senderIDRe.MatchString(id) { return false } return !hasJSONSuffix(id) } func hasJSONSuffix(id string) bool { return len(id) > 5 && id[len(id)-5:] == ".json" } // The AWS bounds this file exists to respect. IAM caps a user name at 64 // characters and SES caps a tenant name and a configuration-set name at 64 // of [A-Za-z0-9_-]. The first draft of the senders design got this wrong: // carlos-mail--+i+ reaches 104 characters when the // instance id is the host itself, which it is for any host of 40 characters // or less. const ( iamUserNameMax = 64 sesNameMax = 64 // senderAppComponentMax truncates the app name inside a slugged sender's // names. 12 + 16 + 1 + 24 + 3 + 8 = 64 exactly, at the longest legal // account and app. Uniqueness comes from the hash, not from the app // component, so truncating it costs nothing but readability. senderAppComponentMax = 24 // senderSlugLen is 40 bits of base32. Short enough to fit, long enough // that a collision is rare, and detected either way by the caller index. senderSlugLen = 8 ) // ErrNameTooLong is the refusal at create for a name no AWS bound can hold. // It is refused rather than truncated because a truncated name would // collide silently with its neighbour, and because the only name this can // happen to today is a standalone credential's, whose name the member chose // and can choose differently. var ErrNameTooLong = errors.New("mail: name exceeds the AWS bound") // SenderNameSet is every AWS name one sender owns. type SenderNameSet struct { Slug string // "" for the app sender, 8 base32 characters otherwise IAMUser string Tenant string ConfigSet string } // SenderNames is THE rule for what a sender's AWS resources are called, and // there is exactly one of it so that a bounds test can enumerate every form. // // The app sender's names are unchanged, deliberately and permanently: // carlos-mail--, tenant and configuration set carlos-. // Renaming an SES tenant means destroying it and recreating it along with // every association it carries, and rewriting an IAM user name means a new // principal with the old one left live and stale. So the rule is that // existing shapes never change and a name that would exceed a bound is // refused at create. // // Configuration sets stay per account even for a slugged sender: an identity // has only one default set, and SES has no tenant-level default, so // per-sender sets on a shared identity are impossible (design ยง3). func SenderNames(acct, app, senderID, salt string) (SenderNameSet, error) { if !release.ValidAccount(acct) { return SenderNameSet{}, fmt.Errorf("mail: invalid account %q", acct) } if !release.ValidApp(app) { return SenderNameSet{}, fmt.Errorf("mail: invalid app %q", app) } if !ValidSenderID(senderID) { return SenderNameSet{}, fmt.Errorf("mail: invalid sender id %q", senderID) } n := SenderNameSet{Tenant: TenantName(acct), ConfigSet: ConfigSetName(acct)} if senderID == SenderAppID { n.IAMUser = UserName(acct, app) return n, checkNames(n) } n.Slug = senderSlug(acct, app, senderID, salt) short := app if len(short) > senderAppComponentMax { short = short[:senderAppComponentMax] } n.IAMUser = "carlos-mail-" + acct + "-" + short + "+i+" + n.Slug n.Tenant = "carlos-" + acct + "-" + short + "-" + n.Slug return n, checkNames(n) } // StandaloneUser is the name of one displayed credential belonging to this // sender, bound-checked. The rendered string is byte-identical to // StandaloneUserName for an app sender, because RefreshPolicies recomputes // the name rather than reading sa.IAMUser (walk.go:364): a rule that // rendered anything else would write a policy for a principal that does not // exist and leave the live one stale. func (n SenderNameSet) StandaloneUser(acct, app, credential string) (string, error) { if !validStandaloneName(credential) { return "", fmt.Errorf("mail: invalid credential name %q", credential) } name := StandaloneUserName(acct, app, credential) if n.Slug != "" { name = n.IAMUser + "+s+" + credential } if len(name) > iamUserNameMax { return "", fmt.Errorf("mail: credential name %q makes the IAM user %d characters, over IAM's %d: choose a shorter name: %w", credential, len(name), iamUserNameMax, ErrNameTooLong) } return name, nil } func checkNames(n SenderNameSet) error { if len(n.IAMUser) > iamUserNameMax { return fmt.Errorf("mail: IAM user %q is %d characters, over IAM's %d: %w", n.IAMUser, len(n.IAMUser), iamUserNameMax, ErrNameTooLong) } for _, name := range []string{n.Tenant, n.ConfigSet} { if len(name) > sesNameMax { return fmt.Errorf("mail: SES name %q is %d characters, over SES's %d: %w", name, len(name), sesNameMax, ErrNameTooLong) } } return nil } // senderSlug is 40 bits of base32 over (account, app, sender, salt), // lowercased so it reads like every other name this package renders. Nothing // parses it back, which is why the caller index exists. func senderSlug(acct, app, id, salt string) string { sum := sha256.Sum256([]byte(acct + "/" + app + "/" + id + "/" + salt)) enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:]) return strings.ToLower(enc[:senderSlugLen]) } // SenderOwnsUserName reports whether user is a name that sender id of // (acct, app) can own, by FORWARD rendering only: nothing is parsed back out // of user at a hyphen or at "+s+". It is what the console's backend boundary // checks a caller index against, so an index can never move a principal out // of the namespace its own (acct, app, sender) renders. // // The app sender owns exactly UserName(acct, app) and every // StandaloneUserName(acct, app, n) for a valid credential name n. A slugged // sender owns the names SenderNames renders for SOME salt: the prefix // carlos-mail--+i+, then an 8 character slug, then // optionally +s+ and a valid credential name. The salt is not known here (a // moved salt is written back only after the index), so the slug's shape is // checked and the create-only index entry is what binds the exact name. // The forms cannot collide across owners: an account sqid is lowercase // letters only, and neither an app name nor a credential name can contain // "+". func SenderOwnsUserName(acct, app, id, user string) bool { if id == SenderAppID { if user == UserName(acct, app) { return true } prefix := StandaloneUserName(acct, app, "") cred, ok := strings.CutPrefix(user, prefix) return ok && validStandaloneName(cred) && StandaloneUserName(acct, app, cred) == user } n, err := SenderNames(acct, app, id, "") if err != nil || len(n.IAMUser) <= senderSlugLen { return false } rest, ok := strings.CutPrefix(user, n.IAMUser[:len(n.IAMUser)-senderSlugLen]) if !ok || len(rest) < senderSlugLen || !validSenderSlug(rest[:senderSlugLen]) { return false } slugged := user[:len(user)-len(rest)+senderSlugLen] if len(rest) == senderSlugLen { return len(user) <= iamUserNameMax } cred, ok := strings.CutPrefix(rest[senderSlugLen:], "+s+") return ok && validStandaloneName(cred) && slugged+"+s+"+cred == user && len(user) <= iamUserNameMax } // validSenderSlug is senderSlug's output alphabet and length: lowercased // unpadded base32. func validSenderSlug(s string) bool { if len(s) != senderSlugLen { return false } for _, c := range s { if !(c >= 'a' && c <= 'z') && !(c >= '2' && c <= '7') { return false } } return true } // callersPrefix is the reverse index from an IAM caller name to the sender // that owns it. GLOBAL, not under apps///, because SES hands the // ingest a bare user name with no account attached and the ingest has // nowhere to start looking. The price of that placement is that // release.PurgeApp's prefix sweep never reaches it, so every entry has an // explicit delete: without one, a stale entry would attribute SES events to // a purged app and insert counts for it. const callersPrefix = "email/callers/" func CallerKey(iamUser string) string { return callersPrefix + iamUser + ".json" } // CallerIndex is the wire shape at CallerKey(IAMUser). type CallerIndex struct { Schema int `json:"schema"` // 1 IAMUser string `json:"iam_user"` Account string `json:"account"` App string `json:"app"` Sender string `json:"sender"` CreatedAt time.Time `json:"created_at"` } // ErrCallerClaimed means this IAM name already belongs to a different // sender. For a slugged name that is a hash collision and // ResolveSenderNames recomputes with a salt; for an unslugged one it is a // genuine conflict and the caller surfaces it. var ErrCallerClaimed = errors.New("mail: caller name belongs to another sender") // GetCallerIndex reads one entry. Absent is (nil, nil), matching every other // read in this package. func GetCallerIndex(ctx context.Context, s release.Store, iamUser string) (*CallerIndex, error) { raw, _, err := s.Get(ctx, CallerKey(iamUser)) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, err } var c CallerIndex if err := json.Unmarshal(raw, &c); err != nil { return nil, fmt.Errorf("mail: caller index %s: %w", iamUser, err) } if c.Schema > 1 { return nil, fmt.Errorf("mail: caller index %s: schema %d is newer than this binary understands, upgrade carlos", iamUser, c.Schema) } return &c, nil } // PutCallerIndex claims iamUser for one sender. Create-only, with // claimDomain's rule (domainclaim.go:119): the same triple is idempotent // success, any other triple is ErrCallerClaimed. // // Written right AFTER EnsureUser, which runs on every enable, so a // re-enable converges it. Writing it before MintKey would be no better and // writing it before EnsureUser would be worse: a crash between the index // write and the user's creation would strand the name against a principal // that does not exist. func claimUnserializedPutCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string, now time.Time) error { body, err := json.Marshal(CallerIndex{Schema: 1, IAMUser: iamUser, Account: acct, App: app, Sender: sender, CreatedAt: now}) if err != nil { return err } for attempt := 0; ; attempt++ { cur, err := GetCallerIndex(ctx, s, iamUser) if err != nil { return err } if cur == nil { if _, err := s.Put(ctx, CallerKey(iamUser), body, ""); err == nil { return nil } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { continue // lost the create race, re-read and judge the winner } else { return err } } if cur.Account != acct || cur.App != app || cur.Sender != sender { return ErrCallerClaimed } return nil // already ours } } // DeleteCallerIndex removes an entry, but only when it is held by this // sender, the same owner gate releaseDomainClaim applies. An entry already // absent is a quiet no-op, so a retried teardown is not an error. func claimUnserializedDeleteCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string) error { cur, err := GetCallerIndex(ctx, s, iamUser) if err != nil || cur == nil { return err } if cur.Account != acct || cur.App != app || cur.Sender != sender { return nil } return s.Delete(ctx, CallerKey(iamUser)) } // ResolveSenderNames renders a sender's names and claims them in the caller // index, recomputing a slugged name with a fresh salt on a collision. // // It reports whether the salt moved, because a moved salt has to be written // back to the sender record under its own CAS or the next enable computes a // different name again and the estate grows an orphan. func claimUnserializedResolveSenderNames(ctx context.Context, s release.Store, acct, app string, sn *Sender, now time.Time) (SenderNameSet, bool, error) { salted := false for attempt := 0; attempt < 4; attempt++ { names, err := SenderNames(acct, app, sn.ID, sn.Salt) if err != nil { return SenderNameSet{}, salted, err } err = PutCallerIndex(ctx, s, names.IAMUser, acct, app, sn.ID, now) if err == nil { sn.Slug = names.Slug return names, salted, nil } if !errors.Is(err, ErrCallerClaimed) { return SenderNameSet{}, salted, err } if names.Slug == "" { // An unslugged name is deterministic, so a conflict here is not // a hash collision: another account or app is genuinely using // this principal, and a salt would not help. return SenderNameSet{}, salted, fmt.Errorf("mail: IAM user %s is already claimed by another sender: %w", names.IAMUser, err) } buf := make([]byte, 4) if _, err := rand.Read(buf); err != nil { return SenderNameSet{}, salted, err } sn.Salt = hex.EncodeToString(buf) salted = true } return SenderNameSet{}, salted, fmt.Errorf("mail: could not find a free IAM name for sender %s/%s/%s after four attempts", acct, app, sn.ID) } // SenderOfCaller resolves the IAM identity SES stamps on an event // (mail.tags["ses:caller-identity"]) to the sender that owns it. // // PARSE FIRST, in two steps, and the order is load-bearing. AppOfCaller // answers for carlos-mail-- and its +s+ standalones, which is // every sender that exists today, and it costs nothing. Only a slugged +i+ // name, which is a hash and unparseable by construction, reaches the index // Get. Every app enabled today short-circuits Enable before MintKey // (walk.go:677), so it has no index entry until it next rotates: a Get-only // resolver would silently stop writing the by_sender log rows that // sesevents.go writes now, for every app in the fleet, on the roll. // // An unreadable index entry is logged and answers "unknown", which puts the // event back on the domain-wide fallback rather than dropping it. func SenderOfCaller(ctx context.Context, s release.Store, caller string) (string, string, string, bool) { if acct, app, ok := AppOfCaller(caller); ok { return acct, app, SenderAppID, true } if !strings.Contains(caller, "+i+") { return "", "", "", false } name := caller if i := strings.LastIndex(name, "/"); i >= 0 { name = name[i+1:] } idx, err := GetCallerIndex(ctx, s, name) if err != nil { log.Printf("mail: caller index %s -> %v", name, err) return "", "", "", false } if idx == nil || !release.ValidAccount(idx.Account) || !release.ValidApp(idx.App) || !ValidSenderID(idx.Sender) { return "", "", "", false } return idx.Account, idx.App, idx.Sender, true } func PutCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string, now time.Time) error { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedPutCallerIndex(ctx, s, iamUser, acct, app, sender, now) } claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { return claimUnserializedPutCallerIndex(ctx, s, iamUser, acct, app, sender, now) }) }) return claimErr } func DeleteCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string) error { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedDeleteCallerIndex(ctx, s, iamUser, acct, app, sender) } claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { return claimUnserializedDeleteCallerIndex(ctx, s, iamUser, acct, app, sender) }) }) return claimErr } func ResolveSenderNames(ctx context.Context, s release.Store, acct, app string, sn *Sender, now time.Time) (SenderNameSet, bool, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedResolveSenderNames(ctx, s, acct, app, sn, now) } var claimResult0 SenderNameSet var claimResult1 bool claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { var err error claimResult0, claimResult1, err = claimUnserializedResolveSenderNames(ctx, s, acct, app, sn, now) return err }) }) return claimResult0, claimResult1, claimErr }