mail: the IAM policy that isolates one tenant from another
Two rails, both mandatory: Resource pinned to the app's verified identity ARNs, and a ses:FromAddress condition over the same domains. Resource alone authorises sending as the identity, which a subdomain identity over-covers; the condition alone does not bind the signing identity. An empty grant is an error, never a permissive document.
2 files changed,
+190
−0
internal/mail/policy.go+85 −0internal/mail/policy_test.go+105 −0
diff --git a/internal/mail/policy.go b/internal/mail/policy.go| new file mode 100644 |
| index 000000000..94e97df73 |
| --- /dev/null |
| +++ b/internal/mail/policy.go |
| @@ -0,0 +1,85 @@ |
| +// 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" |
| +) |
| + |
| +// 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 } |
| + |
| +// 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 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". |
| +func SendPolicyJSON(identityARNs, 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 { |
| + if !strings.HasPrefix(arn, "arn:") { |
| + return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", arn) |
| + } |
| + } |
| + 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": identityARNs, |
| + "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 |
| +} |
diff --git a/internal/mail/policy_test.go b/internal/mail/policy_test.go| new file mode 100644 |
| index 000000000..01a8bee35 |
| --- /dev/null |
| +++ b/internal/mail/policy_test.go |
| @@ -0,0 +1,105 @@ |
| +package mail |
| + |
| +import ( |
| + "encoding/json" |
| + "testing" |
| +) |
| + |
| +type policyDoc struct { |
| + Version string `json:"Version"` |
| + Statement []struct { |
| + Effect string `json:"Effect"` |
| + Action []string `json:"Action"` |
| + Resource []string `json:"Resource"` |
| + Condition map[string]map[string][]string |
| + } `json:"Statement"` |
| +} |
| + |
| +func parsePolicy(t *testing.T, s string) policyDoc { |
| + t.Helper() |
| + var d policyDoc |
| + if err := json.Unmarshal([]byte(s), &d); err != nil { |
| + t.Fatalf("policy is not valid JSON: %v\n%s", err, s) |
| + } |
| + return d |
| +} |
| + |
| +// BOTH rails or neither. Resource alone authorises sending AS the identity, |
| +// and a subdomain identity covers From addresses the app must not claim. |
| +// The condition alone does not bind the identity used to sign. A change that |
| +// drops either must fail a test, not a review (spec §4). |
| +func TestPolicyCarriesBothIsolationRails(t *testing.T) { |
| + got, err := SendPolicyJSON( |
| + []string{"arn:aws:ses:eu-west-1:1:identity/seapointish.bag.oncarlos.com"}, |
| + []string{"seapointish.bag.oncarlos.com"}) |
| + if err != nil { |
| + t.Fatalf("SendPolicyJSON: %v", err) |
| + } |
| + d := parsePolicy(t, got) |
| + if len(d.Statement) != 1 { |
| + t.Fatalf("want exactly one statement, got %d", len(d.Statement)) |
| + } |
| + st := d.Statement[0] |
| + if st.Effect != "Allow" { |
| + t.Fatalf("Effect = %q, want Allow", st.Effect) |
| + } |
| + if len(st.Action) != 1 || st.Action[0] != "ses:SendRawEmail" { |
| + t.Fatalf("Action = %v, want [ses:SendRawEmail] — the contract is SMTP-only", st.Action) |
| + } |
| + if len(st.Resource) != 1 || st.Resource[0] != "arn:aws:ses:eu-west-1:1:identity/seapointish.bag.oncarlos.com" { |
| + t.Fatalf("Resource = %v — rail one is missing or wrong", st.Resource) |
| + } |
| + from := st.Condition["StringLike"]["ses:FromAddress"] |
| + if len(from) != 1 || from[0] != "*@seapointish.bag.oncarlos.com" { |
| + t.Fatalf("ses:FromAddress = %v — rail two is missing or wrong", from) |
| + } |
| +} |
| + |
| +// Multi-region: one credential, one policy, an ARN per (domain, region). |
| +func TestPolicyListsEveryVerifiedARN(t *testing.T) { |
| + got, err := SendPolicyJSON([]string{ |
| + "arn:aws:ses:eu-west-1:1:identity/x.com", |
| + "arn:aws:ses:us-east-1:1:identity/x.com", |
| + }, []string{"x.com"}) |
| + if err != nil { |
| + t.Fatalf("SendPolicyJSON: %v", err) |
| + } |
| + if n := len(parsePolicy(t, got).Statement[0].Resource); n != 2 { |
| + t.Fatalf("Resource count = %d, want 2 — a credential spans regions", n) |
| + } |
| +} |
| + |
| +// An empty grant must not render a policy that permits everything. Refusing |
| +// is the only safe direction: a wildcard here would let one app send as any |
| +// identity in the estate. |
| +func TestPolicyRefusesAnEmptyGrant(t *testing.T) { |
| + if _, err := SendPolicyJSON(nil, nil); err == nil { |
| + t.Fatal("SendPolicyJSON with no ARNs and no domains returned a policy, want an error") |
| + } |
| + if _, err := SendPolicyJSON([]string{"arn:x"}, nil); err == nil { |
| + t.Fatal("SendPolicyJSON with no domains returned a policy, want an error") |
| + } |
| + if _, err := SendPolicyJSON(nil, []string{"x.com"}); err == nil { |
| + t.Fatal("SendPolicyJSON with no ARNs returned a policy, want an error") |
| + } |
| +} |
| + |
| +// A domain that reached this far unvalidated could inject a wildcard into |
| +// the condition ("*" would match every address). Refuse rather than render. |
| +func TestPolicyRefusesAnInvalidDomain(t *testing.T) { |
| + if _, err := SendPolicyJSON([]string{"arn:x"}, []string{"*"}); err == nil { |
| + t.Fatal("SendPolicyJSON accepted a wildcard domain") |
| + } |
| +} |
| + |
| +func TestUserNamesAreStableAndDistinct(t *testing.T) { |
| + if got, want := UserName("bag", "seapointish"), "carlos-mail-bag-seapointish"; got != want { |
| + t.Fatalf("UserName = %q, want %q", got, want) |
| + } |
| + // A standalone credential MUST NOT share the delivered user: IAM allows |
| + // two access keys per user and both slots are spent on the delivered key |
| + // plus the rotation overlap. |
| + if StandaloneUserName("bag", "seapointish", "laptop") == UserName("bag", "seapointish") { |
| + t.Fatal("standalone credential collided with the delivered user") |
| + } |
| +} |