| new file mode 100644 |
| index 000000000..6c99c6967 |
| --- /dev/null |
| +++ b/internal/mail/sender.go |
| @@ -0,0 +1,412 @@ |
| +// 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/<acct>/<app>/email/senders/<id>.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/<acct>/<app>/ 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-<acct>-<app>+i+<instance-id> 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-<acct>-<app>, tenant and configuration set carlos-<acct>. |
| +// 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]) |
| +} |
| + |
| +// callersPrefix is the reverse index from an IAM caller name to the sender |
| +// that owns it. GLOBAL, not under apps/<acct>/<app>/, 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 PutCallerIndex(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 DeleteCallerIndex(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 ResolveSenderNames(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-<acct>-<app> 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 |
| +} |