carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The sender (senders design section 5): a From address, a credential, a
2// cap, a pause and a set of counts, owned by one record.
3//
4// Before this file an app had exactly one implicit sender and everything was
5// keyed by (account, app): email.json, the IAM user, the DailyCap, the pause,
6// the mail_counts rows. Two requests do not fit that shape (#147 sends as an
7// address under an identity CARLOS does not own, #148 gives one instance per
8// customer its own domain, credential, cap and pause), so the sender becomes
9// a thing with a name. This step gives every app exactly ONE sender, "app",
10// which is what it already had. Nothing here creates a second one.
11package mail
12
13import (
14 "context"
15 "crypto/rand"
16 "crypto/sha256"
17 "encoding/base32"
18 "encoding/hex"
19 "encoding/json"
20 "errors"
21 "fmt"
22 "log"
23 "os"
24 "regexp"
25 "strings"
26 "time"
27
28 "github.com/carlosframework/platform/internal/release"
29)
30
31// The two scopes and the two sources a sender can have. Scope says what the
32// sender belongs to; Source says where its authority to send comes from. An
33// app sender on a domain the account verified is the only combination this
34// release produces; the others are named here because SenderNames, the
35// status reply and the caller index all branch on them, and a closed set
36// that is written down once is a set nothing invents a third member of.
37const (
38 ScopeApp = "app"
39 ScopeInstance = "instance"
40
41 SourceDomain = "domain"
42 SourceAddress = "address"
43)
44
45// SenderAppID is the id of the sender every app has always had. It is a
46// constant rather than a literal because it is written into every migrated
47// record, read back by SenderOfCaller and compared in a dozen places.
48const SenderAppID = "app"
49
50// PauseKindPerInstance is the pause an app sender carries once its app has
51// moved to per-instance senders (design section 6.3). It is never a cap pause and
52// never auto-resumes: resumeStaleCapPause reactivates every key it touches,
53// and re-arming the app credential that every instance still holds is
54// exactly what per-instance mode exists to prevent. Nothing in this release
55// sets it; the refusals that read it are in place so step 5 cannot land
56// without them.
57const PauseKindPerInstance = "per-instance"
58
59// Sender is apps/<acct>/<app>/email/senders/<id>.json, one object per
60// sender, each with its own CAS etag so two writers never contend over a
61// sibling's state.
62type Sender struct {
63 Schema int `json:"schema"`
64 ID string `json:"id"` // "app", or provision.InstanceID(host)
65 Slug string `json:"slug"` // the AWS-safe name component, see SenderNames
66 Scope string `json:"scope"` // ScopeApp | ScopeInstance
67 Host string `json:"host,omitempty"`
68 Source string `json:"source"` // SourceDomain | SourceAddress
69
70 // The four blocks keep their current shapes and move inside the sender
71 // unchanged, which is what lets a legacy email.json decode straight into
72 // one and what keeps every walk in walk.go compiling against Record.
73 Declared *Declared `json:"declared,omitempty"`
74 Grant *GrantBlock `json:"grant,omitempty"`
75 Provisioned *Provisioned `json:"provisioned,omitempty"`
76 Standalone []*Standalone `json:"standalone,omitempty"`
77
78 Identity string `json:"identity,omitempty"` // external identity ARN (#147, step 3)
79 Address string `json:"address,omitempty"` // the exact address, lowercased (#147, step 3)
80 TenantARN string `json:"tenant_arn,omitempty"` // from CreateTenant
81
82 // PerInstance marks an app sender whose app has moved to per-instance
83 // senders (design section 6.3). Nothing in this release sets it. It lives on
84 // the app sender rather than being derived from a prefix listing
85 // because a listing cannot decide the question without racing an
86 // app-sender enable, and the transition has to be one CAS write.
87 PerInstance bool `json:"per_instance,omitempty"`
88
89 // Salt disambiguates a slug collision. The slug is 40 bits of a hash, so
90 // two senders can in principle want the same IAM user name; the caller
91 // index detects that and ResolveSenderNames recomputes the slug with a
92 // fresh salt, which has to be stored or the name would not be stable
93 // across the next enable.
94 Salt string `json:"salt,omitempty"`
95}
96
97// SendersPrefix is where an app's senders live. Inside apps/<acct>/<app>/ on
98// purpose: release.PurgeApp's prefix sweep reaches it, so a purged app
99// leaves no sender behind.
100func SendersPrefix(acct, app string) string {
101 return release.AppPrefix(acct, app) + "email/senders/"
102}
103
104func SenderKey(acct, app, id string) string { return SendersPrefix(acct, app) + id + ".json" }
105
106// senderIDRe is deliberately stricter than a host name: this string becomes
107// a bucket key segment and half an IAM user name, and the only safe place to
108// refuse a separator or a traversal is the one gate every caller passes
109// through. It accepts a host (provision.InstanceID returns the host itself
110// when it is short enough) and the underscore-prefixed digest form
111// InstanceID falls back to for a long one.
112var senderIDRe = regexp.MustCompile(`^_?[a-z0-9]([a-z0-9.-]{0,62}[a-z0-9])?$`)
113
114// ValidSenderID reports whether id is usable as a sender id. ".json" is
115// refused along with every other dotted suffix that would collide with the
116// key's own extension only incidentally: what actually refuses it is the
117// check below, which is why it is spelled out.
118func ValidSenderID(id string) bool {
119 if id == "" || len(id) > 64 || id == "." || id == ".." {
120 return false
121 }
122 if !senderIDRe.MatchString(id) {
123 return false
124 }
125 return !hasJSONSuffix(id)
126}
127
128func hasJSONSuffix(id string) bool { return len(id) > 5 && id[len(id)-5:] == ".json" }
129
130// The AWS bounds this file exists to respect. IAM caps a user name at 64
131// characters and SES caps a tenant name and a configuration-set name at 64
132// of [A-Za-z0-9_-]. The first draft of the senders design got this wrong:
133// carlos-mail-<acct>-<app>+i+<instance-id> reaches 104 characters when the
134// instance id is the host itself, which it is for any host of 40 characters
135// or less.
136const (
137 iamUserNameMax = 64
138 sesNameMax = 64
139 // senderAppComponentMax truncates the app name inside a slugged sender's
140 // names. 12 + 16 + 1 + 24 + 3 + 8 = 64 exactly, at the longest legal
141 // account and app. Uniqueness comes from the hash, not from the app
142 // component, so truncating it costs nothing but readability.
143 senderAppComponentMax = 24
144 // senderSlugLen is 40 bits of base32. Short enough to fit, long enough
145 // that a collision is rare, and detected either way by the caller index.
146 senderSlugLen = 8
147)
148
149// ErrNameTooLong is the refusal at create for a name no AWS bound can hold.
150// It is refused rather than truncated because a truncated name would
151// collide silently with its neighbour, and because the only name this can
152// happen to today is a standalone credential's, whose name the member chose
153// and can choose differently.
154var ErrNameTooLong = errors.New("mail: name exceeds the AWS bound")
155
156// SenderNameSet is every AWS name one sender owns.
157type SenderNameSet struct {
158 Slug string // "" for the app sender, 8 base32 characters otherwise
159 IAMUser string
160 Tenant string
161 ConfigSet string
162}
163
164// SenderNames is THE rule for what a sender's AWS resources are called, and
165// there is exactly one of it so that a bounds test can enumerate every form.
166//
167// The app sender's names are unchanged, deliberately and permanently:
168// carlos-mail-<acct>-<app>, tenant and configuration set carlos-<acct>.
169// Renaming an SES tenant means destroying it and recreating it along with
170// every association it carries, and rewriting an IAM user name means a new
171// principal with the old one left live and stale. So the rule is that
172// existing shapes never change and a name that would exceed a bound is
173// refused at create.
174//
175// Configuration sets stay per account even for a slugged sender: an identity
176// has only one default set, and SES has no tenant-level default, so
177// per-sender sets on a shared identity are impossible (design §3).
178func SenderNames(acct, app, senderID, salt string) (SenderNameSet, error) {
179 if !release.ValidAccount(acct) {
180 return SenderNameSet{}, fmt.Errorf("mail: invalid account %q", acct)
181 }
182 if !release.ValidApp(app) {
183 return SenderNameSet{}, fmt.Errorf("mail: invalid app %q", app)
184 }
185 if !ValidSenderID(senderID) {
186 return SenderNameSet{}, fmt.Errorf("mail: invalid sender id %q", senderID)
187 }
188 n := SenderNameSet{Tenant: TenantName(acct), ConfigSet: ConfigSetName(acct)}
189 if senderID == SenderAppID {
190 n.IAMUser = UserName(acct, app)
191 return n, checkNames(n)
192 }
193 n.Slug = senderSlug(acct, app, senderID, salt)
194 short := app
195 if len(short) > senderAppComponentMax {
196 short = short[:senderAppComponentMax]
197 }
198 n.IAMUser = "carlos-mail-" + acct + "-" + short + "+i+" + n.Slug
199 n.Tenant = "carlos-" + acct + "-" + short + "-" + n.Slug
200 return n, checkNames(n)
201}
202
203// StandaloneUser is the name of one displayed credential belonging to this
204// sender, bound-checked. The rendered string is byte-identical to
205// StandaloneUserName for an app sender, because RefreshPolicies recomputes
206// the name rather than reading sa.IAMUser (walk.go:364): a rule that
207// rendered anything else would write a policy for a principal that does not
208// exist and leave the live one stale.
209func (n SenderNameSet) StandaloneUser(acct, app, credential string) (string, error) {
210 if !validStandaloneName(credential) {
211 return "", fmt.Errorf("mail: invalid credential name %q", credential)
212 }
213 name := StandaloneUserName(acct, app, credential)
214 if n.Slug != "" {
215 name = n.IAMUser + "+s+" + credential
216 }
217 if len(name) > iamUserNameMax {
218 return "", fmt.Errorf("mail: credential name %q makes the IAM user %d characters, over IAM's %d: choose a shorter name: %w",
219 credential, len(name), iamUserNameMax, ErrNameTooLong)
220 }
221 return name, nil
222}
223
224func checkNames(n SenderNameSet) error {
225 if len(n.IAMUser) > iamUserNameMax {
226 return fmt.Errorf("mail: IAM user %q is %d characters, over IAM's %d: %w", n.IAMUser, len(n.IAMUser), iamUserNameMax, ErrNameTooLong)
227 }
228 for _, name := range []string{n.Tenant, n.ConfigSet} {
229 if len(name) > sesNameMax {
230 return fmt.Errorf("mail: SES name %q is %d characters, over SES's %d: %w", name, len(name), sesNameMax, ErrNameTooLong)
231 }
232 }
233 return nil
234}
235
236// senderSlug is 40 bits of base32 over (account, app, sender, salt),
237// lowercased so it reads like every other name this package renders. Nothing
238// parses it back, which is why the caller index exists.
239func senderSlug(acct, app, id, salt string) string {
240 sum := sha256.Sum256([]byte(acct + "/" + app + "/" + id + "/" + salt))
241 enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:])
242 return strings.ToLower(enc[:senderSlugLen])
243}
244
245// SenderOwnsUserName reports whether user is a name that sender id of
246// (acct, app) can own, by FORWARD rendering only: nothing is parsed back out
247// of user at a hyphen or at "+s+". It is what the console's backend boundary
248// checks a caller index against, so an index can never move a principal out
249// of the namespace its own (acct, app, sender) renders.
250//
251// The app sender owns exactly UserName(acct, app) and every
252// StandaloneUserName(acct, app, n) for a valid credential name n. A slugged
253// sender owns the names SenderNames renders for SOME salt: the prefix
254// carlos-mail-<acct>-<app component>+i+, then an 8 character slug, then
255// optionally +s+ and a valid credential name. The salt is not known here (a
256// moved salt is written back only after the index), so the slug's shape is
257// checked and the create-only index entry is what binds the exact name.
258// The forms cannot collide across owners: an account sqid is lowercase
259// letters only, and neither an app name nor a credential name can contain
260// "+".
261func SenderOwnsUserName(acct, app, id, user string) bool {
262 if id == SenderAppID {
263 if user == UserName(acct, app) {
264 return true
265 }
266 prefix := StandaloneUserName(acct, app, "")
267 cred, ok := strings.CutPrefix(user, prefix)
268 return ok && validStandaloneName(cred) && StandaloneUserName(acct, app, cred) == user
269 }
270 n, err := SenderNames(acct, app, id, "")
271 if err != nil || len(n.IAMUser) <= senderSlugLen {
272 return false
273 }
274 rest, ok := strings.CutPrefix(user, n.IAMUser[:len(n.IAMUser)-senderSlugLen])
275 if !ok || len(rest) < senderSlugLen || !validSenderSlug(rest[:senderSlugLen]) {
276 return false
277 }
278 slugged := user[:len(user)-len(rest)+senderSlugLen]
279 if len(rest) == senderSlugLen {
280 return len(user) <= iamUserNameMax
281 }
282 cred, ok := strings.CutPrefix(rest[senderSlugLen:], "+s+")
283 return ok && validStandaloneName(cred) && slugged+"+s+"+cred == user && len(user) <= iamUserNameMax
284}
285
286// validSenderSlug is senderSlug's output alphabet and length: lowercased
287// unpadded base32.
288func validSenderSlug(s string) bool {
289 if len(s) != senderSlugLen {
290 return false
291 }
292 for _, c := range s {
293 if !(c >= 'a' && c <= 'z') && !(c >= '2' && c <= '7') {
294 return false
295 }
296 }
297 return true
298}
299
300// callersPrefix is the reverse index from an IAM caller name to the sender
301// that owns it. GLOBAL, not under apps/<acct>/<app>/, because SES hands the
302// ingest a bare user name with no account attached and the ingest has
303// nowhere to start looking. The price of that placement is that
304// release.PurgeApp's prefix sweep never reaches it, so every entry has an
305// explicit delete: without one, a stale entry would attribute SES events to
306// a purged app and insert counts for it.
307const callersPrefix = "email/callers/"
308
309func CallerKey(iamUser string) string { return callersPrefix + iamUser + ".json" }
310
311// CallerIndex is the wire shape at CallerKey(IAMUser).
312type CallerIndex struct {
313 Schema int `json:"schema"` // 1
314 IAMUser string `json:"iam_user"`
315 Account string `json:"account"`
316 App string `json:"app"`
317 Sender string `json:"sender"`
318 CreatedAt time.Time `json:"created_at"`
319}
320
321// ErrCallerClaimed means this IAM name already belongs to a different
322// sender. For a slugged name that is a hash collision and
323// ResolveSenderNames recomputes with a salt; for an unslugged one it is a
324// genuine conflict and the caller surfaces it.
325var ErrCallerClaimed = errors.New("mail: caller name belongs to another sender")
326
327// GetCallerIndex reads one entry. Absent is (nil, nil), matching every other
328// read in this package.
329func GetCallerIndex(ctx context.Context, s release.Store, iamUser string) (*CallerIndex, error) {
330 raw, _, err := s.Get(ctx, CallerKey(iamUser))
331 if err != nil {
332 if errors.Is(err, os.ErrNotExist) {
333 return nil, nil
334 }
335 return nil, err
336 }
337 var c CallerIndex
338 if err := json.Unmarshal(raw, &c); err != nil {
339 return nil, fmt.Errorf("mail: caller index %s: %w", iamUser, err)
340 }
341 if c.Schema > 1 {
342 return nil, fmt.Errorf("mail: caller index %s: schema %d is newer than this binary understands, upgrade carlos", iamUser, c.Schema)
343 }
344 return &c, nil
345}
346
347// PutCallerIndex claims iamUser for one sender. Create-only, with
348// claimDomain's rule (domainclaim.go:119): the same triple is idempotent
349// success, any other triple is ErrCallerClaimed.
350//
351// Written right AFTER EnsureUser, which runs on every enable, so a
352// re-enable converges it. Writing it before MintKey would be no better and
353// writing it before EnsureUser would be worse: a crash between the index
354// write and the user's creation would strand the name against a principal
355// that does not exist.
356func claimUnserializedPutCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string, now time.Time) error {
357 body, err := json.Marshal(CallerIndex{Schema: 1, IAMUser: iamUser, Account: acct, App: app, Sender: sender, CreatedAt: now})
358 if err != nil {
359 return err
360 }
361 for attempt := 0; ; attempt++ {
362 cur, err := GetCallerIndex(ctx, s, iamUser)
363 if err != nil {
364 return err
365 }
366 if cur == nil {
367 if _, err := s.Put(ctx, CallerKey(iamUser), body, ""); err == nil {
368 return nil
369 } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
370 continue // lost the create race, re-read and judge the winner
371 } else {
372 return err
373 }
374 }
375 if cur.Account != acct || cur.App != app || cur.Sender != sender {
376 return ErrCallerClaimed
377 }
378 return nil // already ours
379 }
380}
381
382// DeleteCallerIndex removes an entry, but only when it is held by this
383// sender, the same owner gate releaseDomainClaim applies. An entry already
384// absent is a quiet no-op, so a retried teardown is not an error.
385func claimUnserializedDeleteCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string) error {
386 cur, err := GetCallerIndex(ctx, s, iamUser)
387 if err != nil || cur == nil {
388 return err
389 }
390 if cur.Account != acct || cur.App != app || cur.Sender != sender {
391 return nil
392 }
393 return s.Delete(ctx, CallerKey(iamUser))
394}
395
396// ResolveSenderNames renders a sender's names and claims them in the caller
397// index, recomputing a slugged name with a fresh salt on a collision.
398//
399// It reports whether the salt moved, because a moved salt has to be written
400// back to the sender record under its own CAS or the next enable computes a
401// different name again and the estate grows an orphan.
402func claimUnserializedResolveSenderNames(ctx context.Context, s release.Store, acct, app string, sn *Sender, now time.Time) (SenderNameSet, bool, error) {
403 salted := false
404 for attempt := 0; attempt < 4; attempt++ {
405 names, err := SenderNames(acct, app, sn.ID, sn.Salt)
406 if err != nil {
407 return SenderNameSet{}, salted, err
408 }
409 err = PutCallerIndex(ctx, s, names.IAMUser, acct, app, sn.ID, now)
410 if err == nil {
411 sn.Slug = names.Slug
412 return names, salted, nil
413 }
414 if !errors.Is(err, ErrCallerClaimed) {
415 return SenderNameSet{}, salted, err
416 }
417 if names.Slug == "" {
418 // An unslugged name is deterministic, so a conflict here is not
419 // a hash collision: another account or app is genuinely using
420 // this principal, and a salt would not help.
421 return SenderNameSet{}, salted, fmt.Errorf("mail: IAM user %s is already claimed by another sender: %w", names.IAMUser, err)
422 }
423 buf := make([]byte, 4)
424 if _, err := rand.Read(buf); err != nil {
425 return SenderNameSet{}, salted, err
426 }
427 sn.Salt = hex.EncodeToString(buf)
428 salted = true
429 }
430 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)
431}
432
433// SenderOfCaller resolves the IAM identity SES stamps on an event
434// (mail.tags["ses:caller-identity"]) to the sender that owns it.
435//
436// PARSE FIRST, in two steps, and the order is load-bearing. AppOfCaller
437// answers for carlos-mail-<acct>-<app> and its +s+ standalones, which is
438// every sender that exists today, and it costs nothing. Only a slugged +i+
439// name, which is a hash and unparseable by construction, reaches the index
440// Get. Every app enabled today short-circuits Enable before MintKey
441// (walk.go:677), so it has no index entry until it next rotates: a Get-only
442// resolver would silently stop writing the by_sender log rows that
443// sesevents.go writes now, for every app in the fleet, on the roll.
444//
445// An unreadable index entry is logged and answers "unknown", which puts the
446// event back on the domain-wide fallback rather than dropping it.
447func SenderOfCaller(ctx context.Context, s release.Store, caller string) (string, string, string, bool) {
448 if acct, app, ok := AppOfCaller(caller); ok {
449 return acct, app, SenderAppID, true
450 }
451 if !strings.Contains(caller, "+i+") {
452 return "", "", "", false
453 }
454 name := caller
455 if i := strings.LastIndex(name, "/"); i >= 0 {
456 name = name[i+1:]
457 }
458 idx, err := GetCallerIndex(ctx, s, name)
459 if err != nil {
460 log.Printf("mail: caller index %s -> %v", name, err)
461 return "", "", "", false
462 }
463 if idx == nil || !release.ValidAccount(idx.Account) || !release.ValidApp(idx.App) || !ValidSenderID(idx.Sender) {
464 return "", "", "", false
465 }
466 return idx.Account, idx.App, idx.Sender, true
467}
468
469func PutCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string, now time.Time) error {
470 if _, enabled := s.(ClaimMailStore); !enabled {
471 return claimUnserializedPutCallerIndex(ctx, s, iamUser, acct, app, sender, now)
472 }
473 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
474 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error {
475 return claimUnserializedPutCallerIndex(ctx, s, iamUser, acct, app, sender, now)
476 })
477 })
478 return claimErr
479}
480
481func DeleteCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string) error {
482 if _, enabled := s.(ClaimMailStore); !enabled {
483 return claimUnserializedDeleteCallerIndex(ctx, s, iamUser, acct, app, sender)
484 }
485 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
486 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error {
487 return claimUnserializedDeleteCallerIndex(ctx, s, iamUser, acct, app, sender)
488 })
489 })
490 return claimErr
491}
492
493func ResolveSenderNames(ctx context.Context, s release.Store, acct, app string, sn *Sender, now time.Time) (SenderNameSet, bool, error) {
494 if _, enabled := s.(ClaimMailStore); !enabled {
495 return claimUnserializedResolveSenderNames(ctx, s, acct, app, sn, now)
496 }
497 var claimResult0 SenderNameSet
498 var claimResult1 bool
499 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
500 return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error {
501 var err error
502 claimResult0, claimResult1, err = claimUnserializedResolveSenderNames(ctx, s, acct, app, sn, now)
503 return err
504 })
505 })
506 return claimResult0, claimResult1, claimErr
507}
508