| index 70e63ce50..2da420d08 100644 |
| --- a/internal/mail/sender.go |
| +++ b/internal/mail/sender.go |
| @@ -11,7 +11,12 @@ |
| package mail |
| |
| import ( |
| + "crypto/sha256" |
| + "encoding/base32" |
| + "errors" |
| + "fmt" |
| "regexp" |
| + "strings" |
| |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -114,3 +119,118 @@ func ValidSenderID(id string) bool { |
| } |
| |
| 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]) |
| +} |