// The inline IAM policy every sending principal carries (spec §4). // // This file, not the SES tenant, is what stops account A sending as account // B. The tenant layer isolates REPUTATION; this isolates AUTHORITY, and only // one of those is a security boundary. package mail import ( "encoding/json" "fmt" "strings" "github.com/carlosframework/platform/internal/release" ) // PolicyName is the single inline policy per user. One name, one document — // PutUserPolicy overwrites wholesale, so there is nothing to merge and no // version to reconcile. That is what lets a domain change rewrite the policy // without rotating the credential. const PolicyName = "carlos-mail" // UserPath keeps every sending principal in one IAM path, so an operator can // list the estate this package owns and nothing else. const UserPath = "/carlos-mail/" // UserName is the principal that owns an app's DELIVERED credentials. func UserName(acct, app string) string { return "carlos-mail-" + acct + "-" + app } // AppOfCaller reads the account and app back out of the IAM identity SES // reports as a message's sender (Event.Caller) — the inverse of UserName, // and of StandaloneUserName, whose credential sends for the same app. It is // unambiguous because an account sqid is lowercase letters only // (release.ValidAccount), so the first hyphen after the prefix is where the // account ends, and everything after it is the app, hyphens and all. // // SES is documented to report the identity's name. An IAM USER ARN is // accepted too, by its last path segment, so a change in which of the two // SES sends does not quietly stop naming the sender — but only a user ARN: // an assumed-role session is named by whoever assumes the role, and a // session named carlos-mail-- is not that app. Anything that is // not one of CARLOS's own identities answers false. func AppOfCaller(caller string) (acct, app string, ok bool) { name := caller if strings.HasPrefix(name, "arn:") { _, resource, found := strings.Cut(name, ":user/") if !found || !strings.HasPrefix(name, "arn:aws") || strings.Count(name[:strings.Index(name, ":user/")], ":") != 4 { return "", "", false } name = resource if i := strings.LastIndex(name, "/"); i >= 0 { name = name[i+1:] } } else if strings.Contains(name, "/") { return "", "", false } rest, found := strings.CutPrefix(name, "carlos-mail-") if !found { return "", "", false } if i := strings.Index(rest, "+s+"); i >= 0 { if rest[i+3:] == "" { return "", "", false // a standalone credential always has a name } rest = rest[:i] } acct, app, found = strings.Cut(rest, "-") if !found || !release.ValidAccount(acct) || !release.ValidApp(app) { return "", "", false } return acct, app, true } // TenantName is the SES tenant CARLOS creates for a CARLOS account. // // It is prefixed, never the bare sqid, because the SES tenant namespace is // shared with whatever else lives in the same AWS account — the live estate // this package ships into already holds keymail's `dermdaly`, `paul` and // `till` tenants, and a CARLOS account sqid (`^[a-z]{3,16}$`, all lowercase // letters) can collide with one of those outright, or simply land among // them as if it belonged to that other product. The prefix must not change // once any tenant exists: renaming an SES tenant means destroying it and // recreating it, along with every resource association it carries, not // editing a name in place. func TenantName(acct string) string { return "carlos-" + acct } // StandaloneUserName is the principal for one displayed credential. // // It is a SEPARATE user, and that is forced by IAM's ceiling of two access // keys per user: the delivered key plus PrevKeyID during a rotation overlap // already spend both. A standalone key on the shared user would work until // the first rotation and then make rotation impossible. func StandaloneUserName(acct, app, name string) string { return "carlos-mail-" + acct + "-" + app + "+s+" + name } // SendPolicyJSON renders the policy for a set of verified identity ARNs, the // configuration sets those identities send through, and the domains they // cover. // // Both rails are mandatory and the function refuses to render without both. // An empty grant returns an error rather than a permissive document: the // failure worth having is "this app cannot send", never "this app can send // as anything in the estate". // Each ARN must have the shape arn:aws:ses:::identity/, // the identity name must not contain wildcards, and it must be either exactly // one of the domains or a parent domain of one of them. // // configSetARNs IS THE THIRD RESOURCE AND IT IS NOT OPTIONAL WHEN ONE IS // BOUND (issue #271). SES authorises a configuration set as a SEPARATE // resource from the identity, so the moment SetIdentityConfigSet binds one, // a policy naming only the identity refuses every send: // // 554 Access denied: User '...' is not authorized to perform // 'ses:SendRawEmail' on resource '...:configuration-set/carlos-bag' // // EnsureDomain binds a config set whenever the deployment has an SNS topic, // so this is not a retrofit-only concern: without this resource, ANY // deployment with event ingest configured mints tenants that cannot send at // all. Spec §9's counts, cap and automatic pause were unreachable for // exactly this reason — arming the events broke the sending they govern. // // An EMPTY configSetARNs is the ordinary shape, not an error: a deployment // with no SNS topic binds no set, and its policy must stay what it always // was. Event ingest must never become a precondition for sending. func SendPolicyJSON(identityARNs, configSetARNs, domains []string) (string, error) { if len(identityARNs) == 0 { return "", fmt.Errorf("mail: refusing to render a send policy with no identity ARNs") } if len(domains) == 0 { return "", fmt.Errorf("mail: refusing to render a send policy with no domains") } froms := make([]string, 0, len(domains)) for _, d := range domains { // Re-validated HERE even though callers validate: this string // becomes a match pattern, and an unchecked one ("*") would match // every address on earth. if !ValidDomain(d) { return "", fmt.Errorf("mail: invalid domain %q in send policy", d) } froms = append(froms, "*@"+d) } for _, arn := range identityARNs { // Refuse wildcards anywhere in the ARN before parsing. // IAM expands * and ? as match patterns in all segments, not just identity name. if strings.Contains(arn, "*") || strings.Contains(arn, "?") { return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", arn) } // Validate ARN shape: arn:aws:ses:::identity/ parts := strings.Split(arn, ":") if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "ses" { return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", arn) } // Require region and account-id to be non-empty if parts[3] == "" || parts[4] == "" { return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", arn) } // Extract identity name from the resource part resourceParts := strings.SplitN(parts[5], "/", 2) if len(resourceParts) != 2 || resourceParts[0] != "identity" { return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", arn) } identityName := resourceParts[1] // Check that identity matches domains: either exactly one of them, // or a parent domain of one of them. found := false for _, d := range domains { if identityName == d || strings.HasSuffix(d, "."+identityName) { found = true break } } if !found { return "", fmt.Errorf("mail: identity %q in ARN %q does not match any granted domain", identityName, arn) } } // The config-set ARNs get the identity ARNs' scrutiny, for the identity // ARNs' reason: this string reaches IAM as a resource PATTERN, so a // wildcard would widen the grant to every configuration set in the // estate, and a wrong resource type would be a grant that silently // authorises nothing while looking complete. for _, arn := range configSetARNs { if strings.Contains(arn, "*") || strings.Contains(arn, "?") { return "", fmt.Errorf("mail: invalid configuration-set ARN %q in send policy", arn) } parts := strings.Split(arn, ":") if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "ses" { return "", fmt.Errorf("mail: invalid configuration-set ARN %q in send policy", arn) } if parts[3] == "" || parts[4] == "" { return "", fmt.Errorf("mail: invalid configuration-set ARN %q in send policy", arn) } resourceParts := strings.SplitN(parts[5], "/", 2) if len(resourceParts) != 2 || resourceParts[0] != "configuration-set" || resourceParts[1] == "" { return "", fmt.Errorf("mail: invalid configuration-set ARN %q in send policy", arn) } } // Identities first, then config sets — one flat Resource list, because // SES wants authorisation on BOTH for a single SendRawEmail through a // bound identity, and a caller reading the rendered policy should see // the pair together. resources := make([]string, 0, len(identityARNs)+len(configSetARNs)) resources = append(resources, identityARNs...) resources = append(resources, configSetARNs...) doc := map[string]any{ "Version": "2012-10-17", "Statement": []map[string]any{{ "Effect": "Allow", // SMTP submission authorises against ses:SendRawEmail. The v2 // API's ses:SendEmail is deliberately NOT granted — the // delivered contract is SMTP-only, and the API is not part of it. "Action": []string{"ses:SendRawEmail"}, "Resource": resources, "Condition": map[string]any{ "StringLike": map[string]any{"ses:FromAddress": froms}, }, }}, } b, err := json.Marshal(doc) if err != nil { return "", err } return string(b), nil }