| 1 | // The inline IAM policy every sending principal carries (spec §4). |
| 2 | // |
| 3 | // This file, not the SES tenant, is what stops account A sending as account |
| 4 | // B. The tenant layer isolates REPUTATION; this isolates AUTHORITY, and only |
| 5 | // one of those is a security boundary. |
| 6 | package mail |
| 7 | |
| 8 | import ( |
| 9 | "encoding/json" |
| 10 | "fmt" |
| 11 | "strings" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | // PolicyName is the single inline policy per user. One name, one document — |
| 17 | // PutUserPolicy overwrites wholesale, so there is nothing to merge and no |
| 18 | // version to reconcile. That is what lets a domain change rewrite the policy |
| 19 | // without rotating the credential. |
| 20 | const PolicyName = "carlos-mail" |
| 21 | |
| 22 | // UserPath keeps every sending principal in one IAM path, so an operator can |
| 23 | // list the estate this package owns and nothing else. |
| 24 | const UserPath = "/carlos-mail/" |
| 25 | |
| 26 | // UserName is the principal that owns an app's DELIVERED credentials. |
| 27 | func UserName(acct, app string) string { return "carlos-mail-" + acct + "-" + app } |
| 28 | |
| 29 | // AppOfCaller reads the account and app back out of the IAM identity SES |
| 30 | // reports as a message's sender (Event.Caller) — the inverse of UserName, |
| 31 | // and of StandaloneUserName, whose credential sends for the same app. It is |
| 32 | // unambiguous because an account sqid is lowercase letters only |
| 33 | // (release.ValidAccount), so the first hyphen after the prefix is where the |
| 34 | // account ends, and everything after it is the app, hyphens and all. |
| 35 | // |
| 36 | // SES is documented to report the identity's name. An IAM USER ARN is |
| 37 | // accepted too, by its last path segment, so a change in which of the two |
| 38 | // SES sends does not quietly stop naming the sender — but only a user ARN: |
| 39 | // an assumed-role session is named by whoever assumes the role, and a |
| 40 | // session named carlos-mail-<acct>-<app> is not that app. Anything that is |
| 41 | // not one of CARLOS's own identities answers false. |
| 42 | func AppOfCaller(caller string) (acct, app string, ok bool) { |
| 43 | name := caller |
| 44 | if strings.HasPrefix(name, "arn:") { |
| 45 | _, resource, found := strings.Cut(name, ":user/") |
| 46 | if !found || !strings.HasPrefix(name, "arn:aws") || strings.Count(name[:strings.Index(name, ":user/")], ":") != 4 { |
| 47 | return "", "", false |
| 48 | } |
| 49 | name = resource |
| 50 | if i := strings.LastIndex(name, "/"); i >= 0 { |
| 51 | name = name[i+1:] |
| 52 | } |
| 53 | } else if strings.Contains(name, "/") { |
| 54 | return "", "", false |
| 55 | } |
| 56 | rest, found := strings.CutPrefix(name, "carlos-mail-") |
| 57 | if !found { |
| 58 | return "", "", false |
| 59 | } |
| 60 | if i := strings.Index(rest, "+s+"); i >= 0 { |
| 61 | if rest[i+3:] == "" { |
| 62 | return "", "", false // a standalone credential always has a name |
| 63 | } |
| 64 | rest = rest[:i] |
| 65 | } |
| 66 | acct, app, found = strings.Cut(rest, "-") |
| 67 | if !found || !release.ValidAccount(acct) || !release.ValidApp(app) { |
| 68 | return "", "", false |
| 69 | } |
| 70 | return acct, app, true |
| 71 | } |
| 72 | |
| 73 | // TenantName is the SES tenant CARLOS creates for a CARLOS account. |
| 74 | // |
| 75 | // It is prefixed, never the bare sqid, because the SES tenant namespace is |
| 76 | // shared with whatever else lives in the same AWS account — the live estate |
| 77 | // this package ships into already holds keymail's `dermdaly`, `paul` and |
| 78 | // `till` tenants, and a CARLOS account sqid (`^[a-z]{3,16}$`, all lowercase |
| 79 | // letters) can collide with one of those outright, or simply land among |
| 80 | // them as if it belonged to that other product. The prefix must not change |
| 81 | // once any tenant exists: renaming an SES tenant means destroying it and |
| 82 | // recreating it, along with every resource association it carries, not |
| 83 | // editing a name in place. |
| 84 | func TenantName(acct string) string { return "carlos-" + acct } |
| 85 | |
| 86 | // StandaloneUserName is the principal for one displayed credential. |
| 87 | // |
| 88 | // It is a SEPARATE user, and that is forced by IAM's ceiling of two access |
| 89 | // keys per user: the delivered key plus PrevKeyID during a rotation overlap |
| 90 | // already spend both. A standalone key on the shared user would work until |
| 91 | // the first rotation and then make rotation impossible. |
| 92 | func StandaloneUserName(acct, app, name string) string { |
| 93 | return "carlos-mail-" + acct + "-" + app + "+s+" + name |
| 94 | } |
| 95 | |
| 96 | // SendPolicyJSON renders the policy for a set of verified identity ARNs, the |
| 97 | // configuration sets those identities send through, and the domains they |
| 98 | // cover. |
| 99 | // |
| 100 | // Both rails are mandatory and the function refuses to render without both. |
| 101 | // An empty grant returns an error rather than a permissive document: the |
| 102 | // failure worth having is "this app cannot send", never "this app can send |
| 103 | // as anything in the estate". |
| 104 | // Each ARN must have the shape arn:aws:ses:<region>:<account-id>:identity/<name>, |
| 105 | // the identity name must not contain wildcards, and it must be either exactly |
| 106 | // one of the domains or a parent domain of one of them. |
| 107 | // |
| 108 | // configSetARNs IS THE THIRD RESOURCE AND IT IS NOT OPTIONAL WHEN ONE IS |
| 109 | // BOUND (issue #271). SES authorises a configuration set as a SEPARATE |
| 110 | // resource from the identity, so the moment SetIdentityConfigSet binds one, |
| 111 | // a policy naming only the identity refuses every send: |
| 112 | // |
| 113 | // 554 Access denied: User '...' is not authorized to perform |
| 114 | // 'ses:SendRawEmail' on resource '...:configuration-set/carlos-bag' |
| 115 | // |
| 116 | // EnsureDomain binds a config set whenever the deployment has an SNS topic, |
| 117 | // so this is not a retrofit-only concern: without this resource, ANY |
| 118 | // deployment with event ingest configured mints tenants that cannot send at |
| 119 | // all. Spec §9's counts, cap and automatic pause were unreachable for |
| 120 | // exactly this reason — arming the events broke the sending they govern. |
| 121 | // |
| 122 | // An EMPTY configSetARNs is the ordinary shape, not an error: a deployment |
| 123 | // with no SNS topic binds no set, and its policy must stay what it always |
| 124 | // was. Event ingest must never become a precondition for sending. |
| 125 | func SendPolicyJSON(identityARNs, configSetARNs, domains []string) (string, error) { |
| 126 | if len(identityARNs) == 0 { |
| 127 | return "", fmt.Errorf("mail: refusing to render a send policy with no identity ARNs") |
| 128 | } |
| 129 | if len(domains) == 0 { |
| 130 | return "", fmt.Errorf("mail: refusing to render a send policy with no domains") |
| 131 | } |
| 132 | froms := make([]string, 0, len(domains)) |
| 133 | for _, d := range domains { |
| 134 | // Re-validated HERE even though callers validate: this string |
| 135 | // becomes a match pattern, and an unchecked one ("*") would match |
| 136 | // every address on earth. |
| 137 | if !ValidDomain(d) { |
| 138 | return "", fmt.Errorf("mail: invalid domain %q in send policy", d) |
| 139 | } |
| 140 | froms = append(froms, "*@"+d) |
| 141 | } |
| 142 | for _, arn := range identityARNs { |
| 143 | // Refuse wildcards anywhere in the ARN before parsing. |
| 144 | // IAM expands * and ? as match patterns in all segments, not just identity name. |
| 145 | if strings.Contains(arn, "*") || strings.Contains(arn, "?") { |
| 146 | return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", arn) |
| 147 | } |
| 148 | |
| 149 | // Validate ARN shape: arn:aws:ses:<region>:<account-id>:identity/<name> |
| 150 | parts := strings.Split(arn, ":") |
| 151 | if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "ses" { |
| 152 | return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", arn) |
| 153 | } |
| 154 | |
| 155 | // Require region and account-id to be non-empty |
| 156 | if parts[3] == "" || parts[4] == "" { |
| 157 | return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", arn) |
| 158 | } |
| 159 | |
| 160 | // Extract identity name from the resource part |
| 161 | resourceParts := strings.SplitN(parts[5], "/", 2) |
| 162 | if len(resourceParts) != 2 || resourceParts[0] != "identity" { |
| 163 | return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", arn) |
| 164 | } |
| 165 | identityName := resourceParts[1] |
| 166 | |
| 167 | // Check that identity matches domains: either exactly one of them, |
| 168 | // or a parent domain of one of them. |
| 169 | found := false |
| 170 | for _, d := range domains { |
| 171 | if identityName == d || strings.HasSuffix(d, "."+identityName) { |
| 172 | found = true |
| 173 | break |
| 174 | } |
| 175 | } |
| 176 | if !found { |
| 177 | return "", fmt.Errorf("mail: identity %q in ARN %q does not match any granted domain", identityName, arn) |
| 178 | } |
| 179 | } |
| 180 | // The config-set ARNs get the identity ARNs' scrutiny, for the identity |
| 181 | // ARNs' reason: this string reaches IAM as a resource PATTERN, so a |
| 182 | // wildcard would widen the grant to every configuration set in the |
| 183 | // estate, and a wrong resource type would be a grant that silently |
| 184 | // authorises nothing while looking complete. |
| 185 | for _, arn := range configSetARNs { |
| 186 | if strings.Contains(arn, "*") || strings.Contains(arn, "?") { |
| 187 | return "", fmt.Errorf("mail: invalid configuration-set ARN %q in send policy", arn) |
| 188 | } |
| 189 | parts := strings.Split(arn, ":") |
| 190 | if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "ses" { |
| 191 | return "", fmt.Errorf("mail: invalid configuration-set ARN %q in send policy", arn) |
| 192 | } |
| 193 | if parts[3] == "" || parts[4] == "" { |
| 194 | return "", fmt.Errorf("mail: invalid configuration-set ARN %q in send policy", arn) |
| 195 | } |
| 196 | resourceParts := strings.SplitN(parts[5], "/", 2) |
| 197 | if len(resourceParts) != 2 || resourceParts[0] != "configuration-set" || resourceParts[1] == "" { |
| 198 | return "", fmt.Errorf("mail: invalid configuration-set ARN %q in send policy", arn) |
| 199 | } |
| 200 | } |
| 201 | // Identities first, then config sets — one flat Resource list, because |
| 202 | // SES wants authorisation on BOTH for a single SendRawEmail through a |
| 203 | // bound identity, and a caller reading the rendered policy should see |
| 204 | // the pair together. |
| 205 | resources := make([]string, 0, len(identityARNs)+len(configSetARNs)) |
| 206 | resources = append(resources, identityARNs...) |
| 207 | resources = append(resources, configSetARNs...) |
| 208 | doc := map[string]any{ |
| 209 | "Version": "2012-10-17", |
| 210 | "Statement": []map[string]any{{ |
| 211 | "Effect": "Allow", |
| 212 | // SMTP submission authorises against ses:SendRawEmail. The v2 |
| 213 | // API's ses:SendEmail is deliberately NOT granted — the |
| 214 | // delivered contract is SMTP-only, and the API is not part of it. |
| 215 | "Action": []string{"ses:SendRawEmail"}, |
| 216 | "Resource": resources, |
| 217 | "Condition": map[string]any{ |
| 218 | "StringLike": map[string]any{"ses:FromAddress": froms}, |
| 219 | }, |
| 220 | }}, |
| 221 | } |
| 222 | b, err := json.Marshal(doc) |
| 223 | if err != nil { |
| 224 | return "", err |
| 225 | } |
| 226 | return string(b), nil |
| 227 | } |
| 228 | |