carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
6package mail
7
8import (
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.
20const 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.
24const UserPath = "/carlos-mail/"
25
26// UserName is the principal that owns an app's DELIVERED credentials.
27func 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.
42func 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.
84func 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.
92func 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.
125func 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