mail: the caller reverse index, written after EnsureUser and deleted with the principal (plan 2, task 5)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3 files changed,
+265
−1
internal/mail/sender.go+139 −0internal/mail/sender_test.go+93 −0internal/mail/walk.go+33 −1
diff --git a/internal/mail/sender.go b/internal/mail/sender.go| index 2da420d08..cc186bcc1 100644 |
| --- a/internal/mail/sender.go |
| +++ b/internal/mail/sender.go |
| @@ -11,12 +11,18 @@ |
| package mail |
| import ( |
| + "context" |
| + "crypto/rand" |
| "crypto/sha256" |
| "encoding/base32" |
| + "encoding/hex" |
| + "encoding/json" |
| "errors" |
| "fmt" |
| + "os" |
| "regexp" |
| "strings" |
| + "time" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -234,3 +240,136 @@ func senderSlug(acct, app, id, salt string) string { |
| 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) |
| +} |
diff --git a/internal/mail/sender_test.go b/internal/mail/sender_test.go| index d1f22ba6e..245b55464 100644 |
| --- a/internal/mail/sender_test.go |
| +++ b/internal/mail/sender_test.go |
| @@ -1,11 +1,14 @@ |
| package mail |
| import ( |
| + "context" |
| "encoding/json" |
| "errors" |
| "strings" |
| "testing" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // The app sender is the shape every app has had since before senders |
| @@ -153,3 +156,93 @@ func TestSenderNamesEnumeratesTheBounds(t *testing.T) { |
| got, StandaloneUserName("bag", "hm", "laptop")) |
| } |
| } |
| + |
| +// The caller index is create-only with the domain claim's idempotence rule: |
| +// the same (account, app, sender) is success, a different one is a |
| +// collision. It is what makes a slugged IAM user name resolvable back to |
| +// the sender that owns it, since nothing parses a hash. |
| +func TestCallerIndexIsCreateOnlyAndIdempotentForItsOwnSender(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| + const user = "carlos-mail-bag-hm+i+abcd1234" |
| + |
| + if err := PutCallerIndex(ctx, s, user, "bag", "hm", "book.example.com", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PutCallerIndex(ctx, s, user, "bag", "hm", "book.example.com", now); err != nil { |
| + t.Fatalf("a re-enable of the same sender was refused by the index it holds: %v", err) |
| + } |
| + if err := PutCallerIndex(ctx, s, user, "bag", "hm", "other.example.com", now); !errors.Is(err, ErrCallerClaimed) { |
| + t.Fatalf("a different sender at the same name = %v, want ErrCallerClaimed", err) |
| + } |
| + got, err := GetCallerIndex(ctx, s, user) |
| + if err != nil || got == nil || got.Account != "bag" || got.App != "hm" || got.Sender != "book.example.com" { |
| + t.Fatalf("GetCallerIndex = %+v %v", got, err) |
| + } |
| + |
| + // Deletion is owner-gated, the way releaseDomainClaim is: a stranger |
| + // cannot remove an entry it does not hold. |
| + if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "someone.else"); err != nil { |
| + t.Fatalf("a mismatched delete should be a quiet no-op: %v", err) |
| + } |
| + if got, _ := GetCallerIndex(ctx, s, user); got == nil { |
| + t.Fatal("a mismatched delete removed the entry") |
| + } |
| + if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "book.example.com"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _ := GetCallerIndex(ctx, s, user); got != nil { |
| + t.Fatalf("the entry survived its own delete: %+v", got) |
| + } |
| + // Deleting again is a quiet no-op, so a retried teardown is not an error. |
| + if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "book.example.com"); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// A 40-bit slug needs collision detection, and the index is where it |
| +// happens: the losing sender recomputes its slug with a salt that is stored |
| +// on its record so the name stays stable across the next enable. |
| +func TestResolveSenderNamesRetriesACollisionWithASalt(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| + |
| + sn := &Sender{ID: "book.example.com", Scope: ScopeInstance, Source: SourceDomain, Host: "book.example.com"} |
| + first, err := SenderNames("bag", "hm", sn.ID, "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Somebody else is already at the unsalted name. |
| + if err := PutCallerIndex(ctx, s, first.IAMUser, "bag", "other", "book.example.com", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + names, salted, err := ResolveSenderNames(ctx, s, "bag", "hm", sn, now) |
| + if err != nil { |
| + t.Fatalf("ResolveSenderNames: %v", err) |
| + } |
| + if !salted { |
| + t.Fatal("the collision did not report that the record must be written back with its new salt") |
| + } |
| + if sn.Salt == "" { |
| + t.Fatal("no salt was stored on the record, so the next enable would collide again") |
| + } |
| + if names.IAMUser == first.IAMUser { |
| + t.Fatalf("the slug did not move: %q", names.IAMUser) |
| + } |
| + got, err := GetCallerIndex(ctx, s, names.IAMUser) |
| + if err != nil || got == nil || got.App != "hm" { |
| + t.Fatalf("the index at the new name = %+v %v", got, err) |
| + } |
| + // The app sender takes the same path and never needs a salt. |
| + appSender := &Sender{ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain} |
| + appNames, saltedApp, err := ResolveSenderNames(ctx, s, "bag", "hm", appSender, now) |
| + if err != nil || saltedApp || appSender.Salt != "" { |
| + t.Fatalf("the app sender was salted: %+v %v %v", appNames, saltedApp, err) |
| + } |
| + if appNames.IAMUser != UserName("bag", "hm") { |
| + t.Fatalf("the app sender's name changed: %q", appNames.IAMUser) |
| + } |
| +} |
diff --git a/internal/mail/walk.go b/internal/mail/walk.go| index 89355d076..a69f85dd9 100644 |
| --- a/internal/mail/walk.go |
| +++ b/internal/mail/walk.go |
| @@ -671,6 +671,13 @@ func Enable(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P |
| if err != nil { |
| return nil, fmt.Errorf("mail: ensure user %s: %w", user, err) |
| } |
| + // The reverse index, right after EnsureUser and before MintKey (design |
| + // §5). EnsureUser runs on every enable, so a re-enable converges the |
| + // index; putting it after MintKey would strand a sender forever when a |
| + // crash landed between the two. |
| + if err := PutCallerIndex(ctx, s, user, acct, app, SenderAppID, now); err != nil { |
| + return nil, fmt.Errorf("mail: index caller %s: %w", user, err) |
| + } |
| // Idempotence short-circuit: governs ONLY the expensive mint-and-deliver |
| // half from here down. |
| @@ -1029,10 +1036,22 @@ func CreateStandalone(ctx context.Context, s release.Store, p Provisioner, d Dep |
| sort.Strings(configSets) |
| sort.Strings(names) |
| - user := StandaloneUserName(acct, app, name) |
| + senderNames, err := SenderNames(acct, app, SenderAppID, "") |
| + if err != nil { |
| + return StandaloneCreds{}, err |
| + } |
| + user, err := senderNames.StandaloneUser(acct, app, name) |
| + if err != nil { |
| + return StandaloneCreds{}, err |
| + } |
| if _, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names); err != nil { |
| return StandaloneCreds{}, err |
| } |
| + // A standalone credential sends for the app sender, so its caller name |
| + // resolves to the same sender the delivered credential does. |
| + if err := PutCallerIndex(ctx, s, user, acct, app, SenderAppID, now); err != nil { |
| + return StandaloneCreds{}, fmt.Errorf("mail: index caller %s: %w", user, err) |
| + } |
| // A re-mint for the same name replaces the credential rather than |
| // stacking one: two slots is still the ceiling here too. |
| if err := p.ReapKeys(ctx, user, nil); err != nil { |
| @@ -1096,6 +1115,9 @@ func RevokeStandalone(ctx context.Context, s release.Store, p Provisioner, acct, |
| for _, sa := range rec.Standalone { |
| if sa.Name == name { |
| found = true |
| + if err := DeleteCallerIndex(ctx, s, sa.IAMUser, acct, app, SenderAppID); err != nil { |
| + return err |
| + } |
| if err := p.DeleteUser(ctx, sa.IAMUser); err != nil { |
| return err |
| } |
| @@ -1138,11 +1160,21 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writ |
| } |
| if rec != nil { |
| if rec.Provisioned != nil { |
| + // The index sits outside apps/<acct>/<app>/, so PurgeApp's |
| + // prefix sweep never reaches it. Without this delete a stale |
| + // entry would attribute SES events to a purged app and insert |
| + // counts for it. |
| + if err := DeleteCallerIndex(ctx, s, rec.Provisioned.IAMUser, acct, app, SenderAppID); err != nil { |
| + return err |
| + } |
| if err := p.DeleteUser(ctx, rec.Provisioned.IAMUser); err != nil { |
| return err |
| } |
| } |
| for _, sa := range rec.Standalone { |
| + if err := DeleteCallerIndex(ctx, s, sa.IAMUser, acct, app, SenderAppID); err != nil { |
| + return err |
| + } |
| if err := p.DeleteUser(ctx, sa.IAMUser); err != nil { |
| return err |
| } |