carlos / platform Public

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.
Paul Campbell 3e48ca99e272e81228bafc7f8433664798195a75 parent 09864a2
2 files changed, +190 −0
  • internal/mail/policy.go +85 −0
  • internal/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")
+ }
+}