| 1 | # Tenant Email, Plan 3 of 5: External identities and address senders |
| 2 | |
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | |
| 5 | **Goal:** Let an app send as one exact address under an SES identity CARLOS does not own and must never modify — `jelly@tito.io` under Tito Classic's `tito.io` — by registering that identity once per deployment, refusing every verb that would rewrite or delete it, claiming single addresses under it non-exclusively so `jam` and `stripe` can both send as `jelly@tito.io`, and giving the sender its own IAM principal whose policy names the borrowed identity, CARLOS's configuration set, the exact From address (case-insensitively) and the account's SES tenant, with `<P>_CONFIG_SET` and `<P>_TENANT` delivered beside the five variables the app already reads. |
| 6 | |
| 7 | **Architecture:** `internal/mail` gains `external.go` (the `ExternalIdentity` record, its global key, the parent-walk lookup), `addressclaim.go` (the per-account, non-exclusive claim on one address) and `guard.go` (a `Provisioner` wrapper that refuses `EnsureIdentity`, `SetIdentityConfigSet` and `DeleteIdentity` for a registered domain or any subdomain of one, wired once in `cmd/console/main.go`); `policy.go` gains a second renderer, `SendAddressPolicyJSON`, and the `Provisioner` gains one verb, `EnsureAddressUser`, so the address sender's document never passes through the domain form and the domain form is left byte-identical. `Enable` and `Teardown` branch on the app sender's `Source`: the address branch ensures the tenant and the account's configuration set, associates both with the tenant, refuses an identity whose own default set is not CARLOS's, and delivers the two extra variables; teardown disassociates and drops the claim and never touches the identity. The console gains `apiexternal.go` with four operator doors and one member door, and `svcrole` gains `Mail`, which every email door moves to. |
| 8 | |
| 9 | **Tech Stack:** Go 1.26, `internal/mail`, `internal/console` (the email API doors, the operator gate), `internal/svcrole`, `internal/release` (Store, `NewDirStore`, `ErrCASConflict`, `AppPrefix`, `ValidAccount`, `ValidApp`), `internal/appconfig` (`Apply`, `Seal`, `ValidVarValue`), `aws-sdk-go-v2/service/sesv2 v1.66.6`, root-package `mail_cli.go`, `cmd/console/main.go`. |
| 10 | |
| 11 | **Spec:** `docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md` — §11 step 3 is the whole of this plan's mandate. §7 (external identities, address claims, the provisioner refusal, the address branch through enable and the policy renderer, `<P>_CONFIG_SET`), §4.1 (the spike's four answers, which this plan builds to with no conditional branches), §6.1's one paragraph on `svcrole.Mail`, and §3's ruling that there is no tenant-level default configuration set are in scope. §6.2–§6.5, §8's pause verbs and counting, and everything about instance senders are **not**: no per-instance config layer, no `ConfigAck` change, no instance doors, no `TenantStatus`, no `SetTenantSending`, no `mail_sender_counts` work beyond what plan 2 already landed. |
| 12 | |
| 13 | ## Assumed from plan 2 |
| 14 | |
| 15 | Plan 2 (`docs/superpowers/plans/2026-09-15-tenant-email-2-senders.md`) is merged before this one starts. Every name below is **its** to define; this plan consumes them and redefines none of them. |
| 16 | |
| 17 | ```go |
| 18 | // internal/mail/sender.go |
| 19 | const (ScopeApp, ScopeInstance, SourceDomain, SourceAddress, SenderAppID, PauseKindPerInstance string) |
| 20 | type Sender struct { |
| 21 | Schema int; ID, Slug, Scope, Host, Source string |
| 22 | Declared *Declared; Grant *GrantBlock; Provisioned *Provisioned; Standalone []*Standalone |
| 23 | Identity, Address, TenantARN string |
| 24 | PerInstance bool |
| 25 | Salt string |
| 26 | } |
| 27 | func SendersPrefix(acct, app string) string |
| 28 | func SenderKey(acct, app, id string) string |
| 29 | func ValidSenderID(id string) bool |
| 30 | type SenderNameSet struct{ Slug, IAMUser, Tenant, ConfigSet string } |
| 31 | func SenderNames(acct, app, senderID, salt string) (SenderNameSet, error) |
| 32 | func (n SenderNameSet) StandaloneUser(acct, app, credential string) (string, error) |
| 33 | var ErrNameTooLong error |
| 34 | type CallerIndex struct{ Schema int; IAMUser, Account, App, Sender string; CreatedAt time.Time } |
| 35 | func CallerKey(iamUser string) string |
| 36 | func GetCallerIndex(ctx context.Context, s release.Store, iamUser string) (*CallerIndex, error) |
| 37 | func PutCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string, now time.Time) error |
| 38 | func DeleteCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string) error |
| 39 | func ResolveSenderNames(ctx context.Context, s release.Store, acct, app string, sn *Sender, now time.Time) (SenderNameSet, bool, error) |
| 40 | func SenderOfCaller(ctx context.Context, s release.Store, caller string) (acct, app, sender string, ok bool) |
| 41 | var ErrCallerClaimed error |
| 42 | |
| 43 | // internal/mail/mail.go |
| 44 | func Key(acct, app string) string // apps/<acct>/<app>/email/senders/app.json |
| 45 | func LegacyKey(acct, app string) string // apps/<acct>/<app>/email.json |
| 46 | func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) |
| 47 | func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) |
| 48 | func GetSender(ctx context.Context, s release.Store, acct, app, id string) (*Sender, string, error) |
| 49 | func PutSender(ctx context.Context, s release.Store, acct, app string, sn *Sender, etag string) (string, error) |
| 50 | func ListSenders(ctx context.Context, s release.Store, acct, app string) ([]*Sender, error) |
| 51 | func (r *Record) sender() *Sender // unexported; carries Identity/Address/PerInstance/Salt forward |
| 52 | func (s *Sender) record(app string) *Record |
| 53 | |
| 54 | // internal/mail/provisioner.go, aws.go, memprovisioner.go |
| 55 | AssociateTenantResource(ctx context.Context, tenant, resourceARN string) error |
| 56 | DisassociateTenantResource(ctx context.Context, tenant, resourceARN string) error |
| 57 | // MemProvisioner.TenantResources map[string]map[string]bool, and DeleteIdentity refuses an associated identity. |
| 58 | |
| 59 | // internal/mail/domain.go |
| 60 | func ConfigSetARN(identityARN, configSet string) (string, bool) |
| 61 | |
| 62 | // internal/mail/walk.go |
| 63 | func SetSenderPaused(ctx context.Context, s release.Store, p Provisioner, acct, app, sender string, paused bool, kind string, now time.Time) (*Sender, error) |
| 64 | var ErrPerInstance error |
| 65 | |
| 66 | // internal/console |
| 67 | func (s *Store) RecordMailEvent(ctx context.Context, acct, app, sender, day, evType string) error |
| 68 | func (s *Store) MailCounts(ctx context.Context, acct, app, sender, day string) (MailCounts, error) |
| 69 | func (s *Server) enforceMailLimits(ctx context.Context, acct, app, sender string, now time.Time) (bool, string, error) |
| 70 | func (s *Server) resumeStaleCapPause(ctx context.Context, acct, app, sender string, sn *mail.Sender, now time.Time) (*mail.Sender, bool, error) |
| 71 | func mailCapFor(g *mail.GrantBlock) int |
| 72 | // emailReply's map carries "senders": []map[string]any{...} beside its unchanged top-level fields. |
| 73 | |
| 74 | // mail_cli.go |
| 75 | type apiEmailSender struct{ ID, Scope, Source, Host, Address string; Active bool; PauseKind string; DailyCap int; KeyID string; Counts apiEmailCounts } |
| 76 | // apiEmailReply.Senders []apiEmailSender `json:"senders"` |
| 77 | ``` |
| 78 | |
| 79 | ## Global Constraints |
| 80 | |
| 81 | - **The gate before every commit:** `go build ./... && go vet ./... && go test ./... -count=1` all clean, and `gofmt -l .` empty over the whole tree. Env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`. Socket-binding tests need `TMPDIR=/tmp/g`. Go commands may need the sandbox disabled; "read-only file system" from the toolchain is the tell. |
| 82 | - **This repo lands through `amadan branch merge`, never a GitHub PR.** Branch `worktree-tenant-email-senders`. CI is `.amadan/ci.d/`, triggered by a push to the `amadan` remote; a branch pushed only to `origin` has been gated by nothing. |
| 83 | - **The spike is finished and its answers are in §4.1. Build to them, with no conditional branches and no feature flags.** The `ses:TenantName` condition stays in the address policy; the bundle carries both `<P>_CONFIG_SET` and `<P>_TENANT`; enable refuses an external identity whose own default configuration set is not CARLOS's; teardown disassociates before anything else, because SES refuses to delete or release an identity that still has an association. |
| 84 | - **CARLOS NEVER WRITES A BORROWED IDENTITY.** `CreateEmailIdentity`, `PutEmailIdentityMailFromAttributes` (both inside `EnsureIdentity`), `SetIdentityConfigSet` and `DeleteIdentity` are refused for a registered domain and for every subdomain of one. The wrapper is the enforcement, the `EnsureDomain` refusal is the door, and both are tested. |
| 85 | - **No behaviour change for a domain sender.** `SendPolicyJSON` renders the same bytes it renders today, with no tenant condition added: nothing delivers `<P>_TENANT` to a domain sender, so a tenant condition on that document would be 554 on every send for the whole fleet. The address form is a second renderer, and a test pins the domain form byte-identical. |
| 86 | - **Wire-shape changes are additive.** `emailReply`'s existing fields keep their meanings; the address sender appears through plan 2's `senders` array plus the fields this plan adds to each entry. An older CLI keeps working. |
| 87 | - **Every door refuses the same way it refuses today.** An unknown account, an unknown app and someone else's account are one byte-identical 404 (`apiAccountApp`); the operator doors are `cliUser` -> `IsOperator` -> `mailOperator` -> a bare `http.NotFound`, exactly as `handleAPIEmailPause` (`apimail.go:1290-1304`) and `storeEntitlementOperator` (`apistore_entitlement.go:92`) do. |
| 88 | - **Every line number here is PRE-PLAN-2.** They were read off the tree as it stands before plan 2 lands, and they are exact against that tree — but plan 2 inserts code above most of them in `walk.go`, `mail.go`, `provisioner.go` and `memprovisioner.go`, so by the time this plan is executed they will all have drifted down. Navigate by FUNCTION NAME and use the number as a hint about where to look. A citation that lands in the wrong place is a citation to re-read, not a reason to edit what is there. |
| 89 | - Copy: no em dashes. Full stop and a new sentence, lower case. Comments are full sentences saying why, never process history; log lines use `->` between values. |
| 90 | - Tasks 14 and 15 add member-facing CLI copy. Run the `copy-review` gate on the new strings before they land, as every user-facing string in this repo does. |
| 91 | - Commit after every task with the trailer `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`. |
| 92 | |
| 93 | --- |
| 94 | |
| 95 | ## File Structure |
| 96 | |
| 97 | | File | Responsibility | |
| 98 | |---|---| |
| 99 | | `internal/mail/external.go` (create) | `ExternalIdentity`, `ExternalIdentityKey`, `Get`/`Put`/`Delete`/`ListExternalIdentities`, `ExternalIdentityFor` (the parent walk) | |
| 100 | | `internal/mail/external_test.go` (create) | the record, the global key, the subdomain cover, the ARN refusal | |
| 101 | | `internal/mail/guard.go` (create) | `GuardExternalIdentities`: the `Provisioner` wrapper refusing the four write verbs, `ErrExternalIdentity` | |
| 102 | | `internal/mail/guard_test.go` (create) | the pinned refusal, including a subdomain, and the pass-through for everything else | |
| 103 | | `internal/mail/addressclaim.go` (create) | `AddressClaim`, `ValidAddress`, `ClaimAddress`, `ReleaseAddressClaim`, `GetAddressClaim`, `ListAddressClaims`, `ErrAddressClaimed`, `ErrAddressNotOpen` | |
| 104 | | `internal/mail/policy.go` (modify) | `SendAddressPolicyJSON`: the address form, `StringEqualsIgnoreCase ses:FromAddress` plus `StringEquals ses:TenantName` | |
| 105 | | `internal/mail/provisioner.go`, `aws.go`, `memprovisioner.go` (modify) | `EnsureAddressUser`; `Status.VerifiedExternal()` | |
| 106 | | `internal/mail/walk.go` (modify) | `EnsureDomain` refuses before `claimDomain`; `ClaimAddressSender`; `Enable`'s address branch; `writeSMTPEnv`'s extra vars; `EnvDeliveredSender`; `Teardown`'s address branch and the orphan sweep | |
| 107 | | `internal/svcrole/svcrole.go` (modify) | `Mail` joins the catalogue | |
| 108 | | `internal/console/svcroutes_test.go` (modify) | `closedDoors` gains the operator doors, the count moves, and the new role's door list is pinned | |
| 109 | | `internal/console/apimail.go` (modify) | every email door moves to `Allow(svcrole.Mail)`; `mailWriteGates` takes the service budget; the address door; the reply's address fields | |
| 110 | | `internal/console/apiexternal.go` (create) | the four operator doors and the operator grant | |
| 111 | | `cmd/console/main.go` (modify) | wrap the SES provisioner in `GuardExternalIdentities` | |
| 112 | | `mail_cli.go` (modify) | `carlos email external-identity register/deregister/list/claims/grant`, and `enable --from` taking the address path | |
| 113 | |
| 114 | --- |
| 115 | |
| 116 | ### Task 1: The external-identity record |
| 117 | |
| 118 | **Files:** |
| 119 | - Create: `internal/mail/external.go`, `internal/mail/external_test.go` |
| 120 | |
| 121 | **Interfaces:** |
| 122 | - Consumes: `ValidDomain` (`domain.go:78`), `Schema` (`mail.go:28`), `release.Store`. |
| 123 | - Produces: |
| 124 | ```go |
| 125 | type ExternalIdentity struct { |
| 126 | Schema int `json:"schema"` |
| 127 | Domain string `json:"domain"` |
| 128 | Region string `json:"region"` |
| 129 | IdentityARN string `json:"identity_arn"` |
| 130 | Open bool `json:"open"` |
| 131 | Note string `json:"note,omitempty"` |
| 132 | By string `json:"by"` |
| 133 | At time.Time `json:"at"` |
| 134 | } |
| 135 | func ExternalIdentityKey(domain string) string |
| 136 | func GetExternalIdentity(ctx context.Context, s release.Store, domain string) (*ExternalIdentity, string, error) |
| 137 | func PutExternalIdentity(ctx context.Context, s release.Store, rec *ExternalIdentity, etag string) (string, error) |
| 138 | func DeleteExternalIdentity(ctx context.Context, s release.Store, domain string) error |
| 139 | func ListExternalIdentities(ctx context.Context, s release.Store) ([]*ExternalIdentity, error) |
| 140 | func ExternalIdentityFor(ctx context.Context, s release.Store, domain string) (*ExternalIdentity, error) |
| 141 | ``` |
| 142 | |
| 143 | - [ ] **Step 1: Write the failing test** |
| 144 | |
| 145 | `internal/mail/external_test.go` (create): |
| 146 | |
| 147 | ```go |
| 148 | package mail |
| 149 | |
| 150 | import ( |
| 151 | "context" |
| 152 | "errors" |
| 153 | "os" |
| 154 | "testing" |
| 155 | "time" |
| 156 | |
| 157 | "github.com/carlosframework/platform/internal/release" |
| 158 | ) |
| 159 | |
| 160 | // The external identity is registered ONCE PER DEPLOYMENT, not per account: |
| 161 | // tito.io is one identity at SES whoever asks about it, so its record lives |
| 162 | // at a global key exactly as the domain claim does (domainclaim.go:42). |
| 163 | func TestExternalIdentityIsRegisteredGloballyAndReadBack(t *testing.T) { |
| 164 | ctx := context.Background() |
| 165 | s := release.NewDirStore(t.TempDir()) |
| 166 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 167 | |
| 168 | if got := ExternalIdentityKey("tito.io"); got != "email/external-identities/tito.io.json" { |
| 169 | t.Fatalf("ExternalIdentityKey = %q", got) |
| 170 | } |
| 171 | rec := &ExternalIdentity{ |
| 172 | Domain: "tito.io", Region: "eu-west-1", |
| 173 | IdentityARN: "arn:aws:ses:eu-west-1:665106592142:identity/tito.io", |
| 174 | Open: true, Note: "tito classic owns this identity", By: "paul@tito.io", At: now, |
| 175 | } |
| 176 | etag, err := PutExternalIdentity(ctx, s, rec, "") |
| 177 | if err != nil || etag == "" { |
| 178 | t.Fatalf("PutExternalIdentity = %q %v", etag, err) |
| 179 | } |
| 180 | got, gotEtag, err := GetExternalIdentity(ctx, s, "tito.io") |
| 181 | if err != nil || got == nil { |
| 182 | t.Fatalf("GetExternalIdentity = %+v %v", got, err) |
| 183 | } |
| 184 | if got.IdentityARN != rec.IdentityARN || !got.Open || got.By != "paul@tito.io" || got.Schema != Schema { |
| 185 | t.Fatalf("round trip lost something: %+v", got) |
| 186 | } |
| 187 | // A CAS position, so a second operator editing the record concurrently |
| 188 | // loses rather than clobbers. |
| 189 | if _, err := PutExternalIdentity(ctx, s, rec, ""); !errors.Is(err, release.ErrCASConflict) { |
| 190 | t.Fatalf("a create over an existing record = %v, want ErrCASConflict", err) |
| 191 | } |
| 192 | if _, err := PutExternalIdentity(ctx, s, rec, gotEtag); err != nil { |
| 193 | t.Fatalf("a write at the right etag = %v", err) |
| 194 | } |
| 195 | |
| 196 | all, err := ListExternalIdentities(ctx, s) |
| 197 | if err != nil || len(all) != 1 || all[0].Domain != "tito.io" { |
| 198 | t.Fatalf("ListExternalIdentities = %+v %v", all, err) |
| 199 | } |
| 200 | if err := DeleteExternalIdentity(ctx, s, "tito.io"); err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | if got, _, err := GetExternalIdentity(ctx, s, "tito.io"); got != nil || err != nil { |
| 204 | t.Fatalf("after delete = %+v %v, want (nil, nil)", got, err) |
| 205 | } |
| 206 | // Absent is a state, not an error, matching every other read here. |
| 207 | if _, _, err := GetExternalIdentity(ctx, s, "nobody.example.com"); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 208 | t.Fatalf("an absent record errored: %v", err) |
| 209 | } |
| 210 | // Deleting twice is a quiet no-op, so a retried deregistration is fine. |
| 211 | if err := DeleteExternalIdentity(ctx, s, "tito.io"); err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // The refusal scope is the identity AND every subdomain of it (design §7): a |
| 217 | // claim on mail.tito.io would otherwise let CARLOS create, and later delete, |
| 218 | // an identity underneath one it does not own. |
| 219 | func TestExternalIdentityCoversEverySubdomain(t *testing.T) { |
| 220 | ctx := context.Background() |
| 221 | s := release.NewDirStore(t.TempDir()) |
| 222 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 223 | if _, err := PutExternalIdentity(ctx, s, &ExternalIdentity{ |
| 224 | Domain: "tito.io", Region: "eu-west-1", |
| 225 | IdentityARN: "arn:aws:ses:eu-west-1:665106592142:identity/tito.io", By: "op", At: now, |
| 226 | }, ""); err != nil { |
| 227 | t.Fatal(err) |
| 228 | } |
| 229 | for _, under := range []string{"tito.io", "mail.tito.io", "a.b.tito.io"} { |
| 230 | got, err := ExternalIdentityFor(ctx, s, under) |
| 231 | if err != nil || got == nil || got.Domain != "tito.io" { |
| 232 | t.Errorf("ExternalIdentityFor(%q) = %+v %v, want tito.io", under, got, err) |
| 233 | } |
| 234 | } |
| 235 | // A label-aware match, the same correction DomainKindFor carries |
| 236 | // (walk.go:61): a name that merely ENDS with the string is a different |
| 237 | // zone entirely, and the bare public suffix is nobody's. |
| 238 | for _, outside := range []string{"eviltito.io", "example.com", "io.example.com"} { |
| 239 | got, err := ExternalIdentityFor(ctx, s, outside) |
| 240 | if err != nil || got != nil { |
| 241 | t.Errorf("ExternalIdentityFor(%q) = %+v %v, want nothing", outside, got, err) |
| 242 | } |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | // The ARN is the string a policy Resource is built from, so it is validated |
| 247 | // at the one gate every registration passes through: its region and its |
| 248 | // identity name must be the record's own, or the policy would authorise a |
| 249 | // different identity from the one the operator thinks they registered. |
| 250 | func TestPutExternalIdentityRefusesAnARNThatDisagreesWithTheRecord(t *testing.T) { |
| 251 | ctx := context.Background() |
| 252 | s := release.NewDirStore(t.TempDir()) |
| 253 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 254 | for _, bad := range []*ExternalIdentity{ |
| 255 | {Domain: "tito.io", Region: "eu-west-1", IdentityARN: "arn:aws:ses:us-east-1:1:identity/tito.io", By: "op", At: now}, |
| 256 | {Domain: "tito.io", Region: "eu-west-1", IdentityARN: "arn:aws:ses:eu-west-1:1:identity/other.io", By: "op", At: now}, |
| 257 | {Domain: "tito.io", Region: "eu-west-1", IdentityARN: "arn:aws:ses:eu-west-1:1:identity/*", By: "op", At: now}, |
| 258 | {Domain: "tito.io", Region: "eu-west-1", IdentityARN: "not-an-arn", By: "op", At: now}, |
| 259 | {Domain: "tito.io", Region: "", IdentityARN: "arn:aws:ses:eu-west-1:1:identity/tito.io", By: "op", At: now}, |
| 260 | {Domain: "not a domain", Region: "eu-west-1", IdentityARN: "arn:aws:ses:eu-west-1:1:identity/tito.io", By: "op", At: now}, |
| 261 | } { |
| 262 | if _, err := PutExternalIdentity(ctx, s, bad, ""); err == nil { |
| 263 | t.Errorf("PutExternalIdentity accepted %+v", bad) |
| 264 | } |
| 265 | } |
| 266 | } |
| 267 | ``` |
| 268 | |
| 269 | - [ ] **Step 2: Run it to verify it fails** |
| 270 | |
| 271 | ```bash |
| 272 | go test -run 'TestExternalIdentity|TestPutExternalIdentity' ./internal/mail/ |
| 273 | ``` |
| 274 | |
| 275 | Expected: build failure, `undefined: ExternalIdentity`. |
| 276 | |
| 277 | - [ ] **Step 3: Write the code** |
| 278 | |
| 279 | `internal/mail/external.go` (create): |
| 280 | |
| 281 | ```go |
| 282 | // The external identity (senders design §7): an SES identity CARLOS does |
| 283 | // NOT own, registered once per deployment by an operator so that accounts |
| 284 | // may send as single addresses under it. |
| 285 | // |
| 286 | // tito.io is the case this exists for. Tito Classic has owned that identity |
| 287 | // in the same AWS account for years, and the ordinary walk would adopt it and |
| 288 | // then rewrite it: EnsureIdentity moves MAIL FROM (aws.go:292), |
| 289 | // SetIdentityConfigSet binds CARLOS's configuration set as the identity's |
| 290 | // default (walk.go:301), the domain claim is first-come (domainclaim.go:119), |
| 291 | // and Teardown deletes the identity (walk.go:1205). Every one of those is a |
| 292 | // change to somebody else's mail. So a registered identity is read-only to |
| 293 | // CARLOS for ever: guard.go refuses the four write verbs, EnsureDomain |
| 294 | // refuses the domain before it claims anything, and the only durable thing |
| 295 | // CARLOS ever does to it is a tenant association, which the spike confirmed |
| 296 | // is inert for the owner in every respect except deletion (§4.1). |
| 297 | package mail |
| 298 | |
| 299 | import ( |
| 300 | "context" |
| 301 | "encoding/json" |
| 302 | "errors" |
| 303 | "fmt" |
| 304 | "log" |
| 305 | "os" |
| 306 | "strings" |
| 307 | "time" |
| 308 | |
| 309 | "github.com/carlosframework/platform/internal/release" |
| 310 | ) |
| 311 | |
| 312 | // externalIdentitiesPrefix is GLOBAL, not under accounts/<sqid>/, for the |
| 313 | // reason domainClaimsPrefix is: tito.io is ONE identity at SES whoever is |
| 314 | // asking, and a per-account copy of that fact would let two accounts hold two |
| 315 | // different answers about the same identity. |
| 316 | const externalIdentitiesPrefix = "email/external-identities/" |
| 317 | |
| 318 | func ExternalIdentityKey(domain string) string { |
| 319 | return externalIdentitiesPrefix + domain + ".json" |
| 320 | } |
| 321 | |
| 322 | // ExternalIdentity is the wire shape at ExternalIdentityKey(Domain). |
| 323 | // |
| 324 | // Region must be the deployment's own, because tenants and identities are |
| 325 | // both per region and an association across regions is not a thing SES has. |
| 326 | // Open says whether any account in the deployment may claim an address under |
| 327 | // this identity for itself; when it is false, an operator writes the claim as |
| 328 | // a grant instead (addressclaim.go). The trade-off Open carries is recorded |
| 329 | // in the design: with it, any account here can claim billing@tito.io, and the |
| 330 | // mitigation if that ever bites is a Local allowlist that is deliberately not |
| 331 | // built now. |
| 332 | type ExternalIdentity struct { |
| 333 | Schema int `json:"schema"` // 1 |
| 334 | Domain string `json:"domain"` |
| 335 | Region string `json:"region"` |
| 336 | IdentityARN string `json:"identity_arn"` |
| 337 | Open bool `json:"open"` |
| 338 | Note string `json:"note,omitempty"` |
| 339 | By string `json:"by"` |
| 340 | At time.Time `json:"at"` |
| 341 | } |
| 342 | |
| 343 | // GetExternalIdentity reads one registration and its CAS position. Absent is |
| 344 | // (nil, "", nil): "not registered" is a state, not an error, the same idiom |
| 345 | // GetDomain uses (domain.go:82). |
| 346 | func GetExternalIdentity(ctx context.Context, s release.Store, domain string) (*ExternalIdentity, string, error) { |
| 347 | if !ValidDomain(domain) { |
| 348 | return nil, "", fmt.Errorf("mail: invalid domain %q", domain) |
| 349 | } |
| 350 | raw, etag, err := s.Get(ctx, ExternalIdentityKey(domain)) |
| 351 | if err != nil { |
| 352 | if errors.Is(err, os.ErrNotExist) { |
| 353 | return nil, "", nil |
| 354 | } |
| 355 | return nil, "", err |
| 356 | } |
| 357 | var rec ExternalIdentity |
| 358 | if err := json.Unmarshal(raw, &rec); err != nil { |
| 359 | return nil, "", fmt.Errorf("mail: external identity %s: %w", domain, err) |
| 360 | } |
| 361 | if rec.Schema > Schema { |
| 362 | return nil, "", fmt.Errorf("mail: external identity %s: schema %d is newer than this binary understands, upgrade carlos", domain, rec.Schema) |
| 363 | } |
| 364 | return &rec, etag, nil |
| 365 | } |
| 366 | |
| 367 | // PutExternalIdentity records a registration at the caller's CAS position; |
| 368 | // etag "" is create-only. |
| 369 | // |
| 370 | // The ARN is validated against the record's own domain and region, and not |
| 371 | // merely for shape. This string becomes the Resource of an IAM policy |
| 372 | // (SendAddressPolicyJSON), so an ARN naming a different identity would |
| 373 | // authorise sending as something nobody registered, and a wildcard would |
| 374 | // authorise the whole estate. SendPolicyJSON already refuses those at render |
| 375 | // time; refusing them at registration means the operator hears about it while |
| 376 | // they are still typing rather than at the first enable. |
| 377 | func PutExternalIdentity(ctx context.Context, s release.Store, rec *ExternalIdentity, etag string) (string, error) { |
| 378 | if !ValidDomain(rec.Domain) { |
| 379 | return "", fmt.Errorf("mail: invalid domain %q", rec.Domain) |
| 380 | } |
| 381 | if rec.Region == "" { |
| 382 | return "", fmt.Errorf("mail: external identity %s: a region is required, because tenants and identities are both per region", rec.Domain) |
| 383 | } |
| 384 | if !validIdentityARNFor(rec.IdentityARN, rec.Region, rec.Domain) { |
| 385 | return "", fmt.Errorf("mail: external identity %s: %q is not an SES identity ARN for %s in %s", |
| 386 | rec.Domain, rec.IdentityARN, rec.Domain, rec.Region) |
| 387 | } |
| 388 | rec.Schema = Schema |
| 389 | body, err := json.MarshalIndent(rec, "", " ") |
| 390 | if err != nil { |
| 391 | return "", err |
| 392 | } |
| 393 | return s.Put(ctx, ExternalIdentityKey(rec.Domain), append(body, '\n'), etag) |
| 394 | } |
| 395 | |
| 396 | // validIdentityARNFor reports whether arn is exactly |
| 397 | // arn:aws:ses:<region>:<account-id>:identity/<domain>, with no wildcard |
| 398 | // anywhere: IAM expands * and ? as match patterns in every segment, not only |
| 399 | // in the identity name (SendPolicyJSON's own reasoning, policy.go:143). |
| 400 | func validIdentityARNFor(arn, region, domain string) bool { |
| 401 | if strings.ContainsAny(arn, "*?") { |
| 402 | return false |
| 403 | } |
| 404 | parts := strings.Split(arn, ":") |
| 405 | if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "ses" { |
| 406 | return false |
| 407 | } |
| 408 | if parts[3] != region || parts[4] == "" { |
| 409 | return false |
| 410 | } |
| 411 | return parts[5] == "identity/"+domain |
| 412 | } |
| 413 | |
| 414 | // DeleteExternalIdentity deregisters. Already absent is a quiet no-op, so a |
| 415 | // retried deregistration is not an error. |
| 416 | func DeleteExternalIdentity(ctx context.Context, s release.Store, domain string) error { |
| 417 | if !ValidDomain(domain) { |
| 418 | return fmt.Errorf("mail: invalid domain %q", domain) |
| 419 | } |
| 420 | return s.Delete(ctx, ExternalIdentityKey(domain)) |
| 421 | } |
| 422 | |
| 423 | // ListExternalIdentities is the operator's roster of registrations. An |
| 424 | // unparseable object is logged and skipped, the discipline ListDomainClaims |
| 425 | // applies (domainclaim.go:185): one bad record must never take the listing |
| 426 | // down. |
| 427 | func ListExternalIdentities(ctx context.Context, s release.Store) ([]*ExternalIdentity, error) { |
| 428 | keys, err := s.List(ctx, externalIdentitiesPrefix) |
| 429 | if err != nil { |
| 430 | return nil, fmt.Errorf("mail: list external identities: %w", err) |
| 431 | } |
| 432 | out := make([]*ExternalIdentity, 0, len(keys)) |
| 433 | for _, key := range keys { |
| 434 | raw, _, err := s.Get(ctx, key) |
| 435 | if err != nil { |
| 436 | log.Printf("mail: external identity %s -> %v", key, err) |
| 437 | continue |
| 438 | } |
| 439 | var rec ExternalIdentity |
| 440 | if err := json.Unmarshal(raw, &rec); err != nil { |
| 441 | log.Printf("mail: external identity %s: unparseable -> %v", key, err) |
| 442 | continue |
| 443 | } |
| 444 | if rec.Schema > Schema || !ValidDomain(rec.Domain) || ExternalIdentityKey(rec.Domain) != key { |
| 445 | continue |
| 446 | } |
| 447 | out = append(out, &rec) |
| 448 | } |
| 449 | sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain }) |
| 450 | return out, nil |
| 451 | } |
| 452 | |
| 453 | // ExternalIdentityFor answers "is this name, or anything above it, an |
| 454 | // identity CARLOS must not touch". It walks the labels upwards, so |
| 455 | // mail.tito.io resolves to tito.io's registration. |
| 456 | // |
| 457 | // The walk is LABEL-AWARE by construction, which is the correction |
| 458 | // DomainKindFor's doc comment records for the platform suffix (walk.go:61): |
| 459 | // eviltito.io ends with the string "tito.io" and is a different zone |
| 460 | // entirely. It stops before a single label, because a public suffix is |
| 461 | // nobody's identity and a registration at one would refuse half the internet. |
| 462 | func ExternalIdentityFor(ctx context.Context, s release.Store, domain string) (*ExternalIdentity, error) { |
| 463 | if !ValidDomain(domain) { |
| 464 | return nil, fmt.Errorf("mail: invalid domain %q", domain) |
| 465 | } |
| 466 | for name := domain; ; { |
| 467 | rec, _, err := GetExternalIdentity(ctx, s, name) |
| 468 | if err != nil { |
| 469 | return nil, err |
| 470 | } |
| 471 | if rec != nil { |
| 472 | return rec, nil |
| 473 | } |
| 474 | i := strings.Index(name, ".") |
| 475 | if i < 0 { |
| 476 | return nil, nil |
| 477 | } |
| 478 | name = name[i+1:] |
| 479 | if !strings.Contains(name, ".") { |
| 480 | return nil, nil |
| 481 | } |
| 482 | } |
| 483 | } |
| 484 | ``` |
| 485 | |
| 486 | Add `"sort"` to the file's imports. |
| 487 | |
| 488 | - [ ] **Step 4: Run it to verify it passes** |
| 489 | |
| 490 | ```bash |
| 491 | go test -run 'TestExternalIdentity|TestPutExternalIdentity' ./internal/mail/ |
| 492 | ``` |
| 493 | |
| 494 | Expected: PASS. |
| 495 | |
| 496 | - [ ] **Step 5: Gate and commit** |
| 497 | |
| 498 | ```bash |
| 499 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 500 | git add internal/mail/external.go internal/mail/external_test.go |
| 501 | git commit -m "mail: the external-identity record, registered once per deployment (plan 3, task 1)" |
| 502 | ``` |
| 503 | |
| 504 | --- |
| 505 | |
| 506 | ### Task 2: The provisioner wrapper that refuses to touch a borrowed identity |
| 507 | |
| 508 | **Files:** |
| 509 | - Create: `internal/mail/guard.go`, `internal/mail/guard_test.go` |
| 510 | - Modify: `cmd/console/main.go` (the `mailBackends` call at `:264`, the `Releases` field at `:269`) |
| 511 | |
| 512 | **Interfaces:** |
| 513 | - Consumes: `Provisioner` (`provisioner.go:49`), `ExternalIdentityFor`. |
| 514 | - Produces: |
| 515 | ```go |
| 516 | var ErrExternalIdentity = errors.New("mail: identity is registered as external and CARLOS may not change it") |
| 517 | func GuardExternalIdentities(p Provisioner, s release.Store) Provisioner |
| 518 | ``` |
| 519 | Refuses `EnsureIdentity`, `SetIdentityConfigSet` and `DeleteIdentity`; every other verb passes through the embedded `Provisioner` unchanged. |
| 520 | |
| 521 | - [ ] **Step 1: Write the failing test** |
| 522 | |
| 523 | `internal/mail/guard_test.go` (create): |
| 524 | |
| 525 | ```go |
| 526 | package mail |
| 527 | |
| 528 | import ( |
| 529 | "context" |
| 530 | "errors" |
| 531 | "testing" |
| 532 | "time" |
| 533 | |
| 534 | "github.com/carlosframework/platform/internal/release" |
| 535 | ) |
| 536 | |
| 537 | // registerTitoIO is the fixture every test in this file starts from: an |
| 538 | // identity somebody else owns, registered here so CARLOS will not touch it. |
| 539 | func registerTitoIO(t *testing.T, ctx context.Context, s release.Store, open bool) *ExternalIdentity { |
| 540 | t.Helper() |
| 541 | rec := &ExternalIdentity{ |
| 542 | Domain: "tito.io", Region: "eu-west-1", |
| 543 | IdentityARN: "arn:aws:ses:eu-west-1:1:identity/tito.io", |
| 544 | Open: open, By: "paul@tito.io", At: time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC), |
| 545 | } |
| 546 | if _, err := PutExternalIdentity(ctx, s, rec, ""); err != nil { |
| 547 | t.Fatal(err) |
| 548 | } |
| 549 | return rec |
| 550 | } |
| 551 | |
| 552 | // THE PINNED REFUSAL (design §7). CreateEmailIdentity and |
| 553 | // PutEmailIdentityMailFromAttributes both live inside EnsureIdentity, which |
| 554 | // would move tito.io's MAIL FROM to mail-eu-west-1.tito.io; |
| 555 | // SetIdentityConfigSet would take over the identity's default configuration |
| 556 | // set, which is Classic's; DeleteIdentity would destroy it outright. All |
| 557 | // three are refused for the registered domain AND for anything under it. |
| 558 | func TestGuardRefusesEveryVerbThatWouldChangeABorrowedIdentity(t *testing.T) { |
| 559 | ctx := context.Background() |
| 560 | s, inner := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| 561 | registerTitoIO(t, ctx, s, true) |
| 562 | p := GuardExternalIdentities(inner, s) |
| 563 | |
| 564 | for _, domain := range []string{"tito.io", "mail.tito.io", "a.b.tito.io"} { |
| 565 | if _, err := p.EnsureIdentity(ctx, "eu-west-1", domain, "mail-eu-west-1."+domain); !errors.Is(err, ErrExternalIdentity) { |
| 566 | t.Errorf("EnsureIdentity(%q) = %v, want ErrExternalIdentity", domain, err) |
| 567 | } |
| 568 | if err := p.SetIdentityConfigSet(ctx, "eu-west-1", domain, "carlos-bag"); !errors.Is(err, ErrExternalIdentity) { |
| 569 | t.Errorf("SetIdentityConfigSet(%q) = %v, want ErrExternalIdentity", domain, err) |
| 570 | } |
| 571 | if err := p.DeleteIdentity(ctx, "eu-west-1", domain); !errors.Is(err, ErrExternalIdentity) { |
| 572 | t.Errorf("DeleteIdentity(%q) = %v, want ErrExternalIdentity", domain, err) |
| 573 | } |
| 574 | } |
| 575 | // Nothing reached the backend: a refusal that had already called through |
| 576 | // would be no refusal at all. |
| 577 | if len(inner.Identities) != 0 { |
| 578 | t.Fatalf("the guard let an identity be created: %+v", inner.Identities) |
| 579 | } |
| 580 | |
| 581 | // An identity SES already holds for the owner is not disturbed by a |
| 582 | // refused delete, which is the whole property: Classic's identity |
| 583 | // survives whatever CARLOS's walks try to do to it. |
| 584 | if _, err := inner.EnsureIdentity(ctx, "eu-west-1", "tito.io", ""); err != nil { |
| 585 | t.Fatal(err) |
| 586 | } |
| 587 | if err := p.DeleteIdentity(ctx, "eu-west-1", "tito.io"); !errors.Is(err, ErrExternalIdentity) { |
| 588 | t.Fatalf("DeleteIdentity on the live identity = %v, want ErrExternalIdentity", err) |
| 589 | } |
| 590 | if _, ok := inner.Identities[identityKey("eu-west-1", "tito.io")]; !ok { |
| 591 | t.Fatal("the guard deleted the identity it exists to protect") |
| 592 | } |
| 593 | } |
| 594 | |
| 595 | // Everything else passes straight through, including the verbs this design |
| 596 | // DOES perform on a borrowed identity: reading its status, and associating |
| 597 | // it with a tenant, which the spike confirmed is inert for its owner (§4.1). |
| 598 | func TestGuardPassesThroughEveryOtherVerb(t *testing.T) { |
| 599 | ctx := context.Background() |
| 600 | s, inner := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| 601 | registerTitoIO(t, ctx, s, true) |
| 602 | p := GuardExternalIdentities(inner, s) |
| 603 | |
| 604 | if _, err := inner.EnsureIdentity(ctx, "eu-west-1", "tito.io", ""); err != nil { |
| 605 | t.Fatal(err) |
| 606 | } |
| 607 | if _, err := p.IdentityStatus(ctx, "eu-west-1", "tito.io"); err != nil { |
| 608 | t.Errorf("IdentityStatus through the guard = %v", err) |
| 609 | } |
| 610 | if err := p.EnsureTenant(ctx, "carlos-bag"); err != nil { |
| 611 | t.Errorf("EnsureTenant through the guard = %v", err) |
| 612 | } |
| 613 | if err := p.AssociateTenantResource(ctx, "carlos-bag", "arn:aws:ses:eu-west-1:1:identity/tito.io"); err != nil { |
| 614 | t.Errorf("AssociateTenantResource through the guard = %v", err) |
| 615 | } |
| 616 | if err := p.DisassociateTenantResource(ctx, "carlos-bag", "arn:aws:ses:eu-west-1:1:identity/tito.io"); err != nil { |
| 617 | t.Errorf("DisassociateTenantResource through the guard = %v", err) |
| 618 | } |
| 619 | // A domain nobody registered is ordinary in every respect. |
| 620 | if _, err := p.EnsureIdentity(ctx, "eu-west-1", "harbourmail.bag.oncarlos.com", "mail-eu-west-1.harbourmail.bag.oncarlos.com"); err != nil { |
| 621 | t.Errorf("EnsureIdentity on an ordinary domain = %v", err) |
| 622 | } |
| 623 | if err := p.DeleteIdentity(ctx, "eu-west-1", "harbourmail.bag.oncarlos.com"); err != nil { |
| 624 | t.Errorf("DeleteIdentity on an ordinary domain = %v", err) |
| 625 | } |
| 626 | } |
| 627 | ``` |
| 628 | |
| 629 | - [ ] **Step 2: Run it to verify it fails** |
| 630 | |
| 631 | ```bash |
| 632 | go test -run TestGuard ./internal/mail/ |
| 633 | ``` |
| 634 | |
| 635 | Expected: build failure, `undefined: GuardExternalIdentities`. |
| 636 | |
| 637 | - [ ] **Step 3: Write the code** |
| 638 | |
| 639 | `internal/mail/guard.go` (create): |
| 640 | |
| 641 | ```go |
| 642 | // The guard (senders design §7): a Provisioner that refuses every verb which |
| 643 | // would change an identity CARLOS does not own. |
| 644 | // |
| 645 | // It is a WRAPPER rather than a check inside each walk, and that is the whole |
| 646 | // point. EnsureDomain's own refusal (walk.go) is the door a member meets, and |
| 647 | // it is where the good error message lives; this is the floor underneath it, |
| 648 | // so that a walk added later, or a repair path nobody thought about, cannot |
| 649 | // reach CreateEmailIdentity for tito.io by a route the door does not cover. |
| 650 | // One of the two would be a policy; both is an invariant. |
| 651 | package mail |
| 652 | |
| 653 | import ( |
| 654 | "context" |
| 655 | "errors" |
| 656 | "fmt" |
| 657 | |
| 658 | "github.com/carlosframework/platform/internal/release" |
| 659 | ) |
| 660 | |
| 661 | // ErrExternalIdentity is the refusal. Callers match on it rather than on the |
| 662 | // message, and the console door turns it into a sentence that says what to do |
| 663 | // instead (claim an address under the identity). |
| 664 | var ErrExternalIdentity = errors.New("mail: identity is registered as external and CARLOS may not change it") |
| 665 | |
| 666 | // guardedProvisioner embeds the real one, so every verb this file does not |
| 667 | // name passes through unchanged and a verb ADDED to the interface later is |
| 668 | // forwarded by construction rather than silently dropped. |
| 669 | type guardedProvisioner struct { |
| 670 | Provisioner |
| 671 | store release.Store |
| 672 | } |
| 673 | |
| 674 | // GuardExternalIdentities wraps p so the four write verbs are refused for a |
| 675 | // registered identity and for every subdomain of one. A nil provisioner or a |
| 676 | // nil store is returned unwrapped: a deployment with no SES estate has no |
| 677 | // identities to protect, and one with no bucket has nowhere to have recorded |
| 678 | // a registration. |
| 679 | func GuardExternalIdentities(p Provisioner, s release.Store) Provisioner { |
| 680 | if p == nil || s == nil { |
| 681 | return p |
| 682 | } |
| 683 | return &guardedProvisioner{Provisioner: p, store: s} |
| 684 | } |
| 685 | |
| 686 | // refuse answers nil when domain is CARLOS's to change. A read error is |
| 687 | // propagated rather than swallowed: "the bucket is unreachable" must not |
| 688 | // read as "this identity is ours", which would be the one failure mode worth |
| 689 | // having a guard about. |
| 690 | func (g *guardedProvisioner) refuse(ctx context.Context, verb, domain string) error { |
| 691 | rec, err := ExternalIdentityFor(ctx, g.store, domain) |
| 692 | if err != nil { |
| 693 | return fmt.Errorf("mail: checking external identities before %s on %s: %w", verb, domain, err) |
| 694 | } |
| 695 | if rec == nil { |
| 696 | return nil |
| 697 | } |
| 698 | return fmt.Errorf("mail: %s on %s would change %s, which is registered here as an identity this deployment does not own: %w", |
| 699 | verb, domain, rec.Domain, ErrExternalIdentity) |
| 700 | } |
| 701 | |
| 702 | // EnsureIdentity covers BOTH forbidden SES calls: CreateEmailIdentity and |
| 703 | // PutEmailIdentityMailFromAttributes are the two halves of it (aws.go:261, |
| 704 | // :292), and the second is the one that would silently move tito.io's |
| 705 | // envelope domain to mail-eu-west-1.tito.io and break Classic's DMARC |
| 706 | // alignment. |
| 707 | func (g *guardedProvisioner) EnsureIdentity(ctx context.Context, region, domain, mailFrom string) (Identity, error) { |
| 708 | if err := g.refuse(ctx, "ensure identity", domain); err != nil { |
| 709 | return Identity{}, err |
| 710 | } |
| 711 | return g.Provisioner.EnsureIdentity(ctx, region, domain, mailFrom) |
| 712 | } |
| 713 | |
| 714 | // SetIdentityConfigSet would make CARLOS's configuration set the identity's |
| 715 | // DEFAULT, which is the owner's setting: every message Classic sends without |
| 716 | // naming a set would start publishing events to CARLOS's destination. |
| 717 | func (g *guardedProvisioner) SetIdentityConfigSet(ctx context.Context, region, domain, configSet string) error { |
| 718 | if err := g.refuse(ctx, "set configuration set", domain); err != nil { |
| 719 | return err |
| 720 | } |
| 721 | return g.Provisioner.SetIdentityConfigSet(ctx, region, domain, configSet) |
| 722 | } |
| 723 | |
| 724 | // DeleteIdentity is the one that cannot be undone. SES would also refuse it |
| 725 | // while CARLOS's own tenant association stands (§4.1), but that refusal is a |
| 726 | // side effect of an association this deployment might not have yet, and it is |
| 727 | // not a rule anybody should rely on for this. |
| 728 | func (g *guardedProvisioner) DeleteIdentity(ctx context.Context, region, domain string) error { |
| 729 | if err := g.refuse(ctx, "delete identity", domain); err != nil { |
| 730 | return err |
| 731 | } |
| 732 | return g.Provisioner.DeleteIdentity(ctx, region, domain) |
| 733 | } |
| 734 | ``` |
| 735 | |
| 736 | `cmd/console/main.go`: the guard needs the deployment bucket, which is opened inline in the `Config` literal today (`Releases: openStore()`, `:269`). Lift it to a variable beside the other backends (plan 2's task 4 already lifts it for the legacy sweep; use that variable if it is there) and wrap: |
| 737 | |
| 738 | ```go |
| 739 | rel := openStore() |
| 740 | mailDep := mailDeployment() |
| 741 | mailProvisioner, dnsWriter := mailBackends(mailDep, mail.NewAWSProvisioner, dnsrec.NewRoute53Writer) |
| 742 | // The floor under every walk (senders design §7): an identity registered |
| 743 | // as external is read-only to CARLOS, whatever asks. Wrapped HERE, at the |
| 744 | // one place the real provisioner is constructed, so no console can be |
| 745 | // configured with an unguarded one. |
| 746 | mailProvisioner = mail.GuardExternalIdentities(mailProvisioner, rel) |
| 747 | ``` |
| 748 | |
| 749 | and `Releases: rel,` in the literal. The name is `rel` because that is what plan 2's task 4 hoists it as, for its own sweep; if plan 2 is in, this task adds only the wrap. |
| 750 | |
| 751 | - [ ] **Step 4: Run it to verify it passes** |
| 752 | |
| 753 | ```bash |
| 754 | go test -run TestGuard ./internal/mail/ && go build ./... |
| 755 | ``` |
| 756 | |
| 757 | Expected: PASS and a clean build. |
| 758 | |
| 759 | - [ ] **Step 5: Gate and commit** |
| 760 | |
| 761 | ```bash |
| 762 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 763 | git add internal/mail cmd/console |
| 764 | git commit -m "mail: a provisioner wrapper that refuses to change a borrowed identity (plan 3, task 2)" |
| 765 | ``` |
| 766 | |
| 767 | --- |
| 768 | |
| 769 | ### Task 3: `EnsureDomain` refuses before it claims, and the orphan sweep never deletes a borrowed identity |
| 770 | |
| 771 | **Files:** |
| 772 | - Modify: `internal/mail/walk.go` (`EnsureDomain` at `:149`, before `claimDomain` at `:171`; `Teardown`'s orphan sweep at `:1262-1294`), `internal/mail/walk_domainclaim_test.go` |
| 773 | |
| 774 | **Interfaces:** |
| 775 | - Consumes: `ExternalIdentityFor`, `ErrExternalIdentity`, `claimDomain` (`domainclaim.go:119`), `releaseDomainClaim` (`:158`), `ListDomainClaims` (`:185`). |
| 776 | - Produces: no new exported name. `EnsureDomain` refuses a registered domain or any subdomain of one **before** any durable side effect; `Teardown`'s orphan sweep releases such a claim without calling `DeleteIdentity`. |
| 777 | |
| 778 | - [ ] **Step 1: Write the failing test** |
| 779 | |
| 780 | `internal/mail/walk_domainclaim_test.go` (append): |
| 781 | |
| 782 | ```go |
| 783 | // The refusal is BEFORE the claim (design §6.4). Order matters twice over: |
| 784 | // a claim taken and then refused would leave the domain held by an account |
| 785 | // that can never use it, and the only thing that could clear it is |
| 786 | // Teardown's orphan sweep, which would then have to delete an identity |
| 787 | // belonging to somebody else. |
| 788 | func TestEnsureDomainRefusesARegisteredExternalIdentityBeforeClaiming(t *testing.T) { |
| 789 | ctx := context.Background() |
| 790 | s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| 791 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 792 | registerTitoIO(t, ctx, s, true) |
| 793 | if _, err := Declare(ctx, s, "bag", "harbourmail", "HARBOURMAIL_SMTP", |
| 794 | "jelly@tito.io", "owner@example.test", now); err != nil { |
| 795 | t.Fatal(err) |
| 796 | } |
| 797 | |
| 798 | for _, domain := range []string{"tito.io", "mail.tito.io"} { |
| 799 | _, _, err := EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "bag", "harbourmail", domain, now) |
| 800 | if !errors.Is(err, ErrExternalIdentity) { |
| 801 | t.Errorf("EnsureDomain(%q) = %v, want ErrExternalIdentity", domain, err) |
| 802 | } |
| 803 | if got, err := getDomainClaim(ctx, s, domain); err != nil || got != nil { |
| 804 | t.Errorf("EnsureDomain(%q) took a claim it then refused: %+v %v", domain, got, err) |
| 805 | } |
| 806 | if rec, _, err := GetDomain(ctx, s, "bag", domain); err != nil || rec != nil { |
| 807 | t.Errorf("EnsureDomain(%q) wrote a domain record: %+v %v", domain, rec, err) |
| 808 | } |
| 809 | } |
| 810 | if len(p.Identities) != 0 || len(p.Tenants) != 0 { |
| 811 | t.Fatalf("a refused EnsureDomain still spent a backend call: %+v %+v", p.Identities, p.Tenants) |
| 812 | } |
| 813 | } |
| 814 | |
| 815 | // A claim taken BEFORE the identity was registered is the one shape that can |
| 816 | // still exist. Teardown's orphan sweep must release it and must NOT call |
| 817 | // DeleteIdentity on the way: CARLOS created nothing there, so there is |
| 818 | // nothing of CARLOS's to destroy, and the identity belongs to another |
| 819 | // product. Releasing the claim all the same is deliberate, because a claim |
| 820 | // nothing can ever clear would wedge the domain for every other account. |
| 821 | func TestTeardownNeverDeletesABorrowedIdentityToClearAnOrphanClaim(t *testing.T) { |
| 822 | ctx := context.Background() |
| 823 | s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| 824 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 825 | if _, err := Declare(ctx, s, "bag", "harbourmail", "HARBOURMAIL_SMTP", |
| 826 | "jelly@tito.io", "owner@example.test", now); err != nil { |
| 827 | t.Fatal(err) |
| 828 | } |
| 829 | // The claim predates the registration, which is exactly how a legacy |
| 830 | // orphan comes to exist. |
| 831 | if err := claimDomain(ctx, s, "tito.io", "bag", "harbourmail", now); err != nil { |
| 832 | t.Fatal(err) |
| 833 | } |
| 834 | registerTitoIO(t, ctx, s, true) |
| 835 | // Classic's identity, live at SES before CARLOS ever heard of it. |
| 836 | if _, err := p.EnsureIdentity(ctx, "eu-west-1", "tito.io", "mail.tito.io"); err != nil { |
| 837 | t.Fatal(err) |
| 838 | } |
| 839 | |
| 840 | if err := Teardown(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "bag", "harbourmail"); err != nil { |
| 841 | t.Fatalf("Teardown: %v", err) |
| 842 | } |
| 843 | if _, ok := p.Identities[identityKey("eu-west-1", "tito.io")]; !ok { |
| 844 | t.Fatal("Teardown deleted an identity this deployment does not own") |
| 845 | } |
| 846 | if got, err := getDomainClaim(ctx, s, "tito.io"); err != nil || got != nil { |
| 847 | t.Fatalf("the orphan claim survived Teardown: %+v %v", got, err) |
| 848 | } |
| 849 | } |
| 850 | ``` |
| 851 | |
| 852 | Add `"errors"` to the test file's imports if it is not already there. |
| 853 | |
| 854 | - [ ] **Step 2: Run it to verify it fails** |
| 855 | |
| 856 | ```bash |
| 857 | go test -run 'TestEnsureDomainRefusesARegisteredExternalIdentity|TestTeardownNeverDeletesABorrowedIdentity' ./internal/mail/ |
| 858 | ``` |
| 859 | |
| 860 | Expected: the first fails with `EnsureDomain("tito.io") = <nil>, want ErrExternalIdentity`; the second fails because the identity is gone. |
| 861 | |
| 862 | - [ ] **Step 3: Write the code** |
| 863 | |
| 864 | `internal/mail/walk.go`, `EnsureDomain`, between the platform-name check (`:168-170`) and `claimDomain` (`:171`): |
| 865 | |
| 866 | ```go |
| 867 | // A REGISTERED EXTERNAL IDENTITY IS NOT A SENDING DOMAIN (design §7). |
| 868 | // Refused here, before the claim and before any backend call, for the |
| 869 | // reason the claim itself is the walk's first durable side effect: |
| 870 | // nothing the caller named is trustworthy until the gate says so, and a |
| 871 | // claim taken and then refused by the provisioner guard would be a claim |
| 872 | // only Teardown's orphan sweep could clear, which would in turn have to |
| 873 | // delete an identity belonging to another product. The refusal names the |
| 874 | // registered identity because the caller supplied a name under it and is |
| 875 | // being told what to do instead, not because it discloses anything. |
| 876 | if ext, err := ExternalIdentityFor(ctx, s, domain); err != nil { |
| 877 | return nil, nil, err |
| 878 | } else if ext != nil { |
| 879 | return nil, nil, fmt.Errorf( |
| 880 | "mail: %s sits under %s, an identity this deployment does not own: claim an address under it instead: %w", |
| 881 | domain, ext.Domain, ErrExternalIdentity) |
| 882 | } |
| 883 | ``` |
| 884 | |
| 885 | `internal/mail/walk.go`, `Teardown`'s orphan-claim sweep, inside the loop over `claims` and before the `DeleteIdentity` at `:1288`: |
| 886 | |
| 887 | ```go |
| 888 | // A claim under a registered external identity can only predate the |
| 889 | // registration, because EnsureDomain refuses one now. CARLOS never |
| 890 | // created an identity there, so the DeleteIdentity below has nothing |
| 891 | // of its own to destroy and would instead destroy another product's. |
| 892 | // The claim IS released: it protects CARLOS's identity creation, and |
| 893 | // there is none to protect here, while a claim nothing can ever clear |
| 894 | // would wedge the domain for every account in the deployment. |
| 895 | if ext, err := ExternalIdentityFor(ctx, s, c.Domain); err != nil { |
| 896 | return err |
| 897 | } else if ext != nil { |
| 898 | if err := releaseDomainClaim(ctx, s, c.Domain, acct); err != nil { |
| 899 | return err |
| 900 | } |
| 901 | continue |
| 902 | } |
| 903 | ``` |
| 904 | |
| 905 | - [ ] **Step 4: Run it to verify it passes** |
| 906 | |
| 907 | ```bash |
| 908 | go test ./internal/mail/ -count=1 |
| 909 | ``` |
| 910 | |
| 911 | Expected: PASS, including every existing claim test unchanged. |
| 912 | |
| 913 | - [ ] **Step 5: Gate and commit** |
| 914 | |
| 915 | ```bash |
| 916 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 917 | git add internal/mail |
| 918 | git commit -m "mail: EnsureDomain refuses a borrowed identity before it claims, and teardown never deletes one (plan 3, task 3)" |
| 919 | ``` |
| 920 | |
| 921 | --- |
| 922 | |
| 923 | ### Task 4: The address claim, non-exclusive by design |
| 924 | |
| 925 | **Files:** |
| 926 | - Create: `internal/mail/addressclaim.go`, `internal/mail/addressclaim_test.go` |
| 927 | |
| 928 | **Interfaces:** |
| 929 | - Consumes: `ValidDomain` (`domain.go:78`), `ExternalIdentity.Open`, `claimDomain`'s idempotence rule (`domainclaim.go:119-142`) as the pattern, `release.ErrCASConflict`. |
| 930 | - Produces: |
| 931 | ```go |
| 932 | type AddressClaim struct { |
| 933 | Schema int `json:"schema"` |
| 934 | Address string `json:"address"` |
| 935 | Account string `json:"account"` |
| 936 | App string `json:"app"` |
| 937 | Sender string `json:"sender"` |
| 938 | Grant bool `json:"grant,omitempty"` |
| 939 | By string `json:"by"` |
| 940 | At time.Time `json:"at"` |
| 941 | } |
| 942 | func AddressClaimKey(address, acct string) string |
| 943 | func ValidAddress(addr string) bool |
| 944 | func GetAddressClaim(ctx context.Context, s release.Store, address, acct string) (*AddressClaim, error) |
| 945 | func ClaimAddress(ctx context.Context, s release.Store, address, acct, app, sender, by string, open bool, now time.Time) error |
| 946 | func GrantAddress(ctx context.Context, s release.Store, address, acct, app, sender, by string, now time.Time) error |
| 947 | func ReleaseAddressClaim(ctx context.Context, s release.Store, address, acct, app, sender string) error |
| 948 | func ListAddressClaims(ctx context.Context, s release.Store, address string) ([]*AddressClaim, error) |
| 949 | var ErrAddressClaimed = errors.New("mail: address is claimed by another app in this account") |
| 950 | var ErrAddressNotOpen = errors.New("mail: this identity is not open for self-serve address claims") |
| 951 | ``` |
| 952 | |
| 953 | - [ ] **Step 1: Write the failing test** |
| 954 | |
| 955 | `internal/mail/addressclaim_test.go` (create): |
| 956 | |
| 957 | ```go |
| 958 | package mail |
| 959 | |
| 960 | import ( |
| 961 | "context" |
| 962 | "errors" |
| 963 | "testing" |
| 964 | "time" |
| 965 | |
| 966 | "github.com/carlosframework/platform/internal/appconfig" |
| 967 | "github.com/carlosframework/platform/internal/release" |
| 968 | ) |
| 969 | |
| 970 | // THE CLAIM IS NOT EXCLUSIVE, and that is the requirement, not a compromise |
| 971 | // (design §7): Jelly runs as an app in two accounts and both must send as |
| 972 | // jelly@tito.io. The key is per (address, ACCOUNT), so two accounts holding |
| 973 | // the same address is two objects and no conflict at all. |
| 974 | func TestAddressClaimIsNotExclusiveAcrossAccounts(t *testing.T) { |
| 975 | ctx := context.Background() |
| 976 | s := release.NewDirStore(t.TempDir()) |
| 977 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 978 | |
| 979 | if got := AddressClaimKey("jelly@tito.io", "jam"); got != "email/address-claims/jelly@tito.io/jam.json" { |
| 980 | t.Fatalf("AddressClaimKey = %q", got) |
| 981 | } |
| 982 | for _, acct := range []string{"jam", "stripe"} { |
| 983 | if err := ClaimAddress(ctx, s, "jelly@tito.io", acct, "jelly", SenderAppID, "owner@example.test", true, now); err != nil { |
| 984 | t.Fatalf("ClaimAddress(%s): %v", acct, err) |
| 985 | } |
| 986 | } |
| 987 | roster, err := ListAddressClaims(ctx, s, "jelly@tito.io") |
| 988 | if err != nil || len(roster) != 2 { |
| 989 | t.Fatalf("ListAddressClaims = %+v %v, want two", roster, err) |
| 990 | } |
| 991 | if roster[0].Account != "jam" || roster[1].Account != "stripe" { |
| 992 | t.Fatalf("roster is not sorted by account: %+v", roster) |
| 993 | } |
| 994 | if roster[0].App != "jelly" || roster[0].Sender != SenderAppID || roster[0].By != "owner@example.test" { |
| 995 | t.Fatalf("the claim lost its attribution: %+v", roster[0]) |
| 996 | } |
| 997 | } |
| 998 | |
| 999 | // Within ONE account it is exclusive, with claimDomain's rule: the same |
| 1000 | // (app, sender) is idempotent success, anything else is refused. Without |
| 1001 | // that, a second app in the same account could adopt the first one's From |
| 1002 | // address and then be torn down, releasing a claim the first app is still |
| 1003 | // sending under. |
| 1004 | func TestAddressClaimIsIdempotentForItsOwnSenderAndRefusesAnother(t *testing.T) { |
| 1005 | ctx := context.Background() |
| 1006 | s := release.NewDirStore(t.TempDir()) |
| 1007 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 1008 | |
| 1009 | if err := ClaimAddress(ctx, s, "jelly@tito.io", "jam", "jelly", SenderAppID, "o@example.test", true, now); err != nil { |
| 1010 | t.Fatal(err) |
| 1011 | } |
| 1012 | if err := ClaimAddress(ctx, s, "jelly@tito.io", "jam", "jelly", SenderAppID, "o@example.test", true, now); err != nil { |
| 1013 | t.Fatalf("a re-claim by the holder was refused by its own claim: %v", err) |
| 1014 | } |
| 1015 | if err := ClaimAddress(ctx, s, "jelly@tito.io", "jam", "other", SenderAppID, "o@example.test", true, now); !errors.Is(err, ErrAddressClaimed) { |
| 1016 | t.Fatalf("a sibling app claiming it = %v, want ErrAddressClaimed", err) |
| 1017 | } |
| 1018 | |
| 1019 | // Owner-gated release, the same shape releaseDomainClaim has: a stranger |
| 1020 | // cannot drop a claim it does not hold, and dropping an absent one is a |
| 1021 | // quiet no-op so a retried teardown is not an error. |
| 1022 | if err := ReleaseAddressClaim(ctx, s, "jelly@tito.io", "jam", "other", SenderAppID); err != nil { |
| 1023 | t.Fatalf("a mismatched release should be a no-op: %v", err) |
| 1024 | } |
| 1025 | if got, _ := GetAddressClaim(ctx, s, "jelly@tito.io", "jam"); got == nil { |
| 1026 | t.Fatal("a mismatched release removed the claim") |
| 1027 | } |
| 1028 | if err := ReleaseAddressClaim(ctx, s, "jelly@tito.io", "jam", "jelly", SenderAppID); err != nil { |
| 1029 | t.Fatal(err) |
| 1030 | } |
| 1031 | if got, _ := GetAddressClaim(ctx, s, "jelly@tito.io", "jam"); got != nil { |
| 1032 | t.Fatalf("the claim survived its own release: %+v", got) |
| 1033 | } |
| 1034 | if err := ReleaseAddressClaim(ctx, s, "jelly@tito.io", "jam", "jelly", SenderAppID); err != nil { |
| 1035 | t.Fatal(err) |
| 1036 | } |
| 1037 | } |
| 1038 | |
| 1039 | // Open decides whether an account may help itself. When it is false an |
| 1040 | // operator writes the same record as a GRANT, and the member's own claim then |
| 1041 | // succeeds against it, which is what makes the two paths one record rather |
| 1042 | // than two code paths that could disagree. |
| 1043 | func TestClaimAddressNeedsOpenUnlessAnOperatorGrantedIt(t *testing.T) { |
| 1044 | ctx := context.Background() |
| 1045 | s := release.NewDirStore(t.TempDir()) |
| 1046 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 1047 | |
| 1048 | if err := ClaimAddress(ctx, s, "billing@tito.io", "jam", "jelly", SenderAppID, "o@example.test", false, now); !errors.Is(err, ErrAddressNotOpen) { |
| 1049 | t.Fatalf("a self-serve claim on a closed identity = %v, want ErrAddressNotOpen", err) |
| 1050 | } |
| 1051 | if err := GrantAddress(ctx, s, "billing@tito.io", "jam", "jelly", SenderAppID, "operator@example.test", now); err != nil { |
| 1052 | t.Fatal(err) |
| 1053 | } |
| 1054 | got, err := GetAddressClaim(ctx, s, "billing@tito.io", "jam") |
| 1055 | if err != nil || got == nil || !got.Grant || got.By != "operator@example.test" { |
| 1056 | t.Fatalf("the grant = %+v %v", got, err) |
| 1057 | } |
| 1058 | if err := ClaimAddress(ctx, s, "billing@tito.io", "jam", "jelly", SenderAppID, "o@example.test", false, now); err != nil { |
| 1059 | t.Fatalf("a claim against an operator grant was refused: %v", err) |
| 1060 | } |
| 1061 | // The grant is for ONE app. A sibling is still refused, and refused as a |
| 1062 | // conflict rather than as "not open", because the identity is not the |
| 1063 | // thing standing in its way. |
| 1064 | if err := ClaimAddress(ctx, s, "billing@tito.io", "jam", "other", SenderAppID, "o@example.test", false, now); !errors.Is(err, ErrAddressClaimed) { |
| 1065 | t.Fatalf("a sibling app against someone else's grant = %v, want ErrAddressClaimed", err) |
| 1066 | } |
| 1067 | } |
| 1068 | |
| 1069 | // The address becomes a bucket key segment AND an IAM policy condition value |
| 1070 | // AND the delivered _FROM, so it is validated at the one gate every caller |
| 1071 | // passes through, and the last of those three is asserted rather than |
| 1072 | // assumed. |
| 1073 | func TestValidAddress(t *testing.T) { |
| 1074 | for _, good := range []string{"jelly@tito.io", "no-reply+jam@mail.tito.io", "a.b_c%d@x.co"} { |
| 1075 | if !ValidAddress(good) { |
| 1076 | t.Errorf("ValidAddress(%q) = false", good) |
| 1077 | } |
| 1078 | if err := appconfig.ValidVarValue(good); err != nil { |
| 1079 | t.Errorf("%q is not EnvironmentFile-safe, so it could never be delivered as _FROM: %v", good, err) |
| 1080 | } |
| 1081 | } |
| 1082 | for _, bad := range []string{ |
| 1083 | "", "tito.io", "jelly@", "@tito.io", "jelly@@tito.io", "JELLY@tito.io", |
| 1084 | "jelly@tito", "a/b@tito.io", "..@tito.io", "jelly@tito.io/../x", "jelly tito@tito.io", |
| 1085 | } { |
| 1086 | if ValidAddress(bad) { |
| 1087 | t.Errorf("ValidAddress(%q) = true", bad) |
| 1088 | } |
| 1089 | } |
| 1090 | } |
| 1091 | ``` |
| 1092 | |
| 1093 | - [ ] **Step 2: Run it to verify it fails** |
| 1094 | |
| 1095 | ```bash |
| 1096 | go test -run 'TestAddressClaim|TestClaimAddress|TestValidAddress' ./internal/mail/ |
| 1097 | ``` |
| 1098 | |
| 1099 | Expected: build failure, `undefined: AddressClaimKey`. |
| 1100 | |
| 1101 | - [ ] **Step 3: Write the code** |
| 1102 | |
| 1103 | `internal/mail/addressclaim.go` (create): |
| 1104 | |
| 1105 | ```go |
| 1106 | // The address claim (senders design §7): who may send as one exact address |
| 1107 | // under a registered external identity. |
| 1108 | // |
| 1109 | // IT IS NOT EXCLUSIVE, and that is the requirement rather than a weakening of |
| 1110 | // one. Jelly runs as an app in two accounts, jam and stripe, and both must |
| 1111 | // send as jelly@tito.io. So the key carries the ACCOUNT, and two accounts |
| 1112 | // holding the same address is two objects with nothing to race over. Within |
| 1113 | // one account it IS exclusive, with claimDomain's rule (domainclaim.go:119): |
| 1114 | // the holder re-claiming is idempotent success and any other (app, sender) is |
| 1115 | // refused, because otherwise a sibling app could adopt the address and then be |
| 1116 | // torn down, releasing a claim the first app is still sending under. |
| 1117 | // |
| 1118 | // The claim is a RECORD OF AUTHORITY, not the enforcement. The enforcement is |
| 1119 | // the IAM policy's StringEqualsIgnoreCase on ses:FromAddress |
| 1120 | // (SendAddressPolicyJSON): a credential cannot send as an address its policy |
| 1121 | // does not name, whatever any object in the bucket says. |
| 1122 | package mail |
| 1123 | |
| 1124 | import ( |
| 1125 | "context" |
| 1126 | "encoding/json" |
| 1127 | "errors" |
| 1128 | "fmt" |
| 1129 | "log" |
| 1130 | "os" |
| 1131 | "regexp" |
| 1132 | "sort" |
| 1133 | "strings" |
| 1134 | "time" |
| 1135 | |
| 1136 | "github.com/carlosframework/platform/internal/release" |
| 1137 | ) |
| 1138 | |
| 1139 | const addressClaimsPrefix = "email/address-claims/" |
| 1140 | |
| 1141 | // AddressClaimKey is one account's claim on one address. Global, like the |
| 1142 | // domain claim and the external identity itself: the address belongs to an |
| 1143 | // identity the whole deployment shares, so the roster an operator reads has |
| 1144 | // to be one listing rather than one per account. |
| 1145 | func AddressClaimKey(address, acct string) string { |
| 1146 | return addressClaimsPrefix + address + "/" + acct + ".json" |
| 1147 | } |
| 1148 | |
| 1149 | // AddressClaim is the wire shape at AddressClaimKey(Address, Account). |
| 1150 | // |
| 1151 | // Grant records that an OPERATOR wrote this claim on an identity that is not |
| 1152 | // open for self-serve. It is kept because the two paths write the same record |
| 1153 | // and an operator reading the roster needs to see which claims they made |
| 1154 | // themselves. |
| 1155 | type AddressClaim struct { |
| 1156 | Schema int `json:"schema"` // 1 |
| 1157 | Address string `json:"address"` |
| 1158 | Account string `json:"account"` |
| 1159 | App string `json:"app"` |
| 1160 | Sender string `json:"sender"` |
| 1161 | Grant bool `json:"grant,omitempty"` |
| 1162 | By string `json:"by"` |
| 1163 | At time.Time `json:"at"` |
| 1164 | } |
| 1165 | |
| 1166 | var ( |
| 1167 | // ErrAddressClaimed is a claim from a different app or sender in the SAME |
| 1168 | // account. It never names the holder for a different account, because |
| 1169 | // there is no such refusal: a different account is a different key. |
| 1170 | ErrAddressClaimed = errors.New("mail: address is claimed by another app in this account") |
| 1171 | // ErrAddressNotOpen is a self-serve claim on an identity whose |
| 1172 | // registration is closed. The recourse is an operator grant, and the |
| 1173 | // console door says so. |
| 1174 | ErrAddressNotOpen = errors.New("mail: this identity is not open for self-serve address claims") |
| 1175 | ) |
| 1176 | |
| 1177 | // addressLocalRe is the local part, deliberately stricter than RFC 5321 |
| 1178 | // allows: this string becomes a bucket key segment, an IAM policy condition |
| 1179 | // value and the delivered _FROM variable, so the one safe place to refuse a |
| 1180 | // separator, a traversal or a space is the gate every caller passes through. |
| 1181 | // Lowercase only, because the claim key has to be a function of the address |
| 1182 | // and SES's own StringEquals is case-sensitive (which is why the policy uses |
| 1183 | // StringEqualsIgnoreCase instead). |
| 1184 | var addressLocalRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9._%+-]{0,62}[a-z0-9])?$`) |
| 1185 | |
| 1186 | // ValidAddress reports whether addr is usable as a sender address. |
| 1187 | func ValidAddress(addr string) bool { |
| 1188 | if len(addr) > 254 { |
| 1189 | return false |
| 1190 | } |
| 1191 | local, domain, found := strings.Cut(addr, "@") |
| 1192 | if !found || strings.Contains(domain, "@") { |
| 1193 | return false |
| 1194 | } |
| 1195 | return addressLocalRe.MatchString(local) && ValidDomain(domain) |
| 1196 | } |
| 1197 | |
| 1198 | // AddressDomain is the domain half of a valid address. It is a function |
| 1199 | // rather than an inline Cut because the policy renderer, the enable branch |
| 1200 | // and the claim door all have to agree about where the address ends. |
| 1201 | func AddressDomain(addr string) string { |
| 1202 | _, domain, _ := strings.Cut(addr, "@") |
| 1203 | return domain |
| 1204 | } |
| 1205 | |
| 1206 | // GetAddressClaim reads one account's claim. Absent is (nil, nil). |
| 1207 | func GetAddressClaim(ctx context.Context, s release.Store, address, acct string) (*AddressClaim, error) { |
| 1208 | if !ValidAddress(address) { |
| 1209 | return nil, fmt.Errorf("mail: invalid address %q", address) |
| 1210 | } |
| 1211 | if !release.ValidAccount(acct) { |
| 1212 | return nil, fmt.Errorf("mail: invalid account %q", acct) |
| 1213 | } |
| 1214 | raw, _, err := s.Get(ctx, AddressClaimKey(address, acct)) |
| 1215 | if err != nil { |
| 1216 | if errors.Is(err, os.ErrNotExist) { |
| 1217 | return nil, nil |
| 1218 | } |
| 1219 | return nil, err |
| 1220 | } |
| 1221 | var c AddressClaim |
| 1222 | if err := json.Unmarshal(raw, &c); err != nil { |
| 1223 | return nil, fmt.Errorf("mail: address claim %s/%s: %w", address, acct, err) |
| 1224 | } |
| 1225 | if c.Schema > Schema { |
| 1226 | return nil, fmt.Errorf("mail: address claim %s/%s: schema %d is newer than this binary understands, upgrade carlos", address, acct, c.Schema) |
| 1227 | } |
| 1228 | return &c, nil |
| 1229 | } |
| 1230 | |
| 1231 | // ClaimAddress is the MEMBER's claim. open is the registration's own Open |
| 1232 | // flag; when it is false the only way through is an operator grant already |
| 1233 | // recorded for this account, which is what "otherwise an operator writes the |
| 1234 | // same record as a grant" means in one sentence of code. |
| 1235 | func ClaimAddress(ctx context.Context, s release.Store, address, acct, app, sender, by string, open bool, now time.Time) error { |
| 1236 | cur, err := getOwnAddressClaim(ctx, s, address, acct, app, sender) |
| 1237 | if err != nil { |
| 1238 | return err |
| 1239 | } |
| 1240 | if cur != nil { |
| 1241 | return nil // already ours: idempotent, and a grant is honoured here |
| 1242 | } |
| 1243 | if !open { |
| 1244 | return fmt.Errorf("mail: %s: %w", address, ErrAddressNotOpen) |
| 1245 | } |
| 1246 | return putAddressClaim(ctx, s, &AddressClaim{ |
| 1247 | Address: address, Account: acct, App: app, Sender: sender, By: by, At: now, |
| 1248 | }) |
| 1249 | } |
| 1250 | |
| 1251 | // GrantAddress is the OPERATOR's, and it writes the same record whatever the |
| 1252 | // registration says. It is how a closed identity is opened to exactly one |
| 1253 | // app rather than to the whole deployment. |
| 1254 | func GrantAddress(ctx context.Context, s release.Store, address, acct, app, sender, by string, now time.Time) error { |
| 1255 | cur, err := getOwnAddressClaim(ctx, s, address, acct, app, sender) |
| 1256 | if err != nil { |
| 1257 | return err |
| 1258 | } |
| 1259 | if cur != nil { |
| 1260 | return nil |
| 1261 | } |
| 1262 | return putAddressClaim(ctx, s, &AddressClaim{ |
| 1263 | Address: address, Account: acct, App: app, Sender: sender, Grant: true, By: by, At: now, |
| 1264 | }) |
| 1265 | } |
| 1266 | |
| 1267 | // getOwnAddressClaim answers the shared question both writers ask: is there |
| 1268 | // already a claim here, and is it ours. A claim held by another app or sender |
| 1269 | // in this account is ErrAddressClaimed; ours is returned for the caller to |
| 1270 | // treat as idempotent success. |
| 1271 | func getOwnAddressClaim(ctx context.Context, s release.Store, address, acct, app, sender string) (*AddressClaim, error) { |
| 1272 | if !ValidAddress(address) { |
| 1273 | return nil, fmt.Errorf("mail: invalid address %q", address) |
| 1274 | } |
| 1275 | if !release.ValidApp(app) || !ValidSenderID(sender) { |
| 1276 | return nil, fmt.Errorf("mail: invalid app %q or sender %q", app, sender) |
| 1277 | } |
| 1278 | cur, err := GetAddressClaim(ctx, s, address, acct) |
| 1279 | if err != nil || cur == nil { |
| 1280 | return nil, err |
| 1281 | } |
| 1282 | if cur.App != app || cur.Sender != sender { |
| 1283 | return nil, ErrAddressClaimed |
| 1284 | } |
| 1285 | return cur, nil |
| 1286 | } |
| 1287 | |
| 1288 | // putAddressClaim writes create-only and re-reads on a lost race, so two |
| 1289 | // requests arriving together end with one winner and one idempotent success |
| 1290 | // or one refusal, never with a clobbered record. |
| 1291 | func putAddressClaim(ctx context.Context, s release.Store, c *AddressClaim) error { |
| 1292 | c.Schema = Schema |
| 1293 | body, err := json.MarshalIndent(c, "", " ") |
| 1294 | if err != nil { |
| 1295 | return err |
| 1296 | } |
| 1297 | if _, err := s.Put(ctx, AddressClaimKey(c.Address, c.Account), append(body, '\n'), ""); err != nil { |
| 1298 | if !errors.Is(err, release.ErrCASConflict) { |
| 1299 | return err |
| 1300 | } |
| 1301 | if _, err := getOwnAddressClaim(ctx, s, c.Address, c.Account, c.App, c.Sender); err != nil { |
| 1302 | return err |
| 1303 | } |
| 1304 | } |
| 1305 | return nil |
| 1306 | } |
| 1307 | |
| 1308 | // ReleaseAddressClaim removes a claim, but only when this sender holds it, |
| 1309 | // the owner gate releaseDomainClaim applies (domainclaim.go:158). Absent is a |
| 1310 | // quiet no-op, so a retried teardown is not an error. |
| 1311 | func ReleaseAddressClaim(ctx context.Context, s release.Store, address, acct, app, sender string) error { |
| 1312 | cur, err := GetAddressClaim(ctx, s, address, acct) |
| 1313 | if err != nil || cur == nil { |
| 1314 | return err |
| 1315 | } |
| 1316 | if cur.App != app || cur.Sender != sender { |
| 1317 | return nil |
| 1318 | } |
| 1319 | return s.Delete(ctx, AddressClaimKey(address, acct)) |
| 1320 | } |
| 1321 | |
| 1322 | // ListAddressClaims is the operator's roster for one address: every account |
| 1323 | // that may send as it, sorted by account. An unparseable object is logged and |
| 1324 | // skipped, the discipline ListDomainClaims applies. |
| 1325 | func ListAddressClaims(ctx context.Context, s release.Store, address string) ([]*AddressClaim, error) { |
| 1326 | if !ValidAddress(address) { |
| 1327 | return nil, fmt.Errorf("mail: invalid address %q", address) |
| 1328 | } |
| 1329 | keys, err := s.List(ctx, addressClaimsPrefix+address+"/") |
| 1330 | if err != nil { |
| 1331 | return nil, fmt.Errorf("mail: list address claims for %s: %w", address, err) |
| 1332 | } |
| 1333 | out := make([]*AddressClaim, 0, len(keys)) |
| 1334 | for _, key := range keys { |
| 1335 | raw, _, err := s.Get(ctx, key) |
| 1336 | if err != nil { |
| 1337 | log.Printf("mail: address claim %s -> %v", key, err) |
| 1338 | continue |
| 1339 | } |
| 1340 | var c AddressClaim |
| 1341 | if err := json.Unmarshal(raw, &c); err != nil { |
| 1342 | log.Printf("mail: address claim %s: unparseable -> %v", key, err) |
| 1343 | continue |
| 1344 | } |
| 1345 | if c.Schema > Schema || AddressClaimKey(c.Address, c.Account) != key { |
| 1346 | continue |
| 1347 | } |
| 1348 | out = append(out, &c) |
| 1349 | } |
| 1350 | sort.Slice(out, func(i, j int) bool { return out[i].Account < out[j].Account }) |
| 1351 | return out, nil |
| 1352 | } |
| 1353 | ``` |
| 1354 | |
| 1355 | - [ ] **Step 4: Run it to verify it passes** |
| 1356 | |
| 1357 | ```bash |
| 1358 | go test -run 'TestAddressClaim|TestClaimAddress|TestValidAddress' ./internal/mail/ |
| 1359 | ``` |
| 1360 | |
| 1361 | Expected: PASS. |
| 1362 | |
| 1363 | - [ ] **Step 5: Gate and commit** |
| 1364 | |
| 1365 | ```bash |
| 1366 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 1367 | git add internal/mail |
| 1368 | git commit -m "mail: the non-exclusive address claim, with Open and the operator grant (plan 3, task 4)" |
| 1369 | ``` |
| 1370 | |
| 1371 | --- |
| 1372 | |
| 1373 | ### Task 5: The address form of the send policy |
| 1374 | |
| 1375 | **Files:** |
| 1376 | - Modify: `internal/mail/policy.go` (`SendPolicyJSON` at `:125`, its identity-ARN validation at `:142-179`), `internal/mail/policy_test.go` |
| 1377 | |
| 1378 | **Interfaces:** |
| 1379 | - Consumes: `ValidAddress`, `AddressDomain`, `TenantName` (`policy.go:84`). |
| 1380 | - Produces: |
| 1381 | ```go |
| 1382 | func SendAddressPolicyJSON(identityARN, configSetARN, address, tenant string) (string, error) |
| 1383 | func identityNameFromARN(arn string) (string, bool) // unexported, shared with SendPolicyJSON |
| 1384 | ``` |
| 1385 | |
| 1386 | - [ ] **Step 1: Write the failing test** |
| 1387 | |
| 1388 | `internal/mail/policy_test.go` (append): |
| 1389 | |
| 1390 | ```go |
| 1391 | // The address form (design §7, §4.1). Three things about it are load-bearing |
| 1392 | // and all three come from the spike: |
| 1393 | // |
| 1394 | // - StringEqualsIgnoreCase on ses:FromAddress, because SES's StringEquals |
| 1395 | // is case-sensitive and a From address is not. |
| 1396 | // - StringEquals on ses:TenantName, because the spike proved the key IS |
| 1397 | // enforced over SMTP: a send that omits the tenant header is refused with |
| 1398 | // a 554 rather than silently sent uncounted. That refusal is what makes |
| 1399 | // the honour system around <P>_CONFIG_SET safe. |
| 1400 | // - The configuration set as a SECOND resource, for issue #271's reason. |
| 1401 | func TestSendAddressPolicyNamesTheAddressTheSetAndTheTenant(t *testing.T) { |
| 1402 | doc, err := SendAddressPolicyJSON( |
| 1403 | "arn:aws:ses:eu-west-1:1:identity/tito.io", |
| 1404 | "arn:aws:ses:eu-west-1:1:configuration-set/carlos-jam", |
| 1405 | "jelly@tito.io", "carlos-jam") |
| 1406 | if err != nil { |
| 1407 | t.Fatal(err) |
| 1408 | } |
| 1409 | var got struct { |
| 1410 | Statement []struct { |
| 1411 | Action []string |
| 1412 | Resource []string |
| 1413 | Condition map[string]map[string][]string |
| 1414 | } |
| 1415 | } |
| 1416 | if err := json.Unmarshal([]byte(doc), &got); err != nil { |
| 1417 | t.Fatalf("unparseable policy %s: %v", doc, err) |
| 1418 | } |
| 1419 | if len(got.Statement) != 1 || len(got.Statement[0].Action) != 1 || got.Statement[0].Action[0] != "ses:SendRawEmail" { |
| 1420 | t.Fatalf("statement = %+v, want one ses:SendRawEmail", got.Statement) |
| 1421 | } |
| 1422 | st := got.Statement[0] |
| 1423 | want := []string{ |
| 1424 | "arn:aws:ses:eu-west-1:1:identity/tito.io", |
| 1425 | "arn:aws:ses:eu-west-1:1:configuration-set/carlos-jam", |
| 1426 | } |
| 1427 | if !reflect.DeepEqual(st.Resource, want) { |
| 1428 | t.Fatalf("Resource = %v, want %v", st.Resource, want) |
| 1429 | } |
| 1430 | if got := st.Condition["StringEqualsIgnoreCase"]["ses:FromAddress"]; !reflect.DeepEqual(got, []string{"jelly@tito.io"}) { |
| 1431 | t.Fatalf("FromAddress condition = %v", got) |
| 1432 | } |
| 1433 | if got := st.Condition["StringEquals"]["ses:TenantName"]; !reflect.DeepEqual(got, []string{"carlos-jam"}) { |
| 1434 | t.Fatalf("TenantName condition = %v", got) |
| 1435 | } |
| 1436 | // A wildcard From would be the whole identity, which is exactly what an |
| 1437 | // address sender must NOT have. |
| 1438 | if strings.Contains(doc, "*@") || strings.Contains(doc, "StringLike") { |
| 1439 | t.Fatalf("the address form widened to a domain pattern: %s", doc) |
| 1440 | } |
| 1441 | } |
| 1442 | |
| 1443 | func TestSendAddressPolicyRefusesWhatItCannotScope(t *testing.T) { |
| 1444 | const set = "arn:aws:ses:eu-west-1:1:configuration-set/carlos-jam" |
| 1445 | for _, c := range []struct { |
| 1446 | what string |
| 1447 | identity, configSet, addr, tenant string |
| 1448 | }{ |
| 1449 | {"an identity that is not the address's domain or a parent", "arn:aws:ses:eu-west-1:1:identity/other.io", set, "jelly@tito.io", "carlos-jam"}, |
| 1450 | {"a child of the address's domain", "arn:aws:ses:eu-west-1:1:identity/mail.tito.io", set, "jelly@tito.io", "carlos-jam"}, |
| 1451 | {"a wildcard identity", "arn:aws:ses:eu-west-1:1:identity/*", set, "jelly@tito.io", "carlos-jam"}, |
| 1452 | {"a malformed identity ARN", "tito.io", set, "jelly@tito.io", "carlos-jam"}, |
| 1453 | {"a wildcard configuration set", "arn:aws:ses:eu-west-1:1:identity/tito.io", "arn:aws:ses:eu-west-1:1:configuration-set/*", "jelly@tito.io", "carlos-jam"}, |
| 1454 | {"an invalid address", "arn:aws:ses:eu-west-1:1:identity/tito.io", set, "jelly@", "carlos-jam"}, |
| 1455 | {"no tenant, which is the enforcement", "arn:aws:ses:eu-west-1:1:identity/tito.io", set, "jelly@tito.io", ""}, |
| 1456 | } { |
| 1457 | if _, err := SendAddressPolicyJSON(c.identity, c.configSet, c.addr, c.tenant); err == nil { |
| 1458 | t.Errorf("SendAddressPolicyJSON accepted %s", c.what) |
| 1459 | } |
| 1460 | } |
| 1461 | // An EMPTY configuration set is the ordinary shape on a deployment with |
| 1462 | // no SNS topic, exactly as it is for the domain form, and must render. |
| 1463 | if _, err := SendAddressPolicyJSON("arn:aws:ses:eu-west-1:1:identity/tito.io", "", "jelly@tito.io", "carlos-jam"); err != nil { |
| 1464 | t.Errorf("an SNS-less deployment cannot render an address policy: %v", err) |
| 1465 | } |
| 1466 | // A subdomain address under the registered parent is the case |
| 1467 | // mail.tito.io exists for, and it renders. |
| 1468 | if _, err := SendAddressPolicyJSON("arn:aws:ses:eu-west-1:1:identity/tito.io", "", "jelly@mail.tito.io", "carlos-jam"); err != nil { |
| 1469 | t.Errorf("an address under a subdomain of the identity was refused: %v", err) |
| 1470 | } |
| 1471 | } |
| 1472 | |
| 1473 | // THE DOMAIN FORM DOES NOT MOVE. Nothing delivers <P>_TENANT to a domain |
| 1474 | // sender, so a tenant condition on that document would be a 554 on every send |
| 1475 | // for every app in the fleet. The two conditions are named here so a later |
| 1476 | // edit that "unifies" the renderers has to fail this test first. |
| 1477 | func TestSendPolicyKeepsItsDomainFormWithNoTenantCondition(t *testing.T) { |
| 1478 | doc, err := SendPolicyJSON( |
| 1479 | []string{"arn:aws:ses:eu-west-1:1:identity/x.com"}, |
| 1480 | []string{"arn:aws:ses:eu-west-1:1:configuration-set/carlos-bag"}, |
| 1481 | []string{"x.com"}) |
| 1482 | if err != nil { |
| 1483 | t.Fatal(err) |
| 1484 | } |
| 1485 | var got struct { |
| 1486 | Statement []struct { |
| 1487 | Condition map[string]map[string][]string |
| 1488 | } |
| 1489 | } |
| 1490 | if err := json.Unmarshal([]byte(doc), &got); err != nil { |
| 1491 | t.Fatal(err) |
| 1492 | } |
| 1493 | cond := got.Statement[0].Condition |
| 1494 | if !reflect.DeepEqual(cond["StringLike"]["ses:FromAddress"], []string{"*@x.com"}) { |
| 1495 | t.Fatalf("the domain form's From condition moved: %v", cond) |
| 1496 | } |
| 1497 | if _, ok := cond["StringEquals"]; ok { |
| 1498 | t.Fatalf("the domain form grew a tenant condition, which would 554 every send in the fleet: %v", cond) |
| 1499 | } |
| 1500 | } |
| 1501 | ``` |
| 1502 | |
| 1503 | Add `"encoding/json"`, `"reflect"` and `"strings"` to the test file's imports as needed. |
| 1504 | |
| 1505 | - [ ] **Step 2: Run it to verify it fails** |
| 1506 | |
| 1507 | ```bash |
| 1508 | go test -run 'TestSendAddressPolicy|TestSendPolicyKeepsItsDomainForm' ./internal/mail/ |
| 1509 | ``` |
| 1510 | |
| 1511 | Expected: build failure, `undefined: SendAddressPolicyJSON`. |
| 1512 | |
| 1513 | - [ ] **Step 3: Write the code** |
| 1514 | |
| 1515 | `internal/mail/policy.go`, extract the identity-ARN parse `SendPolicyJSON` does inline (`:142-165`) so both renderers ask the same question of the same string, then add the address form: |
| 1516 | |
| 1517 | ```go |
| 1518 | // identityNameFromARN validates an SES identity ARN and returns the identity |
| 1519 | // it names. Wildcards are refused BEFORE parsing, anywhere in the string: IAM |
| 1520 | // expands * and ? as match patterns in every segment, not only in the |
| 1521 | // identity name, so a wildcard in the region would widen the grant just as |
| 1522 | // effectively. |
| 1523 | func identityNameFromARN(arn string) (string, bool) { |
| 1524 | if strings.Contains(arn, "*") || strings.Contains(arn, "?") { |
| 1525 | return "", false |
| 1526 | } |
| 1527 | parts := strings.Split(arn, ":") |
| 1528 | if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "ses" { |
| 1529 | return "", false |
| 1530 | } |
| 1531 | if parts[3] == "" || parts[4] == "" { |
| 1532 | return "", false |
| 1533 | } |
| 1534 | resource := strings.SplitN(parts[5], "/", 2) |
| 1535 | if len(resource) != 2 || resource[0] != "identity" { |
| 1536 | return "", false |
| 1537 | } |
| 1538 | // No `resource[1] == ""` clause, deliberately. The extraction has to |
| 1539 | // answer the same question SendPolicyJSON asks today (policy.go:161-164), |
| 1540 | // which accepts an empty identity name here and refuses it four lines |
| 1541 | // later with "does not match any granted domain" — and this task |
| 1542 | // promises the domain form's messages byte for byte. Nothing is let |
| 1543 | // through either way: an empty name matches no granted domain in the |
| 1544 | // domain form, and in the address form it is neither the address's |
| 1545 | // domain nor a parent of it, since HasSuffix(domain, ".") is false for |
| 1546 | // every valid domain. |
| 1547 | return resource[1], true |
| 1548 | } |
| 1549 | |
| 1550 | // SendAddressPolicyJSON renders the policy for ONE address under an identity |
| 1551 | // CARLOS does not own (design §7). |
| 1552 | // |
| 1553 | // It is a second renderer rather than a mode of SendPolicyJSON, because the |
| 1554 | // two documents differ in every part that matters and a shared one would have |
| 1555 | // to be read twice to answer either question. The differences, each of which |
| 1556 | // the spike (§4.1) settled: |
| 1557 | // |
| 1558 | // - StringEqualsIgnoreCase, not StringLike. The grant is one exact address, |
| 1559 | // and SES's StringEquals is case-sensitive while a From address is not. |
| 1560 | // - A ses:TenantName condition, which the domain form does not carry. The |
| 1561 | // spike proved SES DOES populate that key for SMTP sends: tagged sends |
| 1562 | // were accepted and untagged ones refused with a 554. That makes one |
| 1563 | // customer's credential useless for another customer's mail, and it makes |
| 1564 | // a message that omits the tenant header fail loudly rather than send |
| 1565 | // silently uncounted. |
| 1566 | // - The identity may be the address's own domain or ANY parent of it, which |
| 1567 | // is how jelly@mail.tito.io sends under a registration on tito.io. |
| 1568 | // |
| 1569 | // The configuration set is the same second resource it is in the domain form |
| 1570 | // (#271), and an empty one is the ordinary shape on a deployment with no SNS |
| 1571 | // topic rather than an error. |
| 1572 | func SendAddressPolicyJSON(identityARN, configSetARN, address, tenant string) (string, error) { |
| 1573 | if !ValidAddress(address) { |
| 1574 | return "", fmt.Errorf("mail: invalid address %q in send policy", address) |
| 1575 | } |
| 1576 | if tenant == "" { |
| 1577 | return "", fmt.Errorf("mail: refusing to render an address send policy with no tenant: the tenant condition is the isolation") |
| 1578 | } |
| 1579 | name, ok := identityNameFromARN(identityARN) |
| 1580 | if !ok { |
| 1581 | return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", identityARN) |
| 1582 | } |
| 1583 | // The identity must COVER the address: equal to its domain, or a parent |
| 1584 | // of it. A child would be an identity that cannot sign for this address |
| 1585 | // at all, and an unrelated one would be a grant on somebody else's mail. |
| 1586 | domain := AddressDomain(address) |
| 1587 | if name != domain && !strings.HasSuffix(domain, "."+name) { |
| 1588 | return "", fmt.Errorf("mail: identity %q in ARN %q does not cover the address %q", name, identityARN, address) |
| 1589 | } |
| 1590 | resources := []string{identityARN} |
| 1591 | if configSetARN != "" { |
| 1592 | if _, ok := configSetNameFromARN(configSetARN); !ok { |
| 1593 | return "", fmt.Errorf("mail: invalid configuration-set ARN %q in send policy", configSetARN) |
| 1594 | } |
| 1595 | resources = append(resources, configSetARN) |
| 1596 | } |
| 1597 | doc := map[string]any{ |
| 1598 | "Version": "2012-10-17", |
| 1599 | "Statement": []map[string]any{{ |
| 1600 | "Effect": "Allow", |
| 1601 | "Action": []string{"ses:SendRawEmail"}, |
| 1602 | "Resource": resources, |
| 1603 | "Condition": map[string]any{ |
| 1604 | "StringEqualsIgnoreCase": map[string]any{"ses:FromAddress": []string{address}}, |
| 1605 | "StringEquals": map[string]any{"ses:TenantName": []string{tenant}}, |
| 1606 | }, |
| 1607 | }}, |
| 1608 | } |
| 1609 | b, err := json.Marshal(doc) |
| 1610 | if err != nil { |
| 1611 | return "", err |
| 1612 | } |
| 1613 | return string(b), nil |
| 1614 | } |
| 1615 | ``` |
| 1616 | |
| 1617 | Extract the configuration-set ARN check `SendPolicyJSON` performs inline (`:185-200`) the same way, as `configSetNameFromARN(arn string) (string, bool)`, and have both renderers call it. `SendPolicyJSON`'s own loops call the two helpers and keep their existing error messages verbatim, so the domain form renders the same bytes it renders today. |
| 1618 | |
| 1619 | - [ ] **Step 4: Run it to verify it passes** |
| 1620 | |
| 1621 | ```bash |
| 1622 | go test ./internal/mail/ -count=1 |
| 1623 | ``` |
| 1624 | |
| 1625 | Expected: PASS, including every existing policy test unchanged. |
| 1626 | |
| 1627 | - [ ] **Step 5: Gate and commit** |
| 1628 | |
| 1629 | ```bash |
| 1630 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 1631 | git add internal/mail |
| 1632 | git commit -m "mail: the address send policy, scoped by address and tenant (plan 3, task 5)" |
| 1633 | ``` |
| 1634 | |
| 1635 | --- |
| 1636 | |
| 1637 | ### Task 6: `EnsureAddressUser` on the provisioner seam |
| 1638 | |
| 1639 | **Files:** |
| 1640 | - Modify: `internal/mail/provisioner.go` (the interface at `:49`, after `EnsureUser` at `:77`), `internal/mail/aws.go` (`EnsureUser` at `:348`), `internal/mail/memprovisioner.go` (`EnsureUser` at `:201`), `internal/mail/memprovisioner_test.go` |
| 1641 | |
| 1642 | **Interfaces:** |
| 1643 | - Consumes: `SendAddressPolicyJSON`, `PolicyName` (`policy.go:20`), `UserPath` (`:24`). |
| 1644 | - Produces: |
| 1645 | ```go |
| 1646 | // Provisioner |
| 1647 | EnsureAddressUser(ctx context.Context, name, path, identityARN, configSetARN, address, tenant string) (arn string, err error) |
| 1648 | ``` |
| 1649 | The guard (task 2) forwards it by embedding, so no change there. |
| 1650 | |
| 1651 | - [ ] **Step 1: Write the failing test** |
| 1652 | |
| 1653 | `internal/mail/memprovisioner_test.go` (append): |
| 1654 | |
| 1655 | ```go |
| 1656 | // An address sender's principal carries the ADDRESS form of the policy, and |
| 1657 | // the fake writes it the same way the real backend does, so a walk test can |
| 1658 | // assert what a credential may actually send as. |
| 1659 | func TestMemEnsureAddressUserWritesTheAddressPolicy(t *testing.T) { |
| 1660 | ctx, p := context.Background(), NewMemProvisioner("1") |
| 1661 | arn, err := p.EnsureAddressUser(ctx, "carlos-mail-jam-jelly", UserPath, |
| 1662 | "arn:aws:ses:eu-west-1:1:identity/tito.io", |
| 1663 | "arn:aws:ses:eu-west-1:1:configuration-set/carlos-jam", |
| 1664 | "jelly@tito.io", "carlos-jam") |
| 1665 | if err != nil { |
| 1666 | t.Fatal(err) |
| 1667 | } |
| 1668 | if arn == "" { |
| 1669 | t.Fatal("EnsureAddressUser returned no ARN") |
| 1670 | } |
| 1671 | doc := p.Users["carlos-mail-jam-jelly"].Policy |
| 1672 | for _, want := range []string{"jelly@tito.io", "carlos-jam", "StringEqualsIgnoreCase", "ses:TenantName"} { |
| 1673 | if !strings.Contains(doc, want) { |
| 1674 | t.Errorf("policy is missing %q: %s", want, doc) |
| 1675 | } |
| 1676 | } |
| 1677 | if strings.Contains(doc, "*@") { |
| 1678 | t.Errorf("an address principal was granted a whole domain: %s", doc) |
| 1679 | } |
| 1680 | // The refusal is modelled because the real backend refuses too: a |
| 1681 | // principal with an unrenderable policy must not exist at all. |
| 1682 | if _, err := p.EnsureAddressUser(ctx, "carlos-mail-jam-broken", UserPath, |
| 1683 | "arn:aws:ses:eu-west-1:1:identity/other.io", "", "jelly@tito.io", "carlos-jam"); err == nil { |
| 1684 | t.Error("EnsureAddressUser created a principal for an identity that does not cover the address") |
| 1685 | } |
| 1686 | if _, ok := p.Users["carlos-mail-jam-broken"]; ok { |
| 1687 | t.Error("a refused EnsureAddressUser left a principal with no policy behind") |
| 1688 | } |
| 1689 | } |
| 1690 | ``` |
| 1691 | |
| 1692 | Add `"strings"` to the test file's imports. |
| 1693 | |
| 1694 | - [ ] **Step 2: Run it to verify it fails** |
| 1695 | |
| 1696 | ```bash |
| 1697 | go test -run TestMemEnsureAddressUser ./internal/mail/ |
| 1698 | ``` |
| 1699 | |
| 1700 | Expected: build failure, `p.EnsureAddressUser undefined`. |
| 1701 | |
| 1702 | - [ ] **Step 3: Write the code** |
| 1703 | |
| 1704 | `internal/mail/provisioner.go`, in the interface after `EnsureUser` (`:77`): |
| 1705 | |
| 1706 | ```go |
| 1707 | // EnsureAddressUser is EnsureUser for a sender that owns ONE address |
| 1708 | // under an identity CARLOS does not own (design §7). It exists as its own |
| 1709 | // verb rather than as a flag on EnsureUser because the document is a |
| 1710 | // different document: one exact address matched case-insensitively, plus |
| 1711 | // the tenant condition the spike proved SES enforces over SMTP (§4.1). |
| 1712 | // Folding the two into one signature would mean a caller could pass a |
| 1713 | // domain list and an address and get whichever the implementation |
| 1714 | // preferred. Idempotent, like every verb here: the policy is rewritten |
| 1715 | // wholesale on every call. |
| 1716 | EnsureAddressUser(ctx context.Context, name, path, identityARN, configSetARN, address, tenant string) (arn string, err error) |
| 1717 | ``` |
| 1718 | |
| 1719 | `internal/mail/aws.go`: `EnsureUser` (`:348`) keeps its body but hands the rendered document to a shared tail, and the new verb renders the other document into the same tail: |
| 1720 | |
| 1721 | ```go |
| 1722 | func (p *awsProvisioner) EnsureUser(ctx context.Context, name, path string, identityARNs, configSetARNs, domains []string) (string, error) { |
| 1723 | // Render the policy FIRST. SendPolicyJSON refuses an empty grant, and |
| 1724 | // refusing before CreateUser is what stops a principal existing with no |
| 1725 | // policy at all — an unscoped sending user, briefly, is still one. |
| 1726 | doc, err := SendPolicyJSON(identityARNs, configSetARNs, domains) |
| 1727 | if err != nil { |
| 1728 | return "", err |
| 1729 | } |
| 1730 | return p.ensurePrincipal(ctx, name, path, doc) |
| 1731 | } |
| 1732 | |
| 1733 | // EnsureAddressUser is EnsureUser with the address document. The render-first |
| 1734 | // discipline is the same and for the same reason. |
| 1735 | func (p *awsProvisioner) EnsureAddressUser(ctx context.Context, name, path, identityARN, configSetARN, address, tenant string) (string, error) { |
| 1736 | doc, err := SendAddressPolicyJSON(identityARN, configSetARN, address, tenant) |
| 1737 | if err != nil { |
| 1738 | return "", err |
| 1739 | } |
| 1740 | return p.ensurePrincipal(ctx, name, path, doc) |
| 1741 | } |
| 1742 | |
| 1743 | // ensurePrincipal is the IAM half both share: create if absent, then write |
| 1744 | // the inline policy wholesale. |
| 1745 | func (p *awsProvisioner) ensurePrincipal(ctx context.Context, name, path, doc string) (string, error) { |
| 1746 | var arn string |
| 1747 | out, err := p.iam.CreateUser(ctx, &iam.CreateUserInput{ |
| 1748 | UserName: aws.String(name), Path: aws.String(path), |
| 1749 | }) |
| 1750 | if err != nil { |
| 1751 | if !isAlreadyExists(err) { |
| 1752 | return "", fmt.Errorf("mail: create user %q: %w", name, err) |
| 1753 | } |
| 1754 | got, gerr := p.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String(name)}) |
| 1755 | if gerr != nil { |
| 1756 | return "", fmt.Errorf("mail: get existing user %q: %w", name, gerr) |
| 1757 | } |
| 1758 | arn = aws.ToString(got.User.Arn) |
| 1759 | } else { |
| 1760 | arn = aws.ToString(out.User.Arn) |
| 1761 | } |
| 1762 | if _, err := p.iam.PutUserPolicy(ctx, &iam.PutUserPolicyInput{ |
| 1763 | UserName: aws.String(name), |
| 1764 | PolicyName: aws.String(PolicyName), |
| 1765 | PolicyDocument: aws.String(doc), |
| 1766 | }); err != nil { |
| 1767 | return "", fmt.Errorf("mail: put policy for user %q: %w", name, err) |
| 1768 | } |
| 1769 | return arn, nil |
| 1770 | } |
| 1771 | ``` |
| 1772 | |
| 1773 | `internal/mail/memprovisioner.go`, beside `EnsureUser` (`:201`): |
| 1774 | |
| 1775 | ```go |
| 1776 | func (p *MemProvisioner) EnsureAddressUser(_ context.Context, name, path, identityARN, configSetARN, address, tenant string) (string, error) { |
| 1777 | p.mu.Lock() |
| 1778 | defer p.mu.Unlock() |
| 1779 | if err := p.takeFailure("EnsureAddressUser"); err != nil { |
| 1780 | return "", err |
| 1781 | } |
| 1782 | doc, err := SendAddressPolicyJSON(identityARN, configSetARN, address, tenant) |
| 1783 | if err != nil { |
| 1784 | return "", err |
| 1785 | } |
| 1786 | u, ok := p.Users[name] |
| 1787 | if !ok { |
| 1788 | u = &MemUser{ARN: fmt.Sprintf("arn:aws:iam::%s:user%s%s", p.account, path, name), Keys: map[string]MemKey{}} |
| 1789 | p.Users[name] = u |
| 1790 | } |
| 1791 | u.Policy = doc |
| 1792 | return u.ARN, nil |
| 1793 | } |
| 1794 | ``` |
| 1795 | |
| 1796 | - [ ] **Step 4: Run it to verify it passes** |
| 1797 | |
| 1798 | ```bash |
| 1799 | go test -run TestMemEnsureAddressUser ./internal/mail/ && go build ./... |
| 1800 | ``` |
| 1801 | |
| 1802 | Expected: PASS and a clean build. |
| 1803 | |
| 1804 | - [ ] **Step 5: Gate and commit** |
| 1805 | |
| 1806 | ```bash |
| 1807 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 1808 | git add internal/mail |
| 1809 | git commit -m "mail: EnsureAddressUser, the principal that may send as one address (plan 3, task 6)" |
| 1810 | ``` |
| 1811 | |
| 1812 | --- |
| 1813 | |
| 1814 | ### Task 7: The relaxed verification predicate, and the refusal for a foreign default configuration set |
| 1815 | |
| 1816 | **Files:** |
| 1817 | - Modify: `internal/mail/provisioner.go` (`Status.Verified` at `:45`), `internal/mail/external.go`, `internal/mail/external_test.go` |
| 1818 | |
| 1819 | **Interfaces:** |
| 1820 | - Consumes: `IdentityStatus` (`provisioner.go:71`), `Status.ConfigSet` (`:41`), `ConfigSetName` (`walk.go:375`). |
| 1821 | - Produces: |
| 1822 | ```go |
| 1823 | func (s Status) VerifiedExternal() bool |
| 1824 | func CheckExternalIdentity(ctx context.Context, p Provisioner, ext *ExternalIdentity, configSet string) error |
| 1825 | var ErrExternalNotVerified = errors.New("mail: the external identity is not verified for sending") |
| 1826 | var ErrForeignConfigSet = errors.New("mail: the external identity's default configuration set belongs to its owner") |
| 1827 | ``` |
| 1828 | |
| 1829 | - [ ] **Step 1: Write the failing test** |
| 1830 | |
| 1831 | `internal/mail/external_test.go` (append): |
| 1832 | |
| 1833 | ```go |
| 1834 | // Status.Verified demands MailFrom == SUCCESS (provisioner.go:45), which an |
| 1835 | // identity with no CUSTOM MAIL FROM never reaches — and CARLOS must never |
| 1836 | // give a borrowed identity one. tito.io would therefore read as permanently |
| 1837 | // unverified under the ordinary predicate, which is why an external identity |
| 1838 | // gets its own: DKIM and sending, MAIL FROM ignored. |
| 1839 | func TestVerifiedExternalIgnoresMailFrom(t *testing.T) { |
| 1840 | ok := Status{DKIM: "SUCCESS", MailFrom: "NOT_STARTED", Sending: "SUCCESS"} |
| 1841 | if ok.Verified() { |
| 1842 | t.Fatal("the ordinary predicate accepted an identity with no custom MAIL FROM") |
| 1843 | } |
| 1844 | if !ok.VerifiedExternal() { |
| 1845 | t.Fatal("the external predicate refused a DKIM-verified, sending-enabled identity") |
| 1846 | } |
| 1847 | for _, bad := range []Status{ |
| 1848 | {DKIM: "PENDING", MailFrom: "NOT_STARTED", Sending: "SUCCESS"}, |
| 1849 | {DKIM: "FAILED", MailFrom: "NOT_STARTED", Sending: "SUCCESS"}, |
| 1850 | {DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "PENDING"}, |
| 1851 | {}, |
| 1852 | } { |
| 1853 | if bad.VerifiedExternal() { |
| 1854 | t.Errorf("VerifiedExternal accepted %+v", bad) |
| 1855 | } |
| 1856 | } |
| 1857 | } |
| 1858 | |
| 1859 | // THE SPIKE'S SECOND ANSWER, made into a refusal (§4.1). There is no |
| 1860 | // tenant-level default configuration set, so a tenant-tagged send falls back |
| 1861 | // to the IDENTITY's default — and if that default is the owner's set rather |
| 1862 | // than CARLOS's, the send is refused outright with a 554 naming a |
| 1863 | // configuration set the sender has never heard of. Enable refuses first, with |
| 1864 | // the explanation, rather than delivering a credential that cannot send. |
| 1865 | func TestCheckExternalIdentityRefusesTheOwnersDefaultConfigurationSet(t *testing.T) { |
| 1866 | ctx := context.Background() |
| 1867 | s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| 1868 | ext := registerTitoIO(t, ctx, s, true) |
| 1869 | if _, err := p.EnsureIdentity(ctx, "eu-west-1", "tito.io", ""); err != nil { |
| 1870 | t.Fatal(err) |
| 1871 | } |
| 1872 | p.SetIdentityStatus("eu-west-1", "tito.io", Status{DKIM: "SUCCESS", MailFrom: "NOT_STARTED", Sending: "SUCCESS"}) |
| 1873 | |
| 1874 | // No default set at all: nothing to collide with, and the message's own |
| 1875 | // X-SES-CONFIGURATION-SET decides. |
| 1876 | if err := CheckExternalIdentity(ctx, p, ext, "carlos-jam"); err != nil { |
| 1877 | t.Fatalf("an identity with no default set was refused: %v", err) |
| 1878 | } |
| 1879 | // The owner's set, which is the shape the spike ran against. |
| 1880 | if err := p.SetIdentityConfigSet(ctx, "eu-west-1", "tito.io", "classic-events"); err != nil { |
| 1881 | t.Fatal(err) |
| 1882 | } |
| 1883 | if err := CheckExternalIdentity(ctx, p, ext, "carlos-jam"); !errors.Is(err, ErrForeignConfigSet) { |
| 1884 | t.Fatalf("CheckExternalIdentity = %v, want ErrForeignConfigSet", err) |
| 1885 | } |
| 1886 | // CARLOS's own set on the identity is the one case that is fine, because |
| 1887 | // that IS the set associated with this account's tenant. |
| 1888 | if err := p.SetIdentityConfigSet(ctx, "eu-west-1", "tito.io", "carlos-jam"); err != nil { |
| 1889 | t.Fatal(err) |
| 1890 | } |
| 1891 | if err := CheckExternalIdentity(ctx, p, ext, "carlos-jam"); err != nil { |
| 1892 | t.Fatalf("CARLOS's own set was treated as foreign: %v", err) |
| 1893 | } |
| 1894 | // An identity SES has not verified is refused before any of that. |
| 1895 | p.SetIdentityStatus("eu-west-1", "tito.io", Status{DKIM: "PENDING", MailFrom: "NOT_STARTED", Sending: "PENDING"}) |
| 1896 | if err := CheckExternalIdentity(ctx, p, ext, "carlos-jam"); !errors.Is(err, ErrExternalNotVerified) { |
| 1897 | t.Fatalf("CheckExternalIdentity on an unverified identity = %v, want ErrExternalNotVerified", err) |
| 1898 | } |
| 1899 | } |
| 1900 | ``` |
| 1901 | |
| 1902 | - [ ] **Step 2: Run it to verify it fails** |
| 1903 | |
| 1904 | ```bash |
| 1905 | go test -run 'TestVerifiedExternal|TestCheckExternalIdentity' ./internal/mail/ |
| 1906 | ``` |
| 1907 | |
| 1908 | Expected: build failure, `ok.VerifiedExternal undefined`. |
| 1909 | |
| 1910 | - [ ] **Step 3: Write the code** |
| 1911 | |
| 1912 | `internal/mail/provisioner.go`, beside `Verified` (`:44-47`): |
| 1913 | |
| 1914 | ```go |
| 1915 | // VerifiedExternal is Verified for an identity CARLOS does not own. |
| 1916 | // |
| 1917 | // MAIL FROM is deliberately ignored. Verified demands MailFrom == "SUCCESS" |
| 1918 | // because CARLOS sets a custom MAIL FROM on every identity it creates, and an |
| 1919 | // unaligned envelope domain fails DMARC. A borrowed identity gets no custom |
| 1920 | // MAIL FROM, because setting one is a change to somebody else's mail and the |
| 1921 | // guard refuses it — so it never reaches SUCCESS and would read as |
| 1922 | // permanently unverified under the ordinary predicate. DKIM and sending are |
| 1923 | // still both required: they are what SES actually needs to accept a message. |
| 1924 | func (s Status) VerifiedExternal() bool { |
| 1925 | return s.DKIM == "SUCCESS" && s.Sending == "SUCCESS" |
| 1926 | } |
| 1927 | ``` |
| 1928 | |
| 1929 | `internal/mail/external.go` (append): |
| 1930 | |
| 1931 | ```go |
| 1932 | var ( |
| 1933 | // ErrExternalNotVerified is a registered identity SES has not finished |
| 1934 | // verifying, or has stopped accepting sends for. It is the owner's to |
| 1935 | // fix, so the message says which axis and stops there. |
| 1936 | ErrExternalNotVerified = errors.New("mail: the external identity is not verified for sending") |
| 1937 | // ErrForeignConfigSet is the spike's second answer made into a refusal |
| 1938 | // (§4.1). SES has no tenant-level default configuration set, so a |
| 1939 | // tenant-tagged send with no X-SES-CONFIGURATION-SET header falls back to |
| 1940 | // the IDENTITY's default — and when that default belongs to the |
| 1941 | // identity's owner, the send is refused with a 554 naming a set the |
| 1942 | // sender has no authority over. |
| 1943 | ErrForeignConfigSet = errors.New("mail: the external identity's default configuration set belongs to its owner") |
| 1944 | ) |
| 1945 | |
| 1946 | // CheckExternalIdentity is what Enable asks before it mints anything: |
| 1947 | // is this identity usable, and can a tenant-tagged send through it actually |
| 1948 | // reach CARLOS's configuration set. |
| 1949 | // |
| 1950 | // configSet is the account's own set, ConfigSetName(acct). The comparison is |
| 1951 | // against that NAME rather than against a listing of the tenant's resources, |
| 1952 | // and the reason is exact: CARLOS associates exactly one configuration set |
| 1953 | // with an account's tenant, its own, so "is the identity's default set |
| 1954 | // associated with this tenant" and "is it CARLOS's set for this account" are |
| 1955 | // the same question. A set CARLOS did not create was never associated with |
| 1956 | // this tenant by anything. |
| 1957 | func CheckExternalIdentity(ctx context.Context, p Provisioner, ext *ExternalIdentity, configSet string) error { |
| 1958 | st, err := p.IdentityStatus(ctx, ext.Region, ext.Domain) |
| 1959 | if err != nil { |
| 1960 | return fmt.Errorf("mail: identity status for %s in %s: %w", ext.Domain, ext.Region, err) |
| 1961 | } |
| 1962 | if !st.VerifiedExternal() { |
| 1963 | return fmt.Errorf("mail: %s at SES: DKIM %s, sending %s: %w", |
| 1964 | ext.Domain, st.DKIM, st.Sending, ErrExternalNotVerified) |
| 1965 | } |
| 1966 | if st.ConfigSet != "" && st.ConfigSet != configSet { |
| 1967 | return fmt.Errorf( |
| 1968 | "mail: %s has %s as its default configuration set, which belongs to its owner and is not associated with this account's tenant, so every tenant-tagged send that does not name %s itself would be refused: %w", |
| 1969 | ext.Domain, st.ConfigSet, configSet, ErrForeignConfigSet) |
| 1970 | } |
| 1971 | return nil |
| 1972 | } |
| 1973 | ``` |
| 1974 | |
| 1975 | - [ ] **Step 4: Run it to verify it passes** |
| 1976 | |
| 1977 | ```bash |
| 1978 | go test -run 'TestVerifiedExternal|TestCheckExternalIdentity' ./internal/mail/ |
| 1979 | ``` |
| 1980 | |
| 1981 | Expected: PASS. |
| 1982 | |
| 1983 | - [ ] **Step 5: Gate and commit** |
| 1984 | |
| 1985 | ```bash |
| 1986 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 1987 | git add internal/mail |
| 1988 | git commit -m "mail: the external verification predicate and the foreign configuration-set refusal (plan 3, task 7)" |
| 1989 | ``` |
| 1990 | |
| 1991 | --- |
| 1992 | |
| 1993 | ### Task 8: `ClaimAddressSender`, where claiming and declaring an address are one call |
| 1994 | |
| 1995 | **Files:** |
| 1996 | - Modify: `internal/mail/walk.go` (beside `Declare` at `:487`, and inside it at `:521`) |
| 1997 | - Create: `internal/mail/walk_address_test.go` |
| 1998 | |
| 1999 | **Interfaces:** |
| 2000 | - Consumes: `ExternalIdentityFor`, `AddressDomain`, `ValidAddress`, `ClaimAddress`, `ErrAddressNotOpen`, `ErrAddressClaimed`, `ValidEnvPrefix` (`mail.go:118`), `DefaultEnvPrefix`, `appconfig.ValidVarValue`, and plan 2's `GetSender`, `PutSender`, `Sender`, `SourceAddress`, `SourceDomain`, `ScopeApp`, `SenderAppID`, `ValidSenderID`. |
| 2001 | - Produces: |
| 2002 | ```go |
| 2003 | func ClaimAddressSender(ctx context.Context, s release.Store, acct, app, sender, address, envPrefix, actor string, now time.Time) (*Sender, error) |
| 2004 | var ErrNoExternalIdentity = errors.New("mail: no external identity is registered for that address's domain") |
| 2005 | ``` |
| 2006 | |
| 2007 | - [ ] **Step 1: Write the failing test** |
| 2008 | |
| 2009 | `internal/mail/walk_address_test.go` (create): |
| 2010 | |
| 2011 | ```go |
| 2012 | package mail |
| 2013 | |
| 2014 | import ( |
| 2015 | "context" |
| 2016 | "errors" |
| 2017 | "testing" |
| 2018 | "time" |
| 2019 | |
| 2020 | "github.com/carlosframework/platform/internal/release" |
| 2021 | ) |
| 2022 | |
| 2023 | // CLAIMING AND DECLARING ARE ONE CALL (design §7): "it is Declare for an |
| 2024 | // address sender, so declaring a From address and claiming it are one call |
| 2025 | // and cannot disagree." Two calls could: a claim with no declaration is an |
| 2026 | // address nothing sends as, and a declaration with no claim is a From address |
| 2027 | // no roster knows about. |
| 2028 | func TestClaimAddressSenderDeclaresTheAddressAndTakesTheClaim(t *testing.T) { |
| 2029 | ctx := context.Background() |
| 2030 | s := release.NewDirStore(t.TempDir()) |
| 2031 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 2032 | ext := registerTitoIO(t, ctx, s, true) |
| 2033 | |
| 2034 | sn, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID, |
| 2035 | "Jelly@Tito.io", "JELLY_SMTP", "owner@example.test", now) |
| 2036 | if err != nil { |
| 2037 | t.Fatalf("ClaimAddressSender: %v", err) |
| 2038 | } |
| 2039 | // Lowercased on the way in: the claim key is a function of the address, |
| 2040 | // and two cases of one address must not be two claims. |
| 2041 | if sn.Source != SourceAddress || sn.Address != "jelly@tito.io" || sn.Identity != ext.IdentityARN { |
| 2042 | t.Fatalf("the sender did not become an address sender: %+v", sn) |
| 2043 | } |
| 2044 | if sn.Declared == nil || sn.Declared.From != "jelly@tito.io" || sn.Declared.EnvPrefix != "JELLY_SMTP" { |
| 2045 | t.Fatalf("the declaration disagrees with the claim: %+v", sn.Declared) |
| 2046 | } |
| 2047 | claim, err := GetAddressClaim(ctx, s, "jelly@tito.io", "jam") |
| 2048 | if err != nil || claim == nil || claim.App != "jelly" || claim.Sender != SenderAppID { |
| 2049 | t.Fatalf("the claim = %+v %v", claim, err) |
| 2050 | } |
| 2051 | // Idempotent, so a re-run of the door repairs rather than refuses. |
| 2052 | if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID, |
| 2053 | "jelly@tito.io", "JELLY_SMTP", "owner@example.test", now); err != nil { |
| 2054 | t.Fatalf("a second identical claim: %v", err) |
| 2055 | } |
| 2056 | } |
| 2057 | |
| 2058 | // An address under no registered identity is refused, and refused with its |
| 2059 | // own error: "nobody registered tito.io here" and "tito.io is registered but |
| 2060 | // closed to you" are different problems with different recourses, and a |
| 2061 | // caller that could not tell them apart would send the member to the wrong |
| 2062 | // person. |
| 2063 | func TestClaimAddressSenderRefusesAnAddressUnderNoRegisteredIdentity(t *testing.T) { |
| 2064 | ctx := context.Background() |
| 2065 | s := release.NewDirStore(t.TempDir()) |
| 2066 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 2067 | registerTitoIO(t, ctx, s, true) |
| 2068 | |
| 2069 | if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID, |
| 2070 | "jelly@example.com", "", "owner@example.test", now); !errors.Is(err, ErrNoExternalIdentity) { |
| 2071 | t.Fatalf("an unregistered domain = %v, want ErrNoExternalIdentity", err) |
| 2072 | } |
| 2073 | // Not an address at all, and a traversal dressed as one, are both refused |
| 2074 | // before any lookup: the string becomes a claim key and a policy value. |
| 2075 | for _, bad := range []string{"", "tito.io", "jelly@", "jelly@tito.io/../x"} { |
| 2076 | if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID, |
| 2077 | bad, "", "owner@example.test", now); err == nil { |
| 2078 | t.Errorf("ClaimAddressSender accepted %q", bad) |
| 2079 | } |
| 2080 | } |
| 2081 | } |
| 2082 | |
| 2083 | // THE CLAIM IS TAKEN BEFORE THE RECORD IS WRITTEN, the order EnsureDomain |
| 2084 | // takes for the domain claim and for the same reason: a sender record naming |
| 2085 | // an address this account may not hold is a record that can never be enabled, |
| 2086 | // and nothing would ever clear it. |
| 2087 | func TestClaimAddressSenderWritesNoRecordWhenTheClaimIsRefused(t *testing.T) { |
| 2088 | ctx := context.Background() |
| 2089 | s := release.NewDirStore(t.TempDir()) |
| 2090 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 2091 | registerTitoIO(t, ctx, s, false) // closed to self-serve |
| 2092 | |
| 2093 | if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID, |
| 2094 | "billing@tito.io", "", "owner@example.test", now); !errors.Is(err, ErrAddressNotOpen) { |
| 2095 | t.Fatalf("a self-serve claim on a closed identity = %v, want ErrAddressNotOpen", err) |
| 2096 | } |
| 2097 | if sn, _, err := GetSender(ctx, s, "jam", "jelly", SenderAppID); err != nil || sn != nil { |
| 2098 | t.Fatalf("a refused claim still wrote a sender: %+v %v", sn, err) |
| 2099 | } |
| 2100 | // The operator's grant is the recourse, and the member's own call then |
| 2101 | // succeeds against it — one record, not two code paths. |
| 2102 | if err := GrantAddress(ctx, s, "billing@tito.io", "jam", "jelly", SenderAppID, "operator@example.test", now); err != nil { |
| 2103 | t.Fatal(err) |
| 2104 | } |
| 2105 | if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID, |
| 2106 | "billing@tito.io", "", "owner@example.test", now); err != nil { |
| 2107 | t.Fatalf("a claim against an operator grant: %v", err) |
| 2108 | } |
| 2109 | // A sibling app in the same account is refused as a conflict. |
| 2110 | if _, err := ClaimAddressSender(ctx, s, "jam", "other", SenderAppID, |
| 2111 | "billing@tito.io", "", "owner@example.test", now); !errors.Is(err, ErrAddressClaimed) { |
| 2112 | t.Fatalf("a sibling app = %v, want ErrAddressClaimed", err) |
| 2113 | } |
| 2114 | } |
| 2115 | |
| 2116 | // The env prefix is locked once delivered, exactly as Declare locks it |
| 2117 | // (walk.go:516): moving it would leave the old variables standing in every |
| 2118 | // instance's config and the app would keep reading the stale pair. |
| 2119 | func TestClaimAddressSenderKeepsDeclaresEnvPrefixLock(t *testing.T) { |
| 2120 | ctx := context.Background() |
| 2121 | s := release.NewDirStore(t.TempDir()) |
| 2122 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 2123 | registerTitoIO(t, ctx, s, true) |
| 2124 | |
| 2125 | sn, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID, |
| 2126 | "jelly@tito.io", "JELLY_SMTP", "owner@example.test", now) |
| 2127 | if err != nil { |
| 2128 | t.Fatal(err) |
| 2129 | } |
| 2130 | sn.Provisioned = &Provisioned{IAMUser: "carlos-mail-jam-jelly", KeyID: "AKIA", Active: true, At: now} |
| 2131 | if _, err := PutSender(ctx, s, "jam", "jelly", sn, ""); err != nil { |
| 2132 | // The etag is whatever GetSender would hand back; a test that cannot |
| 2133 | // write the fixture is a test failure, not a lock assertion. |
| 2134 | got, etag, gerr := GetSender(ctx, s, "jam", "jelly", SenderAppID) |
| 2135 | if gerr != nil { |
| 2136 | t.Fatal(gerr) |
| 2137 | } |
| 2138 | got.Provisioned = sn.Provisioned |
| 2139 | if _, err := PutSender(ctx, s, "jam", "jelly", got, etag); err != nil { |
| 2140 | t.Fatal(err) |
| 2141 | } |
| 2142 | } |
| 2143 | if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID, |
| 2144 | "jelly@tito.io", "OTHER_SMTP", "owner@example.test", now); err == nil { |
| 2145 | t.Fatal("the env prefix moved after delivery") |
| 2146 | } |
| 2147 | } |
| 2148 | |
| 2149 | // A SENDER DOES NOT MOVE TO A DIFFERENT ADDRESS, and this is the test that |
| 2150 | // pins why. Overwriting Address would leave the old one claimed by this |
| 2151 | // account with nothing naming it: teardown releases only the address the |
| 2152 | // record currently carries, so the orphan would refuse every sibling app in |
| 2153 | // the account for ever and hold the identity's tenant association open by |
| 2154 | // itself. `carlos email enable --from other@tito.io` against a sender |
| 2155 | // already sending as jelly@tito.io is the exact command that would do it. |
| 2156 | func TestClaimAddressSenderRefusesASecondAddressOnTheSameSender(t *testing.T) { |
| 2157 | ctx := context.Background() |
| 2158 | s := release.NewDirStore(t.TempDir()) |
| 2159 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 2160 | registerTitoIO(t, ctx, s, true) |
| 2161 | |
| 2162 | if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID, |
| 2163 | "jelly@tito.io", "JELLY_SMTP", "owner@example.test", now); err != nil { |
| 2164 | t.Fatal(err) |
| 2165 | } |
| 2166 | if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID, |
| 2167 | "other@tito.io", "JELLY_SMTP", "owner@example.test", now); err == nil { |
| 2168 | t.Fatal("a second address was adopted in place, stranding the first claim") |
| 2169 | } |
| 2170 | // Both halves of the leak: the first claim still stands and is still |
| 2171 | // this sender's, and the second was never taken. |
| 2172 | if c, _ := GetAddressClaim(ctx, s, "jelly@tito.io", "jam"); c == nil || c.App != "jelly" { |
| 2173 | t.Fatalf("the refusal disturbed the live claim: %+v", c) |
| 2174 | } |
| 2175 | if c, _ := GetAddressClaim(ctx, s, "other@tito.io", "jam"); c != nil { |
| 2176 | t.Fatalf("a refused claim was written anyway: %+v", c) |
| 2177 | } |
| 2178 | // The record is untouched, so the credential that is live keeps matching |
| 2179 | // the address its policy names. |
| 2180 | sn, _, err := GetSender(ctx, s, "jam", "jelly", SenderAppID) |
| 2181 | if err != nil || sn.Address != "jelly@tito.io" || sn.Declared.From != "jelly@tito.io" { |
| 2182 | t.Fatalf("the sender moved underneath its own policy: %+v %v", sn, err) |
| 2183 | } |
| 2184 | } |
| 2185 | ``` |
| 2186 | |
| 2187 | **This closes a design gap the spec is being amended to match.** §7 describes the claim door as "Declare for an address sender" and Declare freely rewrites its From; for an address sender that is a claim leak rather than an edit, so both doors refuse a change of address and point at teardown. Task 9's `ensureAddressPrincipal` re-reads the claim on every enable, which is what makes "the record and the roster agree" an invariant rather than a hope. |
| 2188 | |
| 2189 | **`Declare` is the same hole through the other door, and it is closed in this task too.** `Declare` overwrites `Declared.From` unconditionally (`walk.go:521`) and knows nothing about claims. On an address sender that splits the record in two: `_FROM` is delivered from `Declared.From` while the policy is rendered from `sn.Address`, so the app sends as an address its credential may not use and gets a 554 on every message — and `EnvDeliveredSender` keeps reading healthy, because it compares `_FROM` against `Declared.From`, which is exactly the value that moved. Add the refusal beside the env-prefix lock: |
| 2190 | |
| 2191 | ```go |
| 2192 | // An address sender's From is its CLAIM, not a field. Changing it here |
| 2193 | // would leave the delivered _FROM and the rendered policy describing |
| 2194 | // different addresses, which is a 554 on every message with nothing in |
| 2195 | // the record disagreeing about it. The address door is the only way in, |
| 2196 | // and it refuses a move of its own (ClaimAddressSender). |
| 2197 | if sn := rec.sender(); sn.Source == SourceAddress && sn.Address != "" && from != sn.Address { |
| 2198 | return nil, fmt.Errorf( |
| 2199 | "mail: %s/%s sends as %s under an identity this deployment does not own, so its From address is not declared here: claim a different address instead, or tear the sender down", |
| 2200 | acct, app, sn.Address) |
| 2201 | } |
| 2202 | ``` |
| 2203 | |
| 2204 | with a test beside the one above: declaring `other@tito.io` over a claimed `jelly@tito.io` is refused, and `Declared.From` still reads `jelly@tito.io` afterwards. A declare that repeats the SAME address is still success, so a re-run of any flow that declares before it claims is unaffected. |
| 2205 | |
| 2206 | - [ ] **Step 2: Run it to verify it fails** |
| 2207 | |
| 2208 | ```bash |
| 2209 | go test -run TestClaimAddressSender ./internal/mail/ |
| 2210 | ``` |
| 2211 | |
| 2212 | Expected: build failure, `undefined: ClaimAddressSender`. |
| 2213 | |
| 2214 | - [ ] **Step 3: Write the code** |
| 2215 | |
| 2216 | `internal/mail/walk.go`, after `Declare` (`:487-526`): |
| 2217 | |
| 2218 | ```go |
| 2219 | // ErrNoExternalIdentity is an address whose domain nobody registered. It is |
| 2220 | // its own error because the recourse differs from every other refusal here: |
| 2221 | // an operator has to register the identity, which is a conversation with the |
| 2222 | // identity's owner, not something the member can retry into. |
| 2223 | var ErrNoExternalIdentity = errors.New("mail: no external identity is registered for that address's domain") |
| 2224 | |
| 2225 | // ClaimAddressSender is Declare for a sender that sends as ONE address under |
| 2226 | // an identity CARLOS does not own (design §7). |
| 2227 | // |
| 2228 | // Claiming and declaring are ONE call, deliberately. Two calls could |
| 2229 | // disagree: a claim with no declaration is an address nothing sends as, and a |
| 2230 | // declaration with no claim is a From address no roster knows about and no |
| 2231 | // policy will ever be rendered for. So this door sets Source, Address, |
| 2232 | // Identity and Declared together or sets none of them. |
| 2233 | // |
| 2234 | // THE CLAIM IS TAKEN FIRST, before the record is written, which is the order |
| 2235 | // EnsureDomain takes for the domain claim (walk.go:171) and for the same |
| 2236 | // reason: a sender record naming an address this account may not hold could |
| 2237 | // never be enabled, and nothing in the system would ever clear it. The |
| 2238 | // reverse order buys nothing — the record write is the cheap half. |
| 2239 | // |
| 2240 | // It does NOT reach a backend. Like Declare, this is the member's half: |
| 2241 | // nothing is provisioned, nothing is associated, and no credential exists |
| 2242 | // when it returns. Enable is where the address, the identity and the tenant |
| 2243 | // meet, and where SES first hears about any of it. |
| 2244 | func ClaimAddressSender(ctx context.Context, s release.Store, acct, app, sender, |
| 2245 | address, envPrefix, actor string, now time.Time) (*Sender, error) { |
| 2246 | |
| 2247 | if !release.ValidAccount(acct) || !release.ValidApp(app) || !ValidSenderID(sender) { |
| 2248 | return nil, fmt.Errorf("mail: invalid account %q, app %q or sender %q", acct, app, sender) |
| 2249 | } |
| 2250 | // Lowercased before anything else looks at it: the claim key is a |
| 2251 | // function of the address, so two cases of one address must not become |
| 2252 | // two claims with two different holders. |
| 2253 | address = strings.ToLower(strings.TrimSpace(address)) |
| 2254 | if !ValidAddress(address) { |
| 2255 | return nil, fmt.Errorf("mail: %q is not a sender address", address) |
| 2256 | } |
| 2257 | // Checked here for Declare's own reason (walk.go:503): the address is |
| 2258 | // delivered verbatim as <P>_FROM, and an unrenderable byte caught only |
| 2259 | // inside writeSMTPEnv would fail AFTER MintKey has run. |
| 2260 | if err := appconfig.ValidVarValue(address); err != nil { |
| 2261 | return nil, fmt.Errorf("mail: From %q is not EnvironmentFile-safe: %w", address, err) |
| 2262 | } |
| 2263 | if envPrefix == "" { |
| 2264 | envPrefix = DefaultEnvPrefix |
| 2265 | } |
| 2266 | if !ValidEnvPrefix(envPrefix) { |
| 2267 | return nil, fmt.Errorf("mail: invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$", envPrefix) |
| 2268 | } |
| 2269 | ext, err := ExternalIdentityFor(ctx, s, AddressDomain(address)) |
| 2270 | if err != nil { |
| 2271 | return nil, err |
| 2272 | } |
| 2273 | if ext == nil { |
| 2274 | return nil, fmt.Errorf("mail: %s: %w", address, ErrNoExternalIdentity) |
| 2275 | } |
| 2276 | sn, etag, err := GetSender(ctx, s, acct, app, sender) |
| 2277 | if err != nil { |
| 2278 | return nil, err |
| 2279 | } |
| 2280 | if sn == nil { |
| 2281 | sn = &Sender{ID: sender, Scope: ScopeApp} |
| 2282 | } |
| 2283 | if sn.Declared != nil && sn.Provisioned != nil && sn.Declared.EnvPrefix != envPrefix { |
| 2284 | return nil, fmt.Errorf("mail: env prefix is %q and cannot move after delivery", sn.Declared.EnvPrefix) |
| 2285 | } |
| 2286 | // A PROVISIONED DOMAIN SENDER DOES NOT CONVERT. Its live policy names |
| 2287 | // granted domains and its delivered bundle carries five variables; moving |
| 2288 | // Source underneath it would leave a credential whose policy and whose |
| 2289 | // delivered environment describe two different senders until the next |
| 2290 | // enable, and the failure in between is a 554 on every message. Tear the |
| 2291 | // sender down and claim the address instead. |
| 2292 | if sn.Source == SourceDomain && sn.Provisioned != nil { |
| 2293 | return nil, fmt.Errorf( |
| 2294 | "mail: %s/%s already sends at a verified domain, so it cannot be moved onto %s in place: tear it down first", |
| 2295 | acct, app, address) |
| 2296 | } |
| 2297 | // AND NEITHER DOES AN ADDRESS SENDER MOVE TO A DIFFERENT ADDRESS. This |
| 2298 | // is the same rule as the line above, and it is here because the |
| 2299 | // alternative leaks a claim for ever: overwriting Address would leave the |
| 2300 | // OLD address still claimed by this account, with nothing left in the |
| 2301 | // system naming it. Teardown releases only the address the record |
| 2302 | // currently carries, so nothing would ever release the old one, and it |
| 2303 | // would refuse every sibling app in the account with ErrAddressClaimed |
| 2304 | // while holding the identity's tenant association open on its own. |
| 2305 | // Releasing it here instead was the other option and is worse: the |
| 2306 | // release would have to happen before the new claim is taken, so a |
| 2307 | // failure in between would leave a sender that has given up an address |
| 2308 | // it is still sending as. |
| 2309 | if sn.Source == SourceAddress && sn.Address != "" && sn.Address != address { |
| 2310 | return nil, fmt.Errorf( |
| 2311 | "mail: %s/%s already sends as %s, so it cannot be moved onto %s in place: tear it down first", |
| 2312 | acct, app, sn.Address, address) |
| 2313 | } |
| 2314 | if err := ClaimAddress(ctx, s, address, acct, app, sender, actor, ext.Open, now); err != nil { |
| 2315 | return nil, err |
| 2316 | } |
| 2317 | sn.Source, sn.Address, sn.Identity = SourceAddress, address, ext.IdentityARN |
| 2318 | sn.Declared = &Declared{EnvPrefix: envPrefix, From: address, At: now, By: actor} |
| 2319 | if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil { |
| 2320 | return nil, err |
| 2321 | } |
| 2322 | return sn, nil |
| 2323 | } |
| 2324 | ``` |
| 2325 | |
| 2326 | - [ ] **Step 4: Run it to verify it passes** |
| 2327 | |
| 2328 | ```bash |
| 2329 | go test -run TestClaimAddressSender ./internal/mail/ && go test ./internal/mail/ -count=1 |
| 2330 | ``` |
| 2331 | |
| 2332 | Expected: PASS, with every existing declare test unchanged. |
| 2333 | |
| 2334 | - [ ] **Step 5: Gate and commit** |
| 2335 | |
| 2336 | ```bash |
| 2337 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 2338 | git add internal/mail |
| 2339 | git commit -m "mail: ClaimAddressSender, where claiming an address and declaring it are one call (plan 3, task 8)" |
| 2340 | ``` |
| 2341 | |
| 2342 | --- |
| 2343 | |
| 2344 | ### Task 9: `Enable`'s address branch, and the seven variables an address sender is delivered |
| 2345 | |
| 2346 | **Files:** |
| 2347 | - Modify: `internal/mail/walk.go` (`EnvDelivered` at `:576`, `Enable` at `:616`, `writeSMTPEnv` at `:795` and its two call sites at `:728` and `:902`), `internal/mail/walk_address_test.go` |
| 2348 | |
| 2349 | **Interfaces:** |
| 2350 | - Consumes: `ExternalIdentityFor`, `GetAddressClaim`, `CheckExternalIdentity`, `ErrForeignConfigSet`, `EnsureAddressUser`, `ConfigSetName` (`walk.go:375`), `TenantName` (`policy.go:84`), `UserPath`/`UserName` (`policy.go:24`, `:27`), and plan 2's `ConfigSetARN`, `AssociateTenantResource`, `(r *Record) sender()`. |
| 2351 | - Produces: |
| 2352 | ```go |
| 2353 | func EnvNamesAddress(prefix string) (configSet, tenant string) |
| 2354 | func EnvDeliveredSender(ctx context.Context, s release.Store, acct, app string, sn *Sender, wantKeyID string) ([]string, error) |
| 2355 | func ensureAddressPrincipal(ctx context.Context, s release.Store, p Provisioner, d Deployment, acct, app string, sn *Sender) (user, arn, configSet, tenant string, err error) // unexported |
| 2356 | ``` |
| 2357 | `writeSMTPEnv` gains a trailing `sn *Sender`. `Enable`'s signature does not change: plan 2 owns it, and this task only branches inside it. |
| 2358 | |
| 2359 | - [ ] **Step 1: Write the failing test** |
| 2360 | |
| 2361 | `internal/mail/walk_address_test.go` (append): |
| 2362 | |
| 2363 | ```go |
| 2364 | // addressEnableFixture is the state an address sender's enable expects to |
| 2365 | // find: a registered identity, a claim, and SES reporting the identity |
| 2366 | // verified under the EXTERNAL predicate (no custom MAIL FROM, because CARLOS |
| 2367 | // must never give it one). |
| 2368 | func addressEnableFixture(t *testing.T) (context.Context, release.Store, *MemProvisioner, *ecdh.PublicKey, time.Time) { |
| 2369 | t.Helper() |
| 2370 | ctx := context.Background() |
| 2371 | s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| 2372 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 2373 | _, pubB64, err := appconfig.GenerateKey() |
| 2374 | if err != nil { |
| 2375 | t.Fatal(err) |
| 2376 | } |
| 2377 | pub, err := appconfig.ParsePublicKey(pubB64, "test") |
| 2378 | if err != nil { |
| 2379 | t.Fatal(err) |
| 2380 | } |
| 2381 | registerTitoIO(t, ctx, s, true) |
| 2382 | if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID, |
| 2383 | "jelly@tito.io", "JELLY_SMTP", "owner@example.test", now); err != nil { |
| 2384 | t.Fatal(err) |
| 2385 | } |
| 2386 | if _, err := p.EnsureIdentity(ctx, "eu-west-1", "tito.io", ""); err != nil { |
| 2387 | t.Fatal(err) |
| 2388 | } |
| 2389 | p.SetIdentityStatus("eu-west-1", "tito.io", Status{DKIM: "SUCCESS", MailFrom: "NOT_STARTED", Sending: "SUCCESS"}) |
| 2390 | return ctx, s, p, pub, now |
| 2391 | } |
| 2392 | |
| 2393 | // SEVEN VARIABLES, NOT FIVE (design §7, spike answer 2). There is no |
| 2394 | // tenant-level default configuration set, so a tenant-tagged send that does |
| 2395 | // not name CARLOS's set falls back to the IDENTITY's default, which belongs |
| 2396 | // to its owner, and is refused with a 554. And the policy's ses:TenantName |
| 2397 | // condition means a send that omits the tenant header is refused outright. |
| 2398 | // So the app must set both headers, and it can only do that if it is told |
| 2399 | // both values. |
| 2400 | func TestEnableDeliversTheSevenVariablesForAnAddressSender(t *testing.T) { |
| 2401 | ctx, s, p, pub, now := addressEnableFixture(t) |
| 2402 | rec, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now) |
| 2403 | if err != nil { |
| 2404 | t.Fatalf("Enable: %v", err) |
| 2405 | } |
| 2406 | if rec.Provisioned == nil || rec.Provisioned.KeyID == "" { |
| 2407 | t.Fatalf("nothing provisioned: %+v", rec) |
| 2408 | } |
| 2409 | man, err := appconfig.Get(ctx, s, "jam", "jelly") |
| 2410 | if err != nil { |
| 2411 | t.Fatal(err) |
| 2412 | } |
| 2413 | for name, want := range map[string]string{ |
| 2414 | "JELLY_SMTP_HOST": "email-smtp.eu-west-1.amazonaws.com", |
| 2415 | "JELLY_SMTP_PORT": "587", |
| 2416 | "JELLY_SMTP_FROM": "jelly@tito.io", |
| 2417 | "JELLY_SMTP_USER": rec.Provisioned.KeyID, |
| 2418 | "JELLY_SMTP_CONFIG_SET": "carlos-jam", |
| 2419 | "JELLY_SMTP_TENANT": "carlos-jam", |
| 2420 | } { |
| 2421 | if got := man.Vars[name]; got.Kind != "env" || got.Value != want { |
| 2422 | t.Errorf("%s = %+v, want env %q", name, got, want) |
| 2423 | } |
| 2424 | } |
| 2425 | if man.Vars["JELLY_SMTP_PASS"].Kind != "secret" { |
| 2426 | t.Error("PASS was not delivered as a sealed secret") |
| 2427 | } |
| 2428 | // The credential may send as ONE address, under an identity with no |
| 2429 | // DomainRecord anywhere in this account. |
| 2430 | doc := p.Users["carlos-mail-jam-jelly"].Policy |
| 2431 | for _, want := range []string{"jelly@tito.io", "carlos-jam", "StringEqualsIgnoreCase", "ses:TenantName"} { |
| 2432 | if !strings.Contains(doc, want) { |
| 2433 | t.Errorf("policy is missing %q: %s", want, doc) |
| 2434 | } |
| 2435 | } |
| 2436 | if strings.Contains(doc, "*@") { |
| 2437 | t.Errorf("an address sender was granted a whole domain: %s", doc) |
| 2438 | } |
| 2439 | } |
| 2440 | |
| 2441 | // The tenant is what the ses:TenantName condition names, so both resources a |
| 2442 | // tenant-tagged send touches have to be associated with it: the identity, and |
| 2443 | // the configuration set the message's own header names. Association is inert |
| 2444 | // for the identity's owner in every respect except deletion (§4.1), which is |
| 2445 | // the cross-team contract the registration step states. |
| 2446 | func TestEnableAssociatesTheIdentityAndTheSetWithTheSendersTenant(t *testing.T) { |
| 2447 | ctx, s, p, pub, now := addressEnableFixture(t) |
| 2448 | if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now); err != nil { |
| 2449 | t.Fatal(err) |
| 2450 | } |
| 2451 | res := p.TenantResources["carlos-jam"] |
| 2452 | for _, want := range []string{ |
| 2453 | "arn:aws:ses:eu-west-1:1:identity/tito.io", |
| 2454 | "arn:aws:ses:eu-west-1:1:configuration-set/carlos-jam", |
| 2455 | } { |
| 2456 | if !res[want] { |
| 2457 | t.Errorf("%s is not associated with carlos-jam: %+v", want, res) |
| 2458 | } |
| 2459 | } |
| 2460 | // And CARLOS never wrote the identity itself: no MAIL FROM was set and |
| 2461 | // its default configuration set is untouched. |
| 2462 | if st, _ := p.IdentityStatus(ctx, "eu-west-1", "tito.io"); st.ConfigSet != "" { |
| 2463 | t.Errorf("enable bound a configuration set to a borrowed identity: %q", st.ConfigSet) |
| 2464 | } |
| 2465 | } |
| 2466 | |
| 2467 | // The idempotence short-circuit counts the two extra variables, because a |
| 2468 | // bundle missing either one is a credential that cannot send at all. A |
| 2469 | // repair walk re-delivers rather than short-circuiting on the five. |
| 2470 | func TestEnableRepairsAMissingTenantVariable(t *testing.T) { |
| 2471 | ctx, s, p, pub, now := addressEnableFixture(t) |
| 2472 | rec, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now) |
| 2473 | if err != nil { |
| 2474 | t.Fatal(err) |
| 2475 | } |
| 2476 | first := rec.Provisioned.KeyID |
| 2477 | // A second enable with everything delivered is a no-op: no fresh key. |
| 2478 | again, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now) |
| 2479 | if err != nil { |
| 2480 | t.Fatal(err) |
| 2481 | } |
| 2482 | if again.Provisioned.KeyID != first { |
| 2483 | t.Fatalf("an idempotent enable minted a second key: %q -> %q", first, again.Provisioned.KeyID) |
| 2484 | } |
| 2485 | if _, err := appconfig.Apply(ctx, s, "jam", "jelly", nil, []string{"JELLY_SMTP_TENANT"}); err != nil { |
| 2486 | t.Fatal(err) |
| 2487 | } |
| 2488 | sn, _, err := GetSender(ctx, s, "jam", "jelly", SenderAppID) |
| 2489 | if err != nil { |
| 2490 | t.Fatal(err) |
| 2491 | } |
| 2492 | missing, err := EnvDeliveredSender(ctx, s, "jam", "jelly", sn, again.Provisioned.KeyID) |
| 2493 | if err != nil || len(missing) != 1 || missing[0] != "JELLY_SMTP_TENANT" { |
| 2494 | t.Fatalf("EnvDeliveredSender = %v %v, want [JELLY_SMTP_TENANT]", missing, err) |
| 2495 | } |
| 2496 | repaired, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now) |
| 2497 | if err != nil { |
| 2498 | t.Fatal(err) |
| 2499 | } |
| 2500 | man, err := appconfig.Get(ctx, s, "jam", "jelly") |
| 2501 | if err != nil { |
| 2502 | t.Fatal(err) |
| 2503 | } |
| 2504 | if man.Vars["JELLY_SMTP_TENANT"].Value != "carlos-jam" { |
| 2505 | t.Fatalf("the repair walk did not re-deliver the tenant: %+v", man.Vars["JELLY_SMTP_TENANT"]) |
| 2506 | } |
| 2507 | if repaired.Provisioned.KeyID == first { |
| 2508 | t.Log("the repair re-delivered without a fresh key, which is also correct") |
| 2509 | } |
| 2510 | } |
| 2511 | |
| 2512 | // Enable refuses BEFORE it mints, on each of the three things that would |
| 2513 | // make the delivered credential useless. |
| 2514 | func TestEnableRefusesAnAddressSenderItCannotMakeWork(t *testing.T) { |
| 2515 | ctx, s, p, pub, now := addressEnableFixture(t) |
| 2516 | |
| 2517 | // The identity's own default set belongs to its owner (§4.1, answer 2). |
| 2518 | if err := p.SetIdentityConfigSet(ctx, "eu-west-1", "tito.io", "classic-events"); err != nil { |
| 2519 | t.Fatal(err) |
| 2520 | } |
| 2521 | if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now); !errors.Is(err, ErrForeignConfigSet) { |
| 2522 | t.Fatalf("Enable = %v, want ErrForeignConfigSet", err) |
| 2523 | } |
| 2524 | if _, ok := p.Users["carlos-mail-jam-jelly"]; ok { |
| 2525 | t.Fatal("a refused enable still created a principal") |
| 2526 | } |
| 2527 | if err := p.SetIdentityConfigSet(ctx, "eu-west-1", "tito.io", ""); err != nil { |
| 2528 | t.Fatal(err) |
| 2529 | } |
| 2530 | |
| 2531 | // The claim has gone: the record says the address is this app's, the |
| 2532 | // roster says it is not, and the roster is the authority. |
| 2533 | if err := ReleaseAddressClaim(ctx, s, "jelly@tito.io", "jam", "jelly", SenderAppID); err != nil { |
| 2534 | t.Fatal(err) |
| 2535 | } |
| 2536 | if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now); !errors.Is(err, ErrAddressClaimed) { |
| 2537 | t.Fatalf("Enable with no claim = %v, want ErrAddressClaimed", err) |
| 2538 | } |
| 2539 | if err := GrantAddress(ctx, s, "jelly@tito.io", "jam", "jelly", SenderAppID, "op@example.test", now); err != nil { |
| 2540 | t.Fatal(err) |
| 2541 | } |
| 2542 | |
| 2543 | // A deployment with no SNS topic has no configuration set, and an |
| 2544 | // address sender must name one on every send or SES falls back to the |
| 2545 | // owner's. |
| 2546 | noSNS := testDeployment() |
| 2547 | noSNS.SNSTopicARN = "" |
| 2548 | if _, err := Enable(ctx, s, p, pub, noSNS, "jam", "jelly", now); err == nil { |
| 2549 | t.Fatal("Enable delivered an address sender on a deployment with no configuration set") |
| 2550 | } |
| 2551 | } |
| 2552 | |
| 2553 | // THE DOMAIN PATH DOES NOT MOVE. Its refusals are the same refusals, and a |
| 2554 | // domain sender is still delivered exactly five variables. |
| 2555 | func TestEnableLeavesTheDomainSenderAlone(t *testing.T) { |
| 2556 | ctx, s, p, pub, now := enableFixture(t) |
| 2557 | if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil { |
| 2558 | t.Fatal(err) |
| 2559 | } |
| 2560 | man, err := appconfig.Get(ctx, s, "bag", "harbourmail") |
| 2561 | if err != nil { |
| 2562 | t.Fatal(err) |
| 2563 | } |
| 2564 | for _, name := range []string{"HARBOURMAIL_SMTP_CONFIG_SET", "HARBOURMAIL_SMTP_TENANT"} { |
| 2565 | if _, ok := man.Vars[name]; ok { |
| 2566 | t.Errorf("%s was delivered to a domain sender, whose policy has no tenant condition: every send would 554", name) |
| 2567 | } |
| 2568 | } |
| 2569 | } |
| 2570 | ``` |
| 2571 | |
| 2572 | Add `"crypto/ecdh"`, `"strings"` and `"github.com/carlosframework/platform/internal/appconfig"` to this test file's imports. |
| 2573 | |
| 2574 | - [ ] **Step 2: Run it to verify it fails** |
| 2575 | |
| 2576 | ```bash |
| 2577 | go test -run 'TestEnableDeliversTheSeven|TestEnableAssociates|TestEnableRepairs|TestEnableRefusesAnAddressSender|TestEnableLeavesTheDomainSender' ./internal/mail/ |
| 2578 | ``` |
| 2579 | |
| 2580 | Expected: build failure, `undefined: EnvDeliveredSender`. |
| 2581 | |
| 2582 | - [ ] **Step 3: Write the code** |
| 2583 | |
| 2584 | `internal/mail/walk.go`, beside `EnvDelivered` (`:576`): |
| 2585 | |
| 2586 | ```go |
| 2587 | // EnvNamesAddress renders the two variables ONLY an address sender is |
| 2588 | // delivered (design §7). They are not part of EnvNames because delivering |
| 2589 | // them to a domain sender would be worse than useless: that sender's policy |
| 2590 | // carries no tenant condition, so a tenant header would name a tenant the |
| 2591 | // credential has no authority over, and the send would 554. |
| 2592 | func EnvNamesAddress(prefix string) (configSet, tenant string) { |
| 2593 | return prefix + "_CONFIG_SET", prefix + "_TENANT" |
| 2594 | } |
| 2595 | |
| 2596 | // EnvDeliveredSender is EnvDelivered for one sender, and it is what the walk |
| 2597 | // and the console both ask now. |
| 2598 | // |
| 2599 | // For an address sender it checks SEVEN variables rather than five, and it |
| 2600 | // compares the two extra VALUES rather than just their kinds, for the reason |
| 2601 | // _USER's value is compared (EnvDelivered's own doc comment): a bundle |
| 2602 | // carrying the wrong tenant or the wrong configuration set is not a bundle |
| 2603 | // that sends badly, it is one that does not send at all, and nothing else |
| 2604 | // ever re-checks it. |
| 2605 | // |
| 2606 | // The two expected values are computed from the ACCOUNT rather than read off |
| 2607 | // the record, because both are per-account by design (§5: "configuration sets |
| 2608 | // stay per account") and an address sender is always the app sender until |
| 2609 | // instance senders land in step 5. When they do, both come from |
| 2610 | // SenderNames instead, and this is the one place that has to change. |
| 2611 | func EnvDeliveredSender(ctx context.Context, s release.Store, acct, app string, sn *Sender, wantKeyID string) ([]string, error) { |
| 2612 | if sn == nil || sn.Declared == nil { |
| 2613 | return nil, ErrNotDeclared |
| 2614 | } |
| 2615 | missing, err := EnvDelivered(ctx, s, acct, app, sn.Declared.EnvPrefix, wantKeyID, sn.Declared.From) |
| 2616 | if err != nil || sn.Source != SourceAddress { |
| 2617 | return missing, err |
| 2618 | } |
| 2619 | man, err := appconfig.Get(ctx, s, acct, app) |
| 2620 | if err != nil { |
| 2621 | return nil, err |
| 2622 | } |
| 2623 | configSetName, tenantName := EnvNamesAddress(sn.Declared.EnvPrefix) |
| 2624 | if v, ok := man.Vars[configSetName]; !ok || v.Kind != "env" || v.Value != ConfigSetName(acct) { |
| 2625 | missing = append(missing, configSetName) |
| 2626 | } |
| 2627 | if v, ok := man.Vars[tenantName]; !ok || v.Kind != "env" || v.Value != TenantName(acct) { |
| 2628 | missing = append(missing, tenantName) |
| 2629 | } |
| 2630 | sort.Strings(missing) |
| 2631 | return missing, nil |
| 2632 | } |
| 2633 | ``` |
| 2634 | |
| 2635 | `internal/mail/walk.go`, `ensureAddressPrincipal`, above `Enable`: |
| 2636 | |
| 2637 | ```go |
| 2638 | // ensureAddressPrincipal is Enable's address half: everything from "this |
| 2639 | // sender says it sends as jelly@tito.io" to "a principal exists that may". |
| 2640 | // |
| 2641 | // The order is refuse-before-write, the discipline EnsureDomain follows. The |
| 2642 | // two reads that can refuse — the registration and the claim — happen before |
| 2643 | // any backend call at all, and CheckExternalIdentity is next because it too |
| 2644 | // only reads. Nothing durable happens until every one of them has passed. |
| 2645 | // |
| 2646 | // A CONFIGURATION SET IS REQUIRED, unlike the domain path where an empty one |
| 2647 | // is the ordinary shape on a deployment with no SNS topic. The spike settled |
| 2648 | // it (§4.1, answer 2): a tenant-tagged send that names no set falls back to |
| 2649 | // the IDENTITY's default, which belongs to the identity's owner, and is |
| 2650 | // refused with a 554 naming a set this credential has no authority over. A |
| 2651 | // domain sender has no such problem because CARLOS owns its identity and |
| 2652 | // binds its own set as that identity's default. |
| 2653 | func ensureAddressPrincipal(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| 2654 | acct, app string, sn *Sender) (user, arn, configSet, tenant string, err error) { |
| 2655 | |
| 2656 | ext, err := ExternalIdentityFor(ctx, s, AddressDomain(sn.Address)) |
| 2657 | if err != nil { |
| 2658 | return "", "", "", "", err |
| 2659 | } |
| 2660 | if ext == nil { |
| 2661 | return "", "", "", "", fmt.Errorf("mail: %s: %w", sn.Address, ErrNoExternalIdentity) |
| 2662 | } |
| 2663 | if ext.Region != d.Region { |
| 2664 | return "", "", "", "", fmt.Errorf( |
| 2665 | "mail: %s is registered in %s and this deployment sends from %s: tenants and identities are both per region", |
| 2666 | ext.Domain, ext.Region, d.Region) |
| 2667 | } |
| 2668 | // THE CLAIM IS THE AUTHORITY, not the sender record. The record is this |
| 2669 | // app's own file and this app can write it; the claim is the global |
| 2670 | // roster an operator reads and a deregistration clears. Re-checked on |
| 2671 | // every enable so that dropping a claim actually takes a credential's |
| 2672 | // authority away at the next walk. |
| 2673 | claim, err := GetAddressClaim(ctx, s, sn.Address, acct) |
| 2674 | if err != nil { |
| 2675 | return "", "", "", "", err |
| 2676 | } |
| 2677 | if claim == nil || claim.App != app || claim.Sender != sn.ID { |
| 2678 | return "", "", "", "", fmt.Errorf("mail: %s is not claimed by %s/%s: %w", sn.Address, acct, app, ErrAddressClaimed) |
| 2679 | } |
| 2680 | if d.SNSTopicARN == "" { |
| 2681 | return "", "", "", "", fmt.Errorf( |
| 2682 | "mail: this deployment has no SNS topic and therefore no configuration set, and a sender under %s must name one on every message or SES falls back to that identity's own default", |
| 2683 | ext.Domain) |
| 2684 | } |
| 2685 | configSet = ConfigSetName(acct) |
| 2686 | if err := CheckExternalIdentity(ctx, p, ext, configSet); err != nil { |
| 2687 | return "", "", "", "", err |
| 2688 | } |
| 2689 | if err := p.EnsureConfigSet(ctx, d.Region, configSet, d.SNSTopicARN); err != nil { |
| 2690 | return "", "", "", "", fmt.Errorf("mail: ensure config set %s: %w", configSet, err) |
| 2691 | } |
| 2692 | tenant = TenantName(acct) |
| 2693 | if err := p.EnsureTenant(ctx, tenant); err != nil { |
| 2694 | return "", "", "", "", fmt.Errorf("mail: ensure tenant %s: %w", tenant, err) |
| 2695 | } |
| 2696 | csARN, ok := ConfigSetARN(ext.IdentityARN, configSet) |
| 2697 | if !ok { |
| 2698 | return "", "", "", "", fmt.Errorf("mail: cannot build a configuration-set ARN from %q", ext.IdentityARN) |
| 2699 | } |
| 2700 | // BOTH resources, because a tenant-tagged send touches both and SES |
| 2701 | // authorises each in its own right (#271). Associating the identity is |
| 2702 | // also the cross-team contract: while it stands, its owner cannot delete |
| 2703 | // the identity, and teardown is what releases it. |
| 2704 | for _, res := range []string{ext.IdentityARN, csARN} { |
| 2705 | if err := p.AssociateTenantResource(ctx, tenant, res); err != nil { |
| 2706 | return "", "", "", "", fmt.Errorf("mail: associate %s with tenant %s: %w", res, tenant, err) |
| 2707 | } |
| 2708 | } |
| 2709 | user = UserName(acct, app) |
| 2710 | arn, err = p.EnsureAddressUser(ctx, user, UserPath, ext.IdentityARN, csARN, sn.Address, tenant) |
| 2711 | if err != nil { |
| 2712 | return "", "", "", "", fmt.Errorf("mail: ensure address user %s: %w", user, err) |
| 2713 | } |
| 2714 | return user, arn, configSet, tenant, nil |
| 2715 | } |
| 2716 | ``` |
| 2717 | |
| 2718 | `internal/mail/walk.go`, `Enable`: the granted-domain walk (`:627-671`) moves inside an `else`, untouched otherwise, and the tail below it is shared: |
| 2719 | |
| 2720 | ```go |
| 2721 | // THE BRANCH, and it is the only one (design §7). Everything below it — |
| 2722 | // the idempotence short-circuit, the reap, the mint, the pause |
| 2723 | // enforcement, the delivery and the record write — is identical for both |
| 2724 | // kinds of sender, which is the point: an address sender differs in which |
| 2725 | // principal it gets and what its bundle carries, not in how a credential |
| 2726 | // is minted or delivered. |
| 2727 | sn := rec.sender() |
| 2728 | var user, arn, configSet, tenant string |
| 2729 | if sn.Source == SourceAddress { |
| 2730 | user, arn, configSet, tenant, err = ensureAddressPrincipal(ctx, s, p, d, acct, app, sn) |
| 2731 | if err != nil { |
| 2732 | return nil, err |
| 2733 | } |
| 2734 | } else { |
| 2735 | // ... :627-671 verbatim: grantedDomains, ErrNoDomains, the ARN and |
| 2736 | // name gather, the "From is not at any verified domain" refusal, and |
| 2737 | // EnsureUser. It assigns user and arn instead of declaring them. |
| 2738 | } |
| 2739 | // PLAN 2'S CALLER INDEX STAYS HERE, BELOW THE BRANCH, not inside the |
| 2740 | // else. Plan 2 task 5 writes it "right after EnsureUser and before |
| 2741 | // MintKey"; an address sender's principal is ensured in the other arm, |
| 2742 | // and a version of this that carried the PutCallerIndex call into the |
| 2743 | // domain branch along with the rest of :627-671 would leave every |
| 2744 | // address sender's IAM user unindexed. SES events name the bare IAM user |
| 2745 | // (§4.1), so an unindexed caller is a sender whose sends are attributed |
| 2746 | // to nothing and counted against nothing. |
| 2747 | if err := PutCallerIndex(ctx, s, user, acct, app, sn.ID, now); err != nil { |
| 2748 | return nil, fmt.Errorf("mail: index caller %s: %w", user, err) |
| 2749 | } |
| 2750 | ``` |
| 2751 | |
| 2752 | and the short-circuit at `:677-685` reads: |
| 2753 | |
| 2754 | ```go |
| 2755 | if rec.Provisioned != nil { |
| 2756 | missing, err := EnvDeliveredSender(ctx, s, acct, app, sn, rec.Provisioned.KeyID) |
| 2757 | if err != nil { |
| 2758 | return nil, err |
| 2759 | } |
| 2760 | if len(missing) == 0 { |
| 2761 | return rec, nil |
| 2762 | } |
| 2763 | } |
| 2764 | ``` |
| 2765 | |
| 2766 | `internal/mail/walk.go`, `writeSMTPEnv` (`:795`) gains a trailing `sn *Sender` and, after the `sets` map is built: |
| 2767 | |
| 2768 | ```go |
| 2769 | // An address sender is delivered TWO MORE variables, and only an address |
| 2770 | // sender is (design §7). Its policy carries a ses:TenantName condition, |
| 2771 | // so a message that does not name the tenant is refused outright; and |
| 2772 | // because there is no tenant-level default configuration set (§3), a |
| 2773 | // message that does not name CARLOS's set is attributed to the identity's |
| 2774 | // own default, which belongs to its owner, and is refused too. The app |
| 2775 | // sets both as headers. Neither value needs validating: both are |
| 2776 | // "carlos-" plus an account sqid, which is three to sixteen lowercase |
| 2777 | // letters. |
| 2778 | if sn != nil && sn.Source == SourceAddress { |
| 2779 | configSetName, tenantName := EnvNamesAddress(prefix) |
| 2780 | sets[configSetName] = appconfig.Var{Kind: "env", Value: ConfigSetName(acct)} |
| 2781 | sets[tenantName] = appconfig.Var{Kind: "env", Value: TenantName(acct)} |
| 2782 | } |
| 2783 | ``` |
| 2784 | |
| 2785 | Both call sites pass the sender the walk already holds: `Enable` (`:728`) passes `sn`, `Rotate` (`:902`) passes `rec.sender()`. A standalone credential is never an address sender in this plan, so `CreateStandalone` is untouched. |
| 2786 | |
| 2787 | - [ ] **Step 4: Run it to verify it passes** |
| 2788 | |
| 2789 | ```bash |
| 2790 | go test ./internal/mail/ -count=1 |
| 2791 | ``` |
| 2792 | |
| 2793 | Expected: PASS, including every existing enable and rotate test unchanged. |
| 2794 | |
| 2795 | - [ ] **Step 5: Gate and commit** |
| 2796 | |
| 2797 | ```bash |
| 2798 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 2799 | git add internal/mail |
| 2800 | git commit -m "mail: enable's address branch, and the config-set and tenant variables it delivers (plan 3, task 9)" |
| 2801 | ``` |
| 2802 | |
| 2803 | --- |
| 2804 | |
| 2805 | ### Task 10: `Teardown` disassociates and releases an address sender, and never deletes the identity |
| 2806 | |
| 2807 | **Files:** |
| 2808 | - Modify: `internal/mail/walk.go` (`Teardown` at `:1132`, before the `DeleteUser` calls at `:1139-1149`), `internal/mail/addressclaim.go`, `internal/mail/walk_address_test.go` |
| 2809 | |
| 2810 | **Interfaces:** |
| 2811 | - Consumes: `ReleaseAddressClaim`, `ExternalIdentityFor`, `AddressDomain`, `TenantName`, plan 2's `DisassociateTenantResource` and `(r *Record) sender()`. |
| 2812 | - Produces: no new exported name. |
| 2813 | ```go |
| 2814 | func accountHoldsAddressUnder(ctx context.Context, s release.Store, domain, acct, exceptApp, exceptSender string) (bool, error) // unexported |
| 2815 | func teardownAddressSender(ctx context.Context, s release.Store, p Provisioner, acct, app string, sn *Sender) error // unexported |
| 2816 | ``` |
| 2817 | |
| 2818 | - [ ] **Step 1: Write the failing test** |
| 2819 | |
| 2820 | `internal/mail/walk_address_test.go` (append): |
| 2821 | |
| 2822 | ```go |
| 2823 | // TEARDOWN NEVER DELETES THE IDENTITY, and it disassociates BEFORE anything |
| 2824 | // else (§4.1, answer 3). The association is the cross-team contract: while it |
| 2825 | // stands, tito.io's owner cannot delete their own identity. So it goes first, |
| 2826 | // before the claim and before the IAM user, and it goes even if the rest of |
| 2827 | // the teardown then fails — a crash after it leaves the owner unblocked, |
| 2828 | // which is the failure mode worth choosing. |
| 2829 | func TestTeardownReleasesTheAssociationAndTheClaimAndKeepsTheIdentity(t *testing.T) { |
| 2830 | ctx, s, p, pub, now := addressEnableFixture(t) |
| 2831 | if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now); err != nil { |
| 2832 | t.Fatal(err) |
| 2833 | } |
| 2834 | if err := Teardown(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "jam", "jelly"); err != nil { |
| 2835 | t.Fatalf("Teardown: %v", err) |
| 2836 | } |
| 2837 | if _, ok := p.Identities[identityKey("eu-west-1", "tito.io")]; !ok { |
| 2838 | t.Fatal("Teardown deleted an identity this deployment does not own") |
| 2839 | } |
| 2840 | if p.TenantResources["carlos-jam"]["arn:aws:ses:eu-west-1:1:identity/tito.io"] { |
| 2841 | t.Fatal("the tenant association survived teardown, so Classic still cannot delete its own identity") |
| 2842 | } |
| 2843 | // The account's own configuration set stays associated: it is CARLOS's, |
| 2844 | // it is shared with every other sender in this account, and it was never |
| 2845 | // the borrowed thing. |
| 2846 | if !p.TenantResources["carlos-jam"]["arn:aws:ses:eu-west-1:1:configuration-set/carlos-jam"] { |
| 2847 | t.Fatal("teardown disassociated the account's own configuration set") |
| 2848 | } |
| 2849 | if claim, err := GetAddressClaim(ctx, s, "jelly@tito.io", "jam"); err != nil || claim != nil { |
| 2850 | t.Fatalf("the address claim survived teardown: %+v %v", claim, err) |
| 2851 | } |
| 2852 | if _, ok := p.Users["carlos-mail-jam-jelly"]; ok { |
| 2853 | t.Fatal("the sending principal survived teardown") |
| 2854 | } |
| 2855 | } |
| 2856 | |
| 2857 | // THE ASSOCIATION IS PER TENANT, AND THE TENANT IS PER ACCOUNT. Two apps in |
| 2858 | // one account may both send under tito.io, and they share carlos-jam. Tearing |
| 2859 | // one down must not take the identity association away from the other, which |
| 2860 | // would 554 every message the survivor sends from the moment it happened. |
| 2861 | func TestTeardownKeepsTheAssociationWhileASiblingAppStillHoldsAClaim(t *testing.T) { |
| 2862 | ctx, s, p, pub, now := addressEnableFixture(t) |
| 2863 | if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now); err != nil { |
| 2864 | t.Fatal(err) |
| 2865 | } |
| 2866 | if _, err := ClaimAddressSender(ctx, s, "jam", "jam-billing", SenderAppID, |
| 2867 | "receipts@tito.io", "BILLING_SMTP", "owner@example.test", now); err != nil { |
| 2868 | t.Fatal(err) |
| 2869 | } |
| 2870 | if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jam-billing", now); err != nil { |
| 2871 | t.Fatal(err) |
| 2872 | } |
| 2873 | if err := Teardown(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "jam", "jelly"); err != nil { |
| 2874 | t.Fatal(err) |
| 2875 | } |
| 2876 | if !p.TenantResources["carlos-jam"]["arn:aws:ses:eu-west-1:1:identity/tito.io"] { |
| 2877 | t.Fatal("tearing one app down cut the sibling's identity association") |
| 2878 | } |
| 2879 | // The departing app's own claim is gone; the sibling's is not. |
| 2880 | if claim, _ := GetAddressClaim(ctx, s, "jelly@tito.io", "jam"); claim != nil { |
| 2881 | t.Fatalf("the departing app's claim survived: %+v", claim) |
| 2882 | } |
| 2883 | if claim, _ := GetAddressClaim(ctx, s, "receipts@tito.io", "jam"); claim == nil { |
| 2884 | t.Fatal("the sibling's claim was released by someone else's teardown") |
| 2885 | } |
| 2886 | // And now the sibling goes: the last claim in the account releases the |
| 2887 | // association. |
| 2888 | if err := Teardown(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "jam", "jam-billing"); err != nil { |
| 2889 | t.Fatal(err) |
| 2890 | } |
| 2891 | if p.TenantResources["carlos-jam"]["arn:aws:ses:eu-west-1:1:identity/tito.io"] { |
| 2892 | t.Fatal("the last sender's teardown left the association standing") |
| 2893 | } |
| 2894 | } |
| 2895 | |
| 2896 | // A deregistration between enable and teardown must not strand either the |
| 2897 | // association or the claim: the sender record carries the identity ARN it was |
| 2898 | // built with, so teardown needs the registration only to scope the sibling |
| 2899 | // question. |
| 2900 | func TestTeardownOfAnAddressSenderSurvivesDeregistration(t *testing.T) { |
| 2901 | ctx, s, p, pub, now := addressEnableFixture(t) |
| 2902 | if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now); err != nil { |
| 2903 | t.Fatal(err) |
| 2904 | } |
| 2905 | if err := DeleteExternalIdentity(ctx, s, "tito.io"); err != nil { |
| 2906 | t.Fatal(err) |
| 2907 | } |
| 2908 | if err := Teardown(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "jam", "jelly"); err != nil { |
| 2909 | t.Fatalf("Teardown after deregistration: %v", err) |
| 2910 | } |
| 2911 | if p.TenantResources["carlos-jam"]["arn:aws:ses:eu-west-1:1:identity/tito.io"] { |
| 2912 | t.Fatal("a deregistered identity was left associated, blocking its owner for ever") |
| 2913 | } |
| 2914 | if claim, _ := GetAddressClaim(ctx, s, "jelly@tito.io", "jam"); claim != nil { |
| 2915 | t.Fatalf("the claim survived: %+v", claim) |
| 2916 | } |
| 2917 | if _, ok := p.Identities[identityKey("eu-west-1", "tito.io")]; !ok { |
| 2918 | t.Fatal("teardown deleted an identity this deployment never owned") |
| 2919 | } |
| 2920 | } |
| 2921 | ``` |
| 2922 | |
| 2923 | Add `"github.com/carlosframework/platform/internal/dnsrec"` to this test file's imports. |
| 2924 | |
| 2925 | - [ ] **Step 2: Run it to verify it fails** |
| 2926 | |
| 2927 | ```bash |
| 2928 | go test -run 'TestTeardownReleasesTheAssociation|TestTeardownKeepsTheAssociation|TestTeardownOfAnAddressSender' ./internal/mail/ |
| 2929 | ``` |
| 2930 | |
| 2931 | Expected: the association survives and the claim survives — both assertions fail. |
| 2932 | |
| 2933 | - [ ] **Step 3: Write the code** |
| 2934 | |
| 2935 | `internal/mail/addressclaim.go` (append): |
| 2936 | |
| 2937 | ```go |
| 2938 | // accountHoldsAddressUnder answers the one question teardown has to ask |
| 2939 | // before it disassociates: does anything ELSE in this account still send |
| 2940 | // under this identity. |
| 2941 | // |
| 2942 | // It has to be asked because the tenant is per ACCOUNT (§5) and the |
| 2943 | // association hangs off the tenant, not off the sender. Two apps in one |
| 2944 | // account may both send under tito.io, and cutting the association when the |
| 2945 | // first one leaves would 554 every message the second one sends from that |
| 2946 | // moment on. |
| 2947 | // |
| 2948 | // The match is LABEL-AWARE, the same correction ExternalIdentityFor's walk |
| 2949 | // carries: a claim on jelly@eviltito.io is a different zone entirely and must |
| 2950 | // not hold tito.io's association open. |
| 2951 | func accountHoldsAddressUnder(ctx context.Context, s release.Store, domain, acct, exceptApp, exceptSender string) (bool, error) { |
| 2952 | keys, err := s.List(ctx, addressClaimsPrefix) |
| 2953 | if err != nil { |
| 2954 | return false, fmt.Errorf("mail: list address claims: %w", err) |
| 2955 | } |
| 2956 | for _, key := range keys { |
| 2957 | raw, _, err := s.Get(ctx, key) |
| 2958 | if err != nil { |
| 2959 | // Loud, not skipped. A read failure here would otherwise read as |
| 2960 | // "no sibling", and the association would be cut out from under |
| 2961 | // an app that is still sending. |
| 2962 | return false, fmt.Errorf("mail: address claim %s: %w", key, err) |
| 2963 | } |
| 2964 | var c AddressClaim |
| 2965 | if err := json.Unmarshal(raw, &c); err != nil { |
| 2966 | log.Printf("mail: address claim %s: unparseable -> %v", key, err) |
| 2967 | continue |
| 2968 | } |
| 2969 | if c.Account != acct || (c.App == exceptApp && c.Sender == exceptSender) { |
| 2970 | continue |
| 2971 | } |
| 2972 | if d := AddressDomain(c.Address); d == domain || strings.HasSuffix(d, "."+domain) { |
| 2973 | return true, nil |
| 2974 | } |
| 2975 | } |
| 2976 | return false, nil |
| 2977 | } |
| 2978 | ``` |
| 2979 | |
| 2980 | `internal/mail/walk.go`, `teardownAddressSender`, above `Teardown`: |
| 2981 | |
| 2982 | ```go |
| 2983 | // teardownAddressSender is everything Teardown does for a sender under an |
| 2984 | // identity CARLOS does not own, and it is defined as much by what it does not |
| 2985 | // do: it never calls DeleteIdentity. CARLOS created nothing there. The |
| 2986 | // ordinary teardown path destroys the identity because CARLOS made it; here |
| 2987 | // that same call would destroy another product's mail. |
| 2988 | // |
| 2989 | // DISASSOCIATE FIRST (§4.1, answer 3). While the association stands, the |
| 2990 | // identity's owner cannot delete their own identity — that is the cross-team |
| 2991 | // contract the registration step states — so releasing it is the obligation |
| 2992 | // this function owes somebody outside CARLOS, and it goes before the two |
| 2993 | // things it owes CARLOS. A crash after it leaves a claim and an IAM user for |
| 2994 | // a retry to clear; a crash before it would leave the owner blocked with |
| 2995 | // nothing left in the system to point at, because the record is about to go. |
| 2996 | // |
| 2997 | // The identity ARN comes from the SENDER, not from the registration: a |
| 2998 | // deregistration between enable and teardown must not strand the association. |
| 2999 | // The registration is consulted only to scope the sibling question to the |
| 3000 | // whole identity rather than to one address's domain. |
| 3001 | func teardownAddressSender(ctx context.Context, s release.Store, p Provisioner, acct, app string, sn *Sender) error { |
| 3002 | domain := AddressDomain(sn.Address) |
| 3003 | identity := sn.Identity |
| 3004 | ext, err := ExternalIdentityFor(ctx, s, domain) |
| 3005 | if err != nil { |
| 3006 | return err |
| 3007 | } |
| 3008 | if ext != nil { |
| 3009 | domain = ext.Domain |
| 3010 | if identity == "" { |
| 3011 | identity = ext.IdentityARN |
| 3012 | } |
| 3013 | } |
| 3014 | if identity != "" { |
| 3015 | held, err := accountHoldsAddressUnder(ctx, s, domain, acct, app, sn.ID) |
| 3016 | if err != nil { |
| 3017 | return err |
| 3018 | } |
| 3019 | if !held { |
| 3020 | tenant := TenantName(acct) |
| 3021 | if err := p.DisassociateTenantResource(ctx, tenant, identity); err != nil { |
| 3022 | return fmt.Errorf("mail: disassociate %s from tenant %s: %w", identity, tenant, err) |
| 3023 | } |
| 3024 | } |
| 3025 | // The account's own configuration set stays associated whatever |
| 3026 | // happens here. It is CARLOS's, every other sender in the account |
| 3027 | // shares it, and it was never the borrowed thing. |
| 3028 | } |
| 3029 | return ReleaseAddressClaim(ctx, s, sn.Address, acct, app, sn.ID) |
| 3030 | } |
| 3031 | ``` |
| 3032 | |
| 3033 | `internal/mail/walk.go`, `Teardown`, inside the `if rec != nil` block at `:1139` and **before** the `DeleteUser` calls: |
| 3034 | |
| 3035 | ```go |
| 3036 | // Before the principal goes, because everything this needs is read |
| 3037 | // off the record and the record's own delete is the last thing this |
| 3038 | // function does. |
| 3039 | if sn := rec.sender(); sn.Source == SourceAddress { |
| 3040 | if err := teardownAddressSender(ctx, s, p, acct, app, sn); err != nil { |
| 3041 | return err |
| 3042 | } |
| 3043 | } |
| 3044 | ``` |
| 3045 | |
| 3046 | The domain loop below needs no change: an address sender has no `DomainRecord` in the account by design, so `ListDomains(acct)` never names tito.io and `DeleteIdentity` is never reached for it. The orphan-claim sweep is already guarded (task 3). |
| 3047 | |
| 3048 | - [ ] **Step 4: Run it to verify it passes** |
| 3049 | |
| 3050 | ```bash |
| 3051 | go test ./internal/mail/ -count=1 |
| 3052 | ``` |
| 3053 | |
| 3054 | Expected: PASS, including every existing teardown test unchanged. |
| 3055 | |
| 3056 | - [ ] **Step 5: Gate and commit** |
| 3057 | |
| 3058 | ```bash |
| 3059 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 3060 | git add internal/mail |
| 3061 | git commit -m "mail: teardown disassociates and releases an address sender, and never deletes the identity (plan 3, task 10)" |
| 3062 | ``` |
| 3063 | |
| 3064 | --- |
| 3065 | |
| 3066 | ### Task 11: `svcrole.Mail`, and the service write budget behind the email doors |
| 3067 | |
| 3068 | **Files:** |
| 3069 | - Modify: `internal/svcrole/svcrole.go` (the const block at `:30-56`, `catalogue` at `:59`), `internal/console/apimail.go` (`mailWriteGates` at `:136`, its ten call sites, `registerEmailRoutes` at `:501` and the role table above it at `:448-500`), `internal/console/svcroutes_test.go`, `services.go` (`servicesUsage` at `:33`, the `--role` flag's usage at `:157`, `usageMsg` at `:164`) |
| 3070 | - Create: `internal/svcrole/mail_role_test.go` |
| 3071 | |
| 3072 | **The catalogue is spelled out by hand in three places in the root CLI, and they are the reason `services.go` is on this list.** `svcrole.Parse` (`services.go:174`) accepts anything in the catalogue, and the refusal beside it already lists `roleNames()`, which reads `svcrole.All()` — so the moment `Mail` joins, `carlos services create --role mail` works while all three strings a person reads say the role does not exist. Each becomes `<publish|instance|operate|service|mail|admin>` (`:33`, `:164`) and `publish, instance, operate, service, mail or admin` (`:157`). They are member-facing, so they go through the `copy-review` gate the same way tasks 14 and 15's strings do, before they land. |
| 3073 | |
| 3074 | **Interfaces:** |
| 3075 | - Consumes: `svcrole.Allow` (`svcrole.go:101`), `principalFrom` (`authcli.go:385`), `allowWrite` (`svcprincipal.go:394`), `(s *Server) doorsFor` (`apiservices.go:132`). |
| 3076 | - Produces: |
| 3077 | ```go |
| 3078 | const Mail Role = "mail" // internal/svcrole |
| 3079 | func (s *Server) mailWriteGates(w http.ResponseWriter, r *http.Request) bool // was (w, addr string) |
| 3080 | ``` |
| 3081 | |
| 3082 | - [ ] **Step 1: Write the failing test** |
| 3083 | |
| 3084 | `internal/svcrole/mail_role_test.go` (create), the shape `service_role_test.go` already has: |
| 3085 | |
| 3086 | ```go |
| 3087 | package svcrole |
| 3088 | |
| 3089 | import "testing" |
| 3090 | |
| 3091 | // Mail is mintable the moment it is in the catalogue: the mint door, the |
| 3092 | // browser mint form and doorsFor all read All(), so nothing else has to |
| 3093 | // change for an account owner to issue one (service_role_test.go's own |
| 3094 | // observation, and the reason the catalogue is the single source). |
| 3095 | func TestMailRoleIsMintable(t *testing.T) { |
| 3096 | got, ok := Parse("mail") |
| 3097 | if !ok || got != Mail { |
| 3098 | t.Fatalf("Parse(\"mail\") = %q, %v", got, ok) |
| 3099 | } |
| 3100 | var found bool |
| 3101 | for _, r := range All() { |
| 3102 | if r == Mail { |
| 3103 | found = true |
| 3104 | } |
| 3105 | } |
| 3106 | if !found { |
| 3107 | t.Fatalf("Mail is not in All(): %v", All()) |
| 3108 | } |
| 3109 | } |
| 3110 | |
| 3111 | // A Mail door admits Mail — and Admin, by construction (Allow adds it to |
| 3112 | // every set, svcrole.go:120). That is what makes this change safe to land |
| 3113 | // without touching a single existing credential: today's Admin-role service |
| 3114 | // principals keep reaching every email door they reach now. |
| 3115 | func TestMailDoorAdmitsMailAndAdmin(t *testing.T) { |
| 3116 | s := Allow(Mail) |
| 3117 | if !s.Permits(Mail) || !s.Permits(Admin) { |
| 3118 | t.Fatalf("a Mail door permits %v", s.Roles()) |
| 3119 | } |
| 3120 | for _, r := range []Role{Publish, Instance, Operate, Service} { |
| 3121 | if s.Permits(r) { |
| 3122 | t.Fatalf("a Mail door admits %q", r) |
| 3123 | } |
| 3124 | } |
| 3125 | } |
| 3126 | |
| 3127 | // The mirror, and the whole point of the role: a Mail credential onboards |
| 3128 | // customer domains and does nothing else. It must not gain a deploy, an |
| 3129 | // instance verb, or the platform's runtime services. |
| 3130 | func TestMailReachesNoOtherRolesDoors(t *testing.T) { |
| 3131 | for _, door := range []Set{Allow(Publish), Allow(Instance), Allow(Operate), Allow(Service)} { |
| 3132 | if door.Permits(Mail) { |
| 3133 | t.Fatalf("%v admits Mail", door.Roles()) |
| 3134 | } |
| 3135 | } |
| 3136 | } |
| 3137 | ``` |
| 3138 | |
| 3139 | `internal/console/svcroutes_test.go` (append), the pin §6.1 asks for: |
| 3140 | |
| 3141 | ```go |
| 3142 | // TestMailRoleReachesExactlyTheEmailDoors is the new role's door list, |
| 3143 | // spelled out rather than derived, for closedDoors' own reason: a door that |
| 3144 | // joined the family by accident and one that left it by accident pull in |
| 3145 | // opposite directions, and only an equality catches both. |
| 3146 | // |
| 3147 | // The three closed email doors are deliberately absent. credentials |
| 3148 | // create/revoke hand a plaintext sending secret to their caller and pause is |
| 3149 | // the abuse brake — no role reaches either, and naming Mail on them would be |
| 3150 | // a registration that lies about who gets in. |
| 3151 | func TestMailRoleReachesExactlyTheEmailDoors(t *testing.T) { |
| 3152 | want := []string{ |
| 3153 | "POST /api/cli/apps/{account}/{app}/email", |
| 3154 | "GET /api/cli/apps/{account}/{app}/email", |
| 3155 | "GET /api/cli/apps/{account}/{app}/email/events", |
| 3156 | "GET /api/cli/apps/{account}/{app}/email/events/push", |
| 3157 | "PUT /api/cli/apps/{account}/{app}/email/events/push", |
| 3158 | "DELETE /api/cli/apps/{account}/{app}/email/events/push", |
| 3159 | "POST /api/cli/apps/{account}/{app}/email/domains", |
| 3160 | "POST /api/cli/apps/{account}/{app}/email/domains/{domain}/verify", |
| 3161 | "POST /api/cli/apps/{account}/{app}/email/enable", |
| 3162 | "POST /api/cli/apps/{account}/{app}/email/rotate", |
| 3163 | } |
| 3164 | sort.Strings(want) |
| 3165 | got := newConsoleFixture(t).srv.doorsFor(svcrole.Mail) |
| 3166 | if !reflect.DeepEqual(got, want) { |
| 3167 | t.Errorf("doorsFor(mail):\n got %q\nwant %q\n"+ |
| 3168 | "A door that joined this family by accident is a widening; one that left it is a hub that cannot onboard.", |
| 3169 | got, want) |
| 3170 | } |
| 3171 | // Every one of them must still be reachable by admin, because Allow adds |
| 3172 | // Admin to every set and today's credentials hold exactly that. |
| 3173 | admin := map[string]bool{} |
| 3174 | for _, d := range newConsoleFixture(t).srv.doorsFor(svcrole.Admin) { |
| 3175 | admin[d] = true |
| 3176 | } |
| 3177 | for _, d := range want { |
| 3178 | if !admin[d] { |
| 3179 | t.Errorf("%s stopped being reachable by admin; every existing email credential just broke", d) |
| 3180 | } |
| 3181 | } |
| 3182 | } |
| 3183 | ``` |
| 3184 | |
| 3185 | `internal/console/apimail_test.go` (append), the budget half: |
| 3186 | |
| 3187 | ```go |
| 3188 | // §6.1's rate-budget correction. mailWriteGates keyed the PERSON's limiter on |
| 3189 | // the audit string, so a hub bulk-onboarding customers drew down a budget |
| 3190 | // sized for someone typing commands. The write gate now picks the limiter |
| 3191 | // from the principal, which is what every other write door in the package |
| 3192 | // already does (apiscan.go:235). |
| 3193 | func TestMailWriteGatesSpendsTheServiceBudgetForAServicePrincipal(t *testing.T) { |
| 3194 | fx := newConsoleFixture(t) |
| 3195 | tok := serviceToken(t, fx.st, OpsAccountID, "hub", "jelly", svcrole.Mail) |
| 3196 | |
| 3197 | // The two budgets differ by four times (cliWriteBurst 30, |
| 3198 | // svcWriteBurst 120), which is what makes this observable from outside |
| 3199 | // without reaching into a limiter: a hub held to the person's budget |
| 3200 | // stops on the thirty-first declare. |
| 3201 | for i := 0; i < cliWriteBurst+2; i++ { |
| 3202 | if code := declareEmailAs(t, fx, tok); code == http.StatusTooManyRequests { |
| 3203 | t.Fatalf("declare %d of %d was refused: a service credential is drawing down the person-sized budget", |
| 3204 | i+1, cliWriteBurst+2) |
| 3205 | } |
| 3206 | } |
| 3207 | } |
| 3208 | ``` |
| 3209 | |
| 3210 | `declareEmailAs` is whatever the package's existing email API tests already use to POST `.../email` with a bearer, returning the status code; add it beside them if there is no such helper yet. The assertion that matters is behavioural — `s.cli.write` is untouched and `s.cli.svcWrite` is the one that moves — and it is made with no access to a limiter's internals. |
| 3211 | |
| 3212 | - [ ] **Step 2: Run it to verify it fails** |
| 3213 | |
| 3214 | ```bash |
| 3215 | go test ./internal/svcrole/ && go test -run 'TestMailRole|TestMailWriteGates' ./internal/console/ |
| 3216 | ``` |
| 3217 | |
| 3218 | Expected: build failure, `undefined: svcrole.Mail`. |
| 3219 | |
| 3220 | - [ ] **Step 3: Write the code** |
| 3221 | |
| 3222 | `internal/svcrole/svcrole.go`, in the const block before `Admin`: |
| 3223 | |
| 3224 | ```go |
| 3225 | // Mail is the email family and nothing else: declare a sender, bring a |
| 3226 | // sending domain into existence and verify it, enable, rotate, read the |
| 3227 | // status and the events. |
| 3228 | // |
| 3229 | // It exists because the email doors were Admin (apimail.go's role table) |
| 3230 | // and Admin is the union — so a credential that could onboard a |
| 3231 | // customer's sending domain could also ship that app, move its |
| 3232 | // instances, and mint its object store. For a hub whose only job is |
| 3233 | // onboarding (senders design §6.1), that inverts the reason those doors |
| 3234 | // were made Admin in the first place: Admin was chosen to be NARROWER |
| 3235 | // than Publish, so a deploy credential could not acquire a sending |
| 3236 | // identity. This role is the other half of that thought — a sending |
| 3237 | // credential that cannot deploy. |
| 3238 | // |
| 3239 | // It does NOT reach credentials create/revoke or pause: those are shut |
| 3240 | // to every role, for reasons about the doors' subjects rather than about |
| 3241 | // any role (svcroutes_test.go's mintDoors and policyDoors). |
| 3242 | Mail Role = "mail" |
| 3243 | ``` |
| 3244 | |
| 3245 | and `catalogue`: |
| 3246 | |
| 3247 | ```go |
| 3248 | var catalogue = []Role{Publish, Instance, Operate, Service, Mail, Admin} |
| 3249 | ``` |
| 3250 | |
| 3251 | `internal/console/apimail.go`, `mailWriteGates` (`:136`): |
| 3252 | |
| 3253 | ```go |
| 3254 | // mailWriteGates is mailAvailable plus the shared write budget, picked from |
| 3255 | // the PRINCIPAL rather than keyed on the audit string. |
| 3256 | // |
| 3257 | // It used to key s.cli.write — the person's limiter — on the same address |
| 3258 | // every other gate here takes, and for a person that is still exactly what |
| 3259 | // happens (allowWrite's person branch keys on the address, svcprincipal.go). |
| 3260 | // For a SERVICE it was wrong in both directions: a hub bulk-onboarding |
| 3261 | // customers drew down a budget sized for someone typing commands, and it |
| 3262 | // drew it down under a key that reads like a person's (senders design §6.1). |
| 3263 | // allowWrite picks the right limiter from the principal, which is what every |
| 3264 | // other write door in this package already does. |
| 3265 | func (s *Server) mailWriteGates(w http.ResponseWriter, r *http.Request) bool { |
| 3266 | p, _ := principalFrom(r.Context()) |
| 3267 | if !s.allowWrite(p, time.Now()) { |
| 3268 | jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| 3269 | return false |
| 3270 | } |
| 3271 | return s.mailAvailable(w) |
| 3272 | } |
| 3273 | ``` |
| 3274 | |
| 3275 | Every call site becomes `if !s.mailWriteGates(w, r) {` — `:539`, `:799`, `:862`, `:910`, `:981`, `:1041`, `:1112`, `:1176`, `:1226` and `:1309`. Several of them then no longer read `addr` before the gate; leave `addr` where the handler still audits with it, and switch the one-use ones to `_` only where the compiler insists. The pause door (`:1309`) keeps its own `addr` from `cliUser` — its principal is a person by construction (`mailOperator` resolves an address), so the picked limiter is the same one it spends today. |
| 3276 | |
| 3277 | `internal/console/apimail.go`, `registerEmailRoutes` (`:501`): every `svcrole.Allow(svcrole.Admin)` becomes `svcrole.Allow(svcrole.Mail)`. The three `svcrole.Set{}` doors do not move. The role table above it (`:448-500`) is rewritten to say so, and its two stale counts are corrected while they are being touched — there are thirteen doors here, not nine, and ten of them are open, not six: |
| 3278 | |
| 3279 | ```go |
| 3280 | // registerEmailRoutes wires this file's thirteen doors onto s.mux, through |
| 3281 | // the role gate (cliRoute) like every other /api/cli/ door. |
| 3282 | // |
| 3283 | // THE ROLE TABLE, and why each door reads as it does. |
| 3284 | // |
| 3285 | // The ten OPEN doors take Allow(svcrole.Mail) — the email family and nothing |
| 3286 | // else. Because Allow adds Admin to every set (svcrole.go:120), every |
| 3287 | // credential that reaches them today still does; what changes is that a hub |
| 3288 | // which onboards customers can hold a credential that does this work and no |
| 3289 | // other (senders design §6.1). Admin remains the union, so the reasoning |
| 3290 | // that made these doors Admin rather than Publish is unchanged and is now |
| 3291 | // stated by the role's own name. |
| 3292 | // |
| 3293 | // ... (the existing per-door paragraphs are unchanged: GET .../email names |
| 3294 | // the estate, GET .../email/events names the recipients, the push doors |
| 3295 | // decide whether those addresses leave the console, and the three CLOSED |
| 3296 | // doors are closed for reasons about their subjects rather than any role.) |
| 3297 | ``` |
| 3298 | |
| 3299 | - [ ] **Step 4: Run it to verify it passes** |
| 3300 | |
| 3301 | ```bash |
| 3302 | go test ./internal/svcrole/ ./internal/console/ -count=1 |
| 3303 | ``` |
| 3304 | |
| 3305 | Expected: PASS, including `TestEveryCLIDoorNamesItsRoles` (`closedDoors` is unchanged: the three closed email doors stay closed) and `TestNoRoleReachesAMintFleetOrPolicyDoor` (which now iterates Mail too, and finds it reaching none of them). |
| 3306 | |
| 3307 | - [ ] **Step 5: Gate and commit** |
| 3308 | |
| 3309 | ```bash |
| 3310 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 3311 | git add internal/svcrole internal/console services.go |
| 3312 | git commit -m "console: svcrole.Mail, and the email write gate takes the service budget (plan 3, task 11)" |
| 3313 | ``` |
| 3314 | |
| 3315 | --- |
| 3316 | |
| 3317 | ### Task 12: `apiexternal.go`, the operator's five doors |
| 3318 | |
| 3319 | **Files:** |
| 3320 | - Create: `internal/console/apiexternal.go`, `internal/console/apiexternal_test.go` |
| 3321 | - Modify: `internal/mail/addressclaim.go` (`ListIdentityClaims`, and `accountHoldsAddressUnder` folded onto it), `internal/console/apimail.go` (`registerEmailRoutes` at `:501`), `internal/console/svcroutes_test.go` (`policyDoors` at `:77`, `closedDoors` at `:91`) |
| 3322 | |
| 3323 | **Interfaces:** |
| 3324 | - Consumes: `cliUser`, `store.IsOperator`, `mailOperator` (`apimail.go:190`), `AccountBySqid` (the viewer-independent lookup `mailPauseTarget` uses, `apimail.go:215`), `allowWrite` (`svcprincipal.go:394`), `auditSystem` (`apisystemupdate.go:134`), `mail.PutExternalIdentity`, `GetExternalIdentity`, `DeleteExternalIdentity`, `ListExternalIdentities`, `GrantAddress`. |
| 3325 | - Produces: |
| 3326 | ```go |
| 3327 | // internal/mail |
| 3328 | func ListIdentityClaims(ctx context.Context, s release.Store, domain string) ([]*AddressClaim, error) |
| 3329 | |
| 3330 | // internal/console |
| 3331 | func (s *Server) mailExternalOperator(w http.ResponseWriter, r *http.Request) (string, bool) |
| 3332 | func (s *Server) handleAPIExternalRegister(w http.ResponseWriter, r *http.Request) |
| 3333 | func (s *Server) handleAPIExternalDeregister(w http.ResponseWriter, r *http.Request) |
| 3334 | func (s *Server) handleAPIExternalList(w http.ResponseWriter, r *http.Request) |
| 3335 | func (s *Server) handleAPIExternalClaims(w http.ResponseWriter, r *http.Request) |
| 3336 | func (s *Server) handleAPIExternalGrant(w http.ResponseWriter, r *http.Request) |
| 3337 | ``` |
| 3338 | |
| 3339 | ``` |
| 3340 | PUT /api/cli/email/external-identities/{domain} register |
| 3341 | DELETE /api/cli/email/external-identities/{domain} deregister |
| 3342 | GET /api/cli/email/external-identities list |
| 3343 | GET /api/cli/email/external-identities/{domain}/claims roster |
| 3344 | POST /api/cli/email/external-identities/{domain}/claims grant |
| 3345 | ``` |
| 3346 | |
| 3347 | - [ ] **Step 1: Write the failing test** |
| 3348 | |
| 3349 | `internal/console/apiexternal_test.go` (create): |
| 3350 | |
| 3351 | ```go |
| 3352 | package console |
| 3353 | |
| 3354 | import ( |
| 3355 | "net/http" |
| 3356 | "testing" |
| 3357 | ) |
| 3358 | |
| 3359 | // THE REFUSAL IS THE SAME 404 THE PAUSE DOOR GIVES, and for the same reason |
| 3360 | // (apimail.go's file comment): a 403 here would tell a prober "this route |
| 3361 | // exists and you lack authority for it", which is the leak the byte-identical |
| 3362 | // 404 exists to prevent everywhere else in this family. A signed-in member, a |
| 3363 | // fleet operator who is not on CARLOS_MAIL_OPERATORS, and a service |
| 3364 | // credential of every role must all read the same. |
| 3365 | func TestExternalIdentityDoorsRefuseEveryoneButAMailOperator(t *testing.T) { |
| 3366 | // member token, operator-not-mail-operator token, and a service |
| 3367 | // credential holding svcrole.Mail: every door, every method, 404 or the |
| 3368 | // cliRoute 403 a closed door gives a service principal. |
| 3369 | // Registration with a valid body must not have happened either — assert |
| 3370 | // the bucket is still empty afterwards. |
| 3371 | } |
| 3372 | |
| 3373 | // Registering is the operator's attestation that this deployment does NOT own |
| 3374 | // the identity. The record is validated the same way mail.PutExternalIdentity |
| 3375 | // validates it (the ARN must name this domain in this region), so the |
| 3376 | // operator hears about a typo while they are still typing. |
| 3377 | func TestExternalIdentityRegisterWritesTheRecordAndIsIdempotent(t *testing.T) { |
| 3378 | // PUT with {region, identity_arn, open, note} -> 200, the record reads |
| 3379 | // back, By is the operator's address, At is set. |
| 3380 | // A second PUT with a changed Open flips it rather than 409ing: the door |
| 3381 | // reads the current etag and writes at it, so re-registering is how an |
| 3382 | // identity is opened or closed. |
| 3383 | // A PUT naming a different region's ARN is a 400 that names the mismatch. |
| 3384 | } |
| 3385 | |
| 3386 | // Deregistering is refused while any account still holds a claim under the |
| 3387 | // identity. Without that, deregistration would silently remove the only |
| 3388 | // record saying "CARLOS must not touch tito.io" while senders are still |
| 3389 | // sending under it — and the NEXT EnsureDomain on that name would sail |
| 3390 | // straight through the guard and adopt somebody else's identity. |
| 3391 | func TestExternalIdentityDeregisterRefusesWhileClaimsStand(t *testing.T) { |
| 3392 | // claim jelly@tito.io for one account -> DELETE is a 409 naming the |
| 3393 | // count (never the accounts: this reply goes to an operator, but the |
| 3394 | // roster door is where that belongs). |
| 3395 | // release the claim -> DELETE is a 200, and a second DELETE is a 200 |
| 3396 | // too, because a retried deregistration is not an error. |
| 3397 | } |
| 3398 | |
| 3399 | // The roster is the whole identity's, not one address's: an operator asking |
| 3400 | // "who sends as anything under tito.io" is asking one question. |
| 3401 | func TestExternalIdentityClaimsListsEveryAddressUnderTheIdentity(t *testing.T) { |
| 3402 | // claims on jelly@tito.io (two accounts) and receipts@mail.tito.io (one) |
| 3403 | // all appear, sorted by address then account, each naming account, app, |
| 3404 | // sender, whether it was a grant, and who made it. |
| 3405 | } |
| 3406 | |
| 3407 | // The grant is the recourse when Open is false, and it writes the same record |
| 3408 | // the member's own claim would. The member's claim then succeeds against it, |
| 3409 | // which is what keeps the two paths one record rather than two code paths |
| 3410 | // that could disagree. |
| 3411 | func TestExternalIdentityGrantOpensOneAppOnAClosedIdentity(t *testing.T) { |
| 3412 | // register closed -> POST {address, account, app, sender} -> 200, the |
| 3413 | // claim reads back with grant=true and By = the operator. |
| 3414 | // An unknown account is the family's byte-identical 404. |
| 3415 | // An address whose domain is not under this identity is a 400. |
| 3416 | } |
| 3417 | ``` |
| 3418 | |
| 3419 | Fill each body in the shape `apimail_test.go`'s operator tests already use (the fixture, the operator allowlist, `serviceToken`), asserting status codes and the bucket's own state rather than reply prose. |
| 3420 | |
| 3421 | - [ ] **Step 2: Run it to verify it fails** |
| 3422 | |
| 3423 | ```bash |
| 3424 | go test -run TestExternalIdentity ./internal/console/ |
| 3425 | ``` |
| 3426 | |
| 3427 | Expected: failures at every door — the routes do not exist, so each call is a 404 for the wrong reason. |
| 3428 | |
| 3429 | - [ ] **Step 3: Write the code** |
| 3430 | |
| 3431 | `internal/mail/addressclaim.go` (append), the listing both the roster door and teardown need: |
| 3432 | |
| 3433 | ```go |
| 3434 | // ListIdentityClaims is every claim under one registered identity, whatever |
| 3435 | // the address: an operator asking "who sends as anything under tito.io" is |
| 3436 | // asking one question, and mail.tito.io is part of the answer. |
| 3437 | // |
| 3438 | // Sorted by address, then by account, so a roster reads the same way twice |
| 3439 | // running. An unparseable object is logged and skipped, the discipline every |
| 3440 | // listing in this package applies. |
| 3441 | func ListIdentityClaims(ctx context.Context, s release.Store, domain string) ([]*AddressClaim, error) { |
| 3442 | if !ValidDomain(domain) { |
| 3443 | return nil, fmt.Errorf("mail: invalid domain %q", domain) |
| 3444 | } |
| 3445 | keys, err := s.List(ctx, addressClaimsPrefix) |
| 3446 | if err != nil { |
| 3447 | return nil, fmt.Errorf("mail: list address claims: %w", err) |
| 3448 | } |
| 3449 | out := make([]*AddressClaim, 0, len(keys)) |
| 3450 | for _, key := range keys { |
| 3451 | raw, _, err := s.Get(ctx, key) |
| 3452 | if err != nil { |
| 3453 | // Loud, not skipped, unlike the per-address listing: this answer |
| 3454 | // decides whether an identity may be deregistered, and a read |
| 3455 | // failure that read as "no claims" would take the guard away |
| 3456 | // from an identity that is still in use. |
| 3457 | return nil, fmt.Errorf("mail: address claim %s: %w", key, err) |
| 3458 | } |
| 3459 | var c AddressClaim |
| 3460 | if err := json.Unmarshal(raw, &c); err != nil { |
| 3461 | log.Printf("mail: address claim %s: unparseable -> %v", key, err) |
| 3462 | continue |
| 3463 | } |
| 3464 | if c.Schema > Schema || AddressClaimKey(c.Address, c.Account) != key { |
| 3465 | continue |
| 3466 | } |
| 3467 | // Label-aware, the same correction ExternalIdentityFor's walk |
| 3468 | // carries: jelly@eviltito.io is a different zone entirely. |
| 3469 | if d := AddressDomain(c.Address); d == domain || strings.HasSuffix(d, "."+domain) { |
| 3470 | out = append(out, &c) |
| 3471 | } |
| 3472 | } |
| 3473 | sort.Slice(out, func(i, j int) bool { |
| 3474 | if out[i].Address != out[j].Address { |
| 3475 | return out[i].Address < out[j].Address |
| 3476 | } |
| 3477 | return out[i].Account < out[j].Account |
| 3478 | }) |
| 3479 | return out, nil |
| 3480 | } |
| 3481 | ``` |
| 3482 | |
| 3483 | `accountHoldsAddressUnder` (task 10) becomes a filter over this rather than a second walk of the same prefix — one function owns the label-aware match, so the two cannot drift about what "under tito.io" means: |
| 3484 | |
| 3485 | ```go |
| 3486 | func accountHoldsAddressUnder(ctx context.Context, s release.Store, domain, acct, exceptApp, exceptSender string) (bool, error) { |
| 3487 | claims, err := ListIdentityClaims(ctx, s, domain) |
| 3488 | if err != nil { |
| 3489 | return false, err |
| 3490 | } |
| 3491 | for _, c := range claims { |
| 3492 | if c.Account == acct && !(c.App == exceptApp && c.Sender == exceptSender) { |
| 3493 | return true, nil |
| 3494 | } |
| 3495 | } |
| 3496 | return false, nil |
| 3497 | } |
| 3498 | ``` |
| 3499 | |
| 3500 | `internal/console/apiexternal.go` (create): |
| 3501 | |
| 3502 | ```go |
| 3503 | // The operator's external-identity doors (senders design §7): registering an |
| 3504 | // SES identity this deployment does NOT own, so that accounts may send as |
| 3505 | // single addresses under it. |
| 3506 | // |
| 3507 | // EVERY DOOR HERE IS AN OPERATOR'S, and none of them is apiAccountApp's |
| 3508 | // shape. Registration is a statement about the whole deployment — "CARLOS |
| 3509 | // must never write this identity" — and there is no {account} it could be |
| 3510 | // scoped to. The grant is about one account, but it is authority being handed |
| 3511 | // out rather than a member's own verb, and the account it names is resolved |
| 3512 | // WITHOUT requiring the operator to be a member of it (AccountBySqid, the |
| 3513 | // viewer-independent lookup handleAPIEmailPause uses for the same reason). |
| 3514 | // |
| 3515 | // The gate is handleAPIEmailPause's, exactly: cliUser -> store.IsOperator -> |
| 3516 | // mailOperator -> a bare, byte-identical http.NotFound. Both refusals give |
| 3517 | // the SAME silent 404, never a speaking 403, for the reason that file's |
| 3518 | // comment records: a 403 would be an oracle telling a prober "this route |
| 3519 | // exists and you lack authority for it". |
| 3520 | // |
| 3521 | // Registered with svcrole.Set{} — closed to every role, admin included — |
| 3522 | // because they already resolve an ADDRESS against CARLOS_MAIL_OPERATORS, |
| 3523 | // which no service principal can ever hold. Naming a role would be a |
| 3524 | // registration that lies about who gets in. |
| 3525 | package console |
| 3526 | |
| 3527 | // mailExternalOperator is the gate. Identical in shape to |
| 3528 | // storeEntitlementOperator (apistore_entitlement.go:92) and to |
| 3529 | // handleAPIEmailPause's inline version, and deliberately a helper here |
| 3530 | // because five doors share it and five copies would drift. |
| 3531 | func (s *Server) mailExternalOperator(w http.ResponseWriter, r *http.Request) (string, bool) { |
| 3532 | addr, ok := s.cliUser(w, r) |
| 3533 | if !ok { |
| 3534 | return "", false |
| 3535 | } |
| 3536 | isOp, err := s.store.IsOperator(addr) |
| 3537 | if err != nil { |
| 3538 | log.Printf("console: api external identity: is-operator: %v", err) |
| 3539 | http.NotFound(w, r) |
| 3540 | return "", false |
| 3541 | } |
| 3542 | if !isOp || !s.mailOperator(addr) { |
| 3543 | http.NotFound(w, r) |
| 3544 | return "", false |
| 3545 | } |
| 3546 | return addr, true |
| 3547 | } |
| 3548 | ``` |
| 3549 | |
| 3550 | The five handlers, each: gate, then `s.allowWrite(Principal{Address: addr}, time.Now())` for the three that write, then `s.mailAvailable(w)`, then the work. |
| 3551 | |
| 3552 | - **register** (`PUT`) reads `{region, identity_arn, open, note}`, defaults `region` to `s.cfg.MailDeployment.Region` (`apimail.go:1002` reads it the same way), reads the current record for its etag so a re-registration EDITS rather than 409s (that is how `Open` is flipped), and hands the rest to `mail.PutExternalIdentity`, whose own validation is the one that refuses an ARN disagreeing with the domain. `auditSystem(addr, "email-external-register", domain+" -> "+arn)`. This is the deployment-wide act, so it audits against the ops account like every other one. |
| 3553 | |
| 3554 | It also refuses a region that is not this deployment's, with a 400, rather than leaving that to enable: |
| 3555 | |
| 3556 | ```go |
| 3557 | if region != s.cfg.MailDeployment.Region { |
| 3558 | jsonError(w, http.StatusBadRequest, fmt.Sprintf( |
| 3559 | "this deployment sends from %s. an identity in %s cannot be associated with a tenant here, because tenants and identities are both per region", |
| 3560 | s.cfg.MailDeployment.Region, region)) |
| 3561 | return |
| 3562 | } |
| 3563 | ``` |
| 3564 | |
| 3565 | §7 is explicit that "the region must match the deployment's", and without this the mismatch is caught by `ensureAddressPrincipal` (task 9) instead: an operator registers, a member claims an address, and the first enable is what finally says the registration was never usable. Refusing at the registration puts the refusal in front of the person who can fix it, while they are still typing. |
| 3566 | - **deregister** (`DELETE`) calls `mail.ListIdentityClaims` FIRST and refuses with a 409 while any claim stands. Deregistering underneath live senders would remove the only record saying "CARLOS must not touch this", and the next `EnsureDomain` on the name would sail through the guard and adopt somebody else's identity. The 409 names the count, not the accounts: the roster door is where those belong. An already-absent registration is a 200, because a retried deregistration is not an error. |
| 3567 | - **list** (`GET`) answers `{"identities": [...]}`, never null. |
| 3568 | - **claims** (`GET .../claims`) answers `{"domain": d, "claims": [...]}` from `ListIdentityClaims`, never null. |
| 3569 | - **grant** (`POST .../claims`) reads `{address, account, app, sender}`, resolves the account with `AccountBySqid` (the family's 404 for an unknown one), refuses an address whose domain is not under `{domain}` with a 400, defaults `sender` to `mail.SenderAppID`, calls `mail.GrantAddress`, and audits against the TARGET account with `s.audit(acct, app, addr, "email-address-grant", ...)` — the account whose authority changed is the one whose trail should show it. |
| 3570 | |
| 3571 | `internal/console/apimail.go`, `registerEmailRoutes`: |
| 3572 | |
| 3573 | ```go |
| 3574 | // The operator's external-identity family (apiexternal.go). No {account} |
| 3575 | // segment on four of the five, because registering an identity is a |
| 3576 | // statement about the deployment. Closed to every role, like pause and |
| 3577 | // for the same reason: the gate is an ADDRESS on CARLOS_MAIL_OPERATORS. |
| 3578 | s.cliRoute("PUT /api/cli/email/external-identities/{domain}", svcrole.Set{}, s.handleAPIExternalRegister) |
| 3579 | s.cliRoute("DELETE /api/cli/email/external-identities/{domain}", svcrole.Set{}, s.handleAPIExternalDeregister) |
| 3580 | s.cliRoute("GET /api/cli/email/external-identities", svcrole.Set{}, s.handleAPIExternalList) |
| 3581 | s.cliRoute("GET /api/cli/email/external-identities/{domain}/claims", svcrole.Set{}, s.handleAPIExternalClaims) |
| 3582 | s.cliRoute("POST /api/cli/email/external-identities/{domain}/claims", svcrole.Set{}, s.handleAPIExternalGrant) |
| 3583 | ``` |
| 3584 | |
| 3585 | `internal/console/svcroutes_test.go`: the five patterns join `closedDoors` (the list is an EQUALITY, so the suite fails until they are there), with the reason written beside them: |
| 3586 | |
| 3587 | ```go |
| 3588 | // Registering an identity CARLOS does not own, and granting an account |
| 3589 | // the right to send as an address under it, are deployment-wide operator |
| 3590 | // acts with no {account} to scope a credential to. They already resolve |
| 3591 | // an address against CARLOS_MAIL_OPERATORS, which no service principal |
| 3592 | // can hold, so a role named here would be a registration that lies about |
| 3593 | // who gets in. The grant is also an authority handover, which is the |
| 3594 | // same cut the store's approve door takes. |
| 3595 | "PUT /api/cli/email/external-identities/{domain}", |
| 3596 | "DELETE /api/cli/email/external-identities/{domain}", |
| 3597 | "GET /api/cli/email/external-identities", |
| 3598 | "GET /api/cli/email/external-identities/{domain}/claims", |
| 3599 | "POST /api/cli/email/external-identities/{domain}/claims", |
| 3600 | ``` |
| 3601 | |
| 3602 | and `policyDoors` gains one substring covering all five, because these are the INTENT-level pin that survives someone editing `closedDoors`: |
| 3603 | |
| 3604 | ```go |
| 3605 | policyDoors = []string{"/instances-manifest", "/{app}/routes", "/email/pause", "/build-probe", "/email/external-identities"} |
| 3606 | ``` |
| 3607 | |
| 3608 | **`closedDoors` is walked by a second test, and three more things in that file have to move with it.** `TestEveryClosedDoorRefusesAServiceCredential` (`svcroutes_test.go:753`) dials every pattern on the list over HTTP, so adding an entry is never bookkeeping alone: |
| 3609 | |
| 3610 | - the wildcard filler at `:765-773` is a `strings.NewReplacer` that knows `{account} {app} {fleet} {label} {name} {host} {id}` and `t.Fatalf`s on anything left over (`:774-776`). Three of the five new entries carry `{domain}`, so add `"{domain}", "tito.io",` to the replacer. The value only has to be a well-formed domain: the role gate refuses before any handler looks at it, which is the whole point of the test. |
| 3611 | - the count at `:790` reads `seen != len(closedDoors) || seen != 31`. Move `31` to `36` in both the condition and the message. |
| 3612 | - the prose list in that same message enumerates the 31 by family. Extend it with `five external-identity doors`, in the place the reader expects it, beside the two email credentials and email pause. |
| 3613 | - while the file is open, correct the stale count at `:40`: the comment says closedDoors says "these twelve are registered with the zero Set" and it has said thirty-one for a while. Make it "these are registered with the zero Set", with no number, so the sentence stops going stale every time a door is closed. |
| 3614 | |
| 3615 | with the paragraph above the list extended to say why: the registration decides which identities CARLOS may never write, and the grant decides who may send as an address under somebody else's domain. Both are policy about the deployment, not work inside one app. |
| 3616 | |
| 3617 | - [ ] **Step 4: Run it to verify it passes** |
| 3618 | |
| 3619 | ```bash |
| 3620 | go test ./internal/console/ ./internal/mail/ -count=1 |
| 3621 | ``` |
| 3622 | |
| 3623 | Expected: PASS, including `TestEveryCLIDoorNamesItsRoles` (the five new patterns are in `closedDoors`) and `TestNoRoleReachesAMintFleetOrPolicyDoor`. |
| 3624 | |
| 3625 | - [ ] **Step 5: Gate and commit** |
| 3626 | |
| 3627 | ```bash |
| 3628 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 3629 | git add internal/console internal/mail |
| 3630 | git commit -m "console: the operator's external-identity doors, register through grant (plan 3, task 12)" |
| 3631 | ``` |
| 3632 | |
| 3633 | --- |
| 3634 | |
| 3635 | ### Task 13: the member's address door, and the reply that describes an address sender |
| 3636 | |
| 3637 | **Files:** |
| 3638 | - Modify: `internal/console/apimail.go` (`emailReply` at `:377`, `registerEmailRoutes` at `:501`, a new handler beside `handleAPIEmailDeclare` at `:534`), `internal/console/apimail_test.go`, `internal/console/svcroutes_test.go` |
| 3639 | |
| 3640 | **Interfaces:** |
| 3641 | - Consumes: `apiAccountApp` (`apicli.go:40`), `mailWriteGates` (task 11), `mail.ClaimAddressSender`, `mail.ErrNoExternalIdentity`, `mail.ErrAddressNotOpen`, `mail.ErrAddressClaimed`, `mail.EnvDeliveredSender`, `mail.EnvNamesAddress`, `mailEffectivePrefix` (`:253`). |
| 3642 | - Produces: |
| 3643 | ```go |
| 3644 | func (s *Server) handleAPIEmailAddress(w http.ResponseWriter, r *http.Request) |
| 3645 | // POST /api/cli/apps/{account}/{app}/email/address svcrole.Allow(svcrole.Mail) |
| 3646 | ``` |
| 3647 | `emailReply` gains `env_names` entries for an address sender, an `identity` on the sender entry, and computes `missing_vars` through `EnvDeliveredSender`. |
| 3648 | |
| 3649 | - [ ] **Step 1: Write the failing test** |
| 3650 | |
| 3651 | `internal/console/apimail_test.go` (append): |
| 3652 | |
| 3653 | ```go |
| 3654 | // The claim-and-declare door is ONE call (design §7): it sets Source, |
| 3655 | // Address and Identity together, so a declared From address and the claim |
| 3656 | // that authorises it can never disagree. It is Declare for an address |
| 3657 | // sender, and enable, rotate, status and teardown are the doors that already |
| 3658 | // exist — they read Source off the record. |
| 3659 | func TestEmailAddressDoorClaimsAndDeclaresInOneCall(t *testing.T) { |
| 3660 | // register tito.io open, then POST .../email/address |
| 3661 | // {"address":"jelly@tito.io","env_prefix":"JELLY_SMTP"} as an account |
| 3662 | // member -> 200. |
| 3663 | // The reply's sender entry carries source=address, address=jelly@tito.io |
| 3664 | // and the identity ARN; env_names carries seven names including |
| 3665 | // JELLY_SMTP_CONFIG_SET and JELLY_SMTP_TENANT; missing_vars names all |
| 3666 | // seven, because nothing is delivered until enable runs. |
| 3667 | // The claim reads back from the bucket naming this account and app. |
| 3668 | } |
| 3669 | |
| 3670 | // Each refusal is its own answer, because each has a different recourse and |
| 3671 | // a member who cannot tell them apart asks the wrong person. |
| 3672 | func TestEmailAddressDoorRefusalsAreDistinct(t *testing.T) { |
| 3673 | // no registration for that domain -> 409, "ask an operator" |
| 3674 | // registered but Open=false -> 409, "ask an operator to |
| 3675 | // grant this app" |
| 3676 | // a sibling app in this account holds it -> 409, names neither the |
| 3677 | // sibling's owner nor any |
| 3678 | // other account |
| 3679 | // "jelly@" / "" / "jelly@tito.io/../x" -> 400 before any lookup |
| 3680 | // someone else's account, an unknown app -> the family's byte- |
| 3681 | // identical 404 |
| 3682 | } |
| 3683 | |
| 3684 | // The wire shape is ADDITIVE. An older CLI reading this reply sees every |
| 3685 | // field it already reads, unchanged and meaning what it meant. |
| 3686 | func TestEmailReplyKeepsItsShapeForADomainSender(t *testing.T) { |
| 3687 | // declare + enable a domain sender, then assert env_names has exactly |
| 3688 | // the five, missing_vars is empty, and no address/identity field appears |
| 3689 | // on the sender entry. |
| 3690 | } |
| 3691 | |
| 3692 | // missing_vars is computed through EnvDeliveredSender, so the two extra |
| 3693 | // variables are part of "can this app actually send" rather than a detail |
| 3694 | // only the walk knows about. |
| 3695 | func TestEmailStatusReportsAMissingTenantVariable(t *testing.T) { |
| 3696 | // enable an address sender, unset <P>_TENANT through appconfig, then GET |
| 3697 | // .../email -> missing_vars is exactly [<P>_TENANT]. |
| 3698 | } |
| 3699 | ``` |
| 3700 | |
| 3701 | `internal/console/svcroutes_test.go`: add the new pattern to `TestMailRoleReachesExactlyTheEmailDoors`'s `want` (task 11's list), which fails until the route is registered with `Allow(svcrole.Mail)`. |
| 3702 | |
| 3703 | - [ ] **Step 2: Run it to verify it fails** |
| 3704 | |
| 3705 | ```bash |
| 3706 | go test -run 'TestEmailAddressDoor|TestEmailReplyKeeps|TestEmailStatusReportsAMissing|TestMailRoleReaches' ./internal/console/ |
| 3707 | ``` |
| 3708 | |
| 3709 | Expected: 404 at the address door, and `doorsFor(mail)` short by one. |
| 3710 | |
| 3711 | - [ ] **Step 3: Write the code** |
| 3712 | |
| 3713 | `internal/console/apimail.go`, the handler beside `handleAPIEmailDeclare`: |
| 3714 | |
| 3715 | ```go |
| 3716 | // ---- address (claim and declare) ------------------------------------------- |
| 3717 | |
| 3718 | // handleAPIEmailAddress answers the member's first step for a sender under an |
| 3719 | // identity this deployment does not own: claim one exact address and declare |
| 3720 | // it, in one call. |
| 3721 | // |
| 3722 | // It is DECLARE for an address sender, not a second kind of declaration. |
| 3723 | // Claiming and declaring separately could disagree — a claim with no |
| 3724 | // declaration is an address nothing sends as, a declaration with no claim is |
| 3725 | // a From address no roster knows about — so mail.ClaimAddressSender sets |
| 3726 | // Source, Address, Identity and Declared together or sets none of them. |
| 3727 | // |
| 3728 | // The doors, in order, are handleAPIEmailDeclare's exactly: token — account |
| 3729 | // and app 404 — the write budget — mailAvailable — the body's own validation. |
| 3730 | // Nothing here reaches a backend: like declare, this is the member's half, |
| 3731 | // and enable is where SES first hears about any of it. |
| 3732 | // |
| 3733 | // EACH REFUSAL IS ITS OWN ANSWER, because each has a different recourse. "No |
| 3734 | // operator has registered that domain here" needs a conversation with the |
| 3735 | // identity's owner. "Registered, but not open" needs an operator grant. "A |
| 3736 | // sibling app already holds it" needs a different address. A single 409 for |
| 3737 | // all three would send the member to the wrong person. None of them names |
| 3738 | // another ACCOUNT: a claim held elsewhere in the deployment is not a refusal |
| 3739 | // at all (the claim is not exclusive across accounts), so there is nothing to |
| 3740 | // disclose and nothing to ask about. |
| 3741 | func (s *Server) handleAPIEmailAddress(w http.ResponseWriter, r *http.Request) { |
| 3742 | addr, acct, app, ok := s.apiAccountApp(w, r) |
| 3743 | if !ok { |
| 3744 | return |
| 3745 | } |
| 3746 | if !s.mailWriteGates(w, r) { |
| 3747 | return |
| 3748 | } |
| 3749 | var req struct { |
| 3750 | Address string `json:"address"` |
| 3751 | EnvPrefix string `json:"env_prefix"` |
| 3752 | } |
| 3753 | if !readJSON(w, r, &req) { |
| 3754 | return |
| 3755 | } |
| 3756 | ctx, cancel := context.WithTimeout(r.Context(), mailTimeout) |
| 3757 | defer cancel() |
| 3758 | |
| 3759 | sn, err := mail.ClaimAddressSender(ctx, s.cfg.Releases, acct.Sqid, app, mail.SenderAppID, |
| 3760 | req.Address, strings.TrimSpace(req.EnvPrefix), addr, time.Now()) |
| 3761 | switch { |
| 3762 | case errors.Is(err, mail.ErrNoExternalIdentity): |
| 3763 | jsonError(w, http.StatusConflict, |
| 3764 | "no identity is registered here for that address. ask a deployment operator to register it") |
| 3765 | return |
| 3766 | case errors.Is(err, mail.ErrAddressNotOpen): |
| 3767 | jsonError(w, http.StatusConflict, |
| 3768 | "that identity is not open for self-serve claims. ask a deployment operator to grant this app the address") |
| 3769 | return |
| 3770 | case errors.Is(err, mail.ErrAddressClaimed): |
| 3771 | jsonError(w, http.StatusConflict, |
| 3772 | "another app in this account already sends as that address") |
| 3773 | return |
| 3774 | case errors.Is(err, release.ErrCASConflict): |
| 3775 | jsonError(w, http.StatusConflict, "the email record changed underneath you. try again") |
| 3776 | return |
| 3777 | case err != nil: |
| 3778 | // The remaining failures are all about the body the caller just |
| 3779 | // sent: an address that is not one, an env prefix that is not one, |
| 3780 | // a prefix that cannot move after delivery. Safe to hand back |
| 3781 | // verbatim, the same judgement handleAPIEmailEnable makes for its |
| 3782 | // own "is not at any verified domain". |
| 3783 | jsonError(w, http.StatusBadRequest, err.Error()) |
| 3784 | return |
| 3785 | } |
| 3786 | s.audit(acct, app, addr, "email-address", "claimed "+sn.Address+" under "+mail.AddressDomain(sn.Address)+" (cli)") |
| 3787 | // No bumpSerial: a claim and a declaration touch nothing a box reads. |
| 3788 | // Enable is what delivers, and it bumps. |
| 3789 | rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| 3790 | if err != nil || rec == nil { |
| 3791 | log.Printf("console: api email address %s/%s: re-read: %v", acct.Sqid, app, err) |
| 3792 | jsonError(w, http.StatusBadGateway, |
| 3793 | "the address was claimed, but its status could not be read. run `carlos email status`") |
| 3794 | return |
| 3795 | } |
| 3796 | s.mailWalkReply(ctx, w, acct, app, rec) |
| 3797 | } |
| 3798 | ``` |
| 3799 | |
| 3800 | registered beside the other member doors: |
| 3801 | |
| 3802 | ```go |
| 3803 | s.cliRoute("POST /api/cli/apps/{account}/{app}/email/address", svcrole.Allow(svcrole.Mail), s.handleAPIEmailAddress) |
| 3804 | ``` |
| 3805 | |
| 3806 | `internal/console/apimail.go`, `emailReply` (`:377`): the names and the missing set both come from the sender now. |
| 3807 | |
| 3808 | ```go |
| 3809 | prefix := mailEffectivePrefix(rec) |
| 3810 | host, port, user, pass, from := mail.EnvNames(prefix) |
| 3811 | names := []string{host, port, user, pass, from} |
| 3812 | // The app sender, read the way this function already reads senders: |
| 3813 | // mail.ListSenders. Record.sender is unexported and this is package |
| 3814 | // console, so hoist plan 2's own ListSenders call (its task 13 puts it |
| 3815 | // just before the returned map) up to here and let the senders loop |
| 3816 | // below reuse `all` rather than listing twice. |
| 3817 | all, err := mail.ListSenders(ctx, s.cfg.Releases, acct.Sqid, app) |
| 3818 | if err != nil { |
| 3819 | return nil, err |
| 3820 | } |
| 3821 | var sn *mail.Sender |
| 3822 | for _, cand := range all { |
| 3823 | if cand.ID == mail.SenderAppID { |
| 3824 | sn = cand |
| 3825 | break |
| 3826 | } |
| 3827 | } |
| 3828 | // An address sender is delivered two more, and they are part of "can |
| 3829 | // this app send" rather than a detail only the walk knows: its policy |
| 3830 | // carries a tenant condition, so a message that names no tenant is |
| 3831 | // refused outright, and a message that names no configuration set is |
| 3832 | // attributed to the borrowed identity's own default and refused too |
| 3833 | // (senders design §7). A nil sn keeps today's five-name answer: an app |
| 3834 | // with a record always has an app sender (ListSenders synthesises one |
| 3835 | // for an unmigrated record, plan 2 task 3), so this is a guard rather |
| 3836 | // than a branch anything reaches. |
| 3837 | if sn != nil && sn.Source == mail.SourceAddress { |
| 3838 | configSetName, tenantName := mail.EnvNamesAddress(prefix) |
| 3839 | names = append(names, configSetName, tenantName) |
| 3840 | } |
| 3841 | missing := []string{} |
| 3842 | if rec.Provisioned != nil && sn != nil { |
| 3843 | got, err := mail.EnvDeliveredSender(ctx, s.cfg.Releases, acct.Sqid, app, sn, rec.Provisioned.KeyID) |
| 3844 | if err != nil { |
| 3845 | return nil, err |
| 3846 | } |
| 3847 | missing = append(missing, got...) |
| 3848 | } |
| 3849 | ``` |
| 3850 | |
| 3851 | The `senders` entry each sender contributes gains `"identity": sn.Identity` beside the `source` and `address` plan 2 already puts there. Every other top-level field keeps its meaning, so an older CLI reads this reply exactly as it reads today. |
| 3852 | |
| 3853 | - [ ] **Step 4: Run it to verify it passes** |
| 3854 | |
| 3855 | ```bash |
| 3856 | go test ./internal/console/ -count=1 |
| 3857 | ``` |
| 3858 | |
| 3859 | Expected: PASS. |
| 3860 | |
| 3861 | - [ ] **Step 5: Gate and commit** |
| 3862 | |
| 3863 | ```bash |
| 3864 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 3865 | git add internal/console |
| 3866 | git commit -m "console: the address door, where claiming and declaring are one call (plan 3, task 13)" |
| 3867 | ``` |
| 3868 | |
| 3869 | --- |
| 3870 | |
| 3871 | ### Task 14: `carlos email external-identity`, the operator's five verbs |
| 3872 | |
| 3873 | **Files:** |
| 3874 | - Modify: `mail_cli.go` (`emailUsage` at `:60`, `runEmail`'s switch at `:137-159`, the `apiSession` wire calls from `:352`), `apimode.go` (the reply types, beside the store operator family at `:1786-1810`), `mail_cli_test.go` |
| 3875 | |
| 3876 | **Interfaces:** |
| 3877 | - Consumes: `requireMailAPISession` (`mail_cli.go:166`), `consoleOption` (`flags.go:39`), `appFlag`, `accountFlags`, `flagUsage`, `s.getJSON`/`postJSON`/`putJSON`/`deleteJSON` (`apimode.go:760`, `:822`, `:2360`, `:2710`), `pathEscape` (`mail_cli.go:517`). |
| 3878 | - Produces: |
| 3879 | ```go |
| 3880 | func runEmailExternalIdentity(args []string) // register|deregister|list|claims|grant |
| 3881 | type apiExternalIdentity struct{ Domain, Region, IdentityARN, Note, By string; Open bool; At time.Time } |
| 3882 | type apiAddressClaim struct{ Address, Account, App, Sender, By string; Grant bool; At time.Time } |
| 3883 | func (s *apiSession) externalIdentityRegister(ctx context.Context, domain, region, arn, note string, open bool) (apiExternalIdentity, error) |
| 3884 | func (s *apiSession) externalIdentityDeregister(ctx context.Context, domain string) error |
| 3885 | func (s *apiSession) externalIdentityList(ctx context.Context) ([]apiExternalIdentity, error) |
| 3886 | func (s *apiSession) externalIdentityClaims(ctx context.Context, domain string) ([]apiAddressClaim, error) |
| 3887 | func (s *apiSession) externalIdentityGrant(ctx context.Context, domain, address, account, app string) (apiAddressClaim, error) |
| 3888 | ``` |
| 3889 | |
| 3890 | **Note on the flag helper.** `main` has moved since this branch's base: every `fs.String("console", ...)` in this file is now `consoleOption(fs, ...)`, which registers `--destination`/`-d` alongside. New verbs use `consoleOption`. Read the current shape of each neighbouring verb rather than trusting a line number from an earlier task. |
| 3891 | |
| 3892 | - [ ] **Step 1: Write the failing test** |
| 3893 | |
| 3894 | `mail_cli_test.go` (append), in the shape this file's existing table tests use (a `httptest` console that records the method and path it was asked for): |
| 3895 | |
| 3896 | ```go |
| 3897 | // Every verb hits the door §7 names, with the domain escaped into the path |
| 3898 | // and nothing else invented. This is the whole contract between this binary |
| 3899 | // and the console for the operator family. |
| 3900 | func TestEmailExternalIdentityCallsTheOperatorDoors(t *testing.T) { |
| 3901 | // register -> PUT /api/cli/email/external-identities/tito.io |
| 3902 | // deregister-> DELETE /api/cli/email/external-identities/tito.io |
| 3903 | // list -> GET /api/cli/email/external-identities |
| 3904 | // claims -> GET /api/cli/email/external-identities/tito.io/claims |
| 3905 | // grant -> POST /api/cli/email/external-identities/tito.io/claims |
| 3906 | // and the register body carries region, identity_arn, open and note. |
| 3907 | } |
| 3908 | |
| 3909 | // A 404 from any of these doors is the operator gate, not a missing domain: |
| 3910 | // the doors refuse a non-operator with a byte-identical 404 on purpose, so |
| 3911 | // the CLI has to say what that means rather than "not found". |
| 3912 | func TestEmailExternalIdentityExplainsTheOperatorRefusal(t *testing.T) { |
| 3913 | // a 404 with no body -> an error naming CARLOS_MAIL_OPERATORS. |
| 3914 | // a 409 with a body -> the console's own sentence, verbatim. |
| 3915 | } |
| 3916 | |
| 3917 | // The domain is the one argument that becomes a path segment, so it is |
| 3918 | // validated here too rather than only at the console. |
| 3919 | func TestEmailExternalIdentityRefusesANonDomain(t *testing.T) { |
| 3920 | // "", "tito.io/../x", "not a domain" -> usage, no request made. |
| 3921 | } |
| 3922 | ``` |
| 3923 | |
| 3924 | - [ ] **Step 2: Run it to verify it fails** |
| 3925 | |
| 3926 | ```bash |
| 3927 | go test -run TestEmailExternalIdentity ./ |
| 3928 | ``` |
| 3929 | |
| 3930 | Expected: build failure, `undefined: runEmailExternalIdentity`. |
| 3931 | |
| 3932 | - [ ] **Step 3: Take the new strings through the copy-review gate** |
| 3933 | |
| 3934 | Every string below is read by a person: the usage block, the printed tables, and each error sentence. Extract them, run the `copy-review` skill, and apply the result verbatim before writing the file. The repo's rules apply and are the reason the drafts below read as they do: no em dashes, full stop and a new sentence, lower case. Do not skip this because the audience is an operator — `carlos email pause`'s strings went through it too. |
| 3935 | |
| 3936 | The strings to review: |
| 3937 | |
| 3938 | - the `external-identity` block of `emailUsage`, five verbs; |
| 3939 | - `registered <domain> in <region>. this deployment will never write that identity`; |
| 3940 | - the cross-team contract, printed by `register` and by nothing else, because §7 says the registration step is where it is stated and this is that step: `while an app here is sending under <domain>, its owner cannot delete that identity at SES. the association is what blocks it, and `carlos email enable` is what creates one. tearing the last sender down releases it.` An operator who registers an identity is making a promise to somebody outside CARLOS, and this is the only place they are told what it is; |
| 3941 | - `deregistered <domain>`; |
| 3942 | - `<n> accounts still hold addresses under <domain>. run `carlos email external-identity claims <domain>` to see them`; |
| 3943 | - `granted <address> to <account>/<app>`; |
| 3944 | - `nothing registered. run `carlos email external-identity register <domain> --arn <arn>` to add one`; |
| 3945 | - `no addresses are claimed under <domain>`; |
| 3946 | - `not found. check the domain, and that your login is on this deployment's CARLOS_MAIL_OPERATORS list`; |
| 3947 | - the column headings for the two tables: `domain region open registered by when` and `address account app sender how when`, where `how` reads `claimed` or `granted`. |
| 3948 | |
| 3949 | - [ ] **Step 4: Write the code** |
| 3950 | |
| 3951 | `mail_cli.go`, the dispatch (`:137`) gains one case and the usage line gains the verb: |
| 3952 | |
| 3953 | ```go |
| 3954 | case "external-identity": |
| 3955 | runEmailExternalIdentity(args[1:]) |
| 3956 | ``` |
| 3957 | |
| 3958 | `emailUsage`'s new block, after `resume`: |
| 3959 | |
| 3960 | ``` |
| 3961 | external-identity register <domain> --arn <arn> [--region <r>] [--open] [--note <n>] |
| 3962 | record that this deployment may send under an SES |
| 3963 | identity it does not own. CARLOS never creates, |
| 3964 | rewrites or deletes a registered identity, and |
| 3965 | `carlos email domains add` refuses the name and |
| 3966 | everything under it. --open lets any account here |
| 3967 | claim an address under it for itself; without it, |
| 3968 | grant is the only way in. Operator only. |
| 3969 | external-identity deregister <domain> |
| 3970 | remove the registration. refused while any account |
| 3971 | still holds an address under it. |
| 3972 | external-identity list the registrations on this deployment. |
| 3973 | external-identity claims <domain> |
| 3974 | every address claimed under the identity, and who |
| 3975 | holds each one. |
| 3976 | external-identity grant <domain> --address <a> --account <sqid> --app <app> |
| 3977 | let one app send as one address under a |
| 3978 | registration that is not open. |
| 3979 | ``` |
| 3980 | |
| 3981 | The five sub-verbs, each its own `flag.FlagSet` named `email external-identity <verb>`, each taking `consoleOption(fs, ...)`, and `grant` taking `appFlag` and `accountFlags` as well. `register` takes `--arn` (required), `--region` (defaults to empty, which the console fills from its own deployment), `--open` and `--note`. Each resolves its session with `requireMailAPISession(*consoleFlag)` and prints through `stdout` with `text/tabwriter`, the way `runEmailCredentialsList` already does. |
| 3982 | |
| 3983 | `apimode.go`, beside the store operator family, the wire calls and one error translator: |
| 3984 | |
| 3985 | ```go |
| 3986 | // externalIdentityError is storeOperatorError's counterpart for the email |
| 3987 | // operator family, and it exists for the same reason: these doors refuse a |
| 3988 | // non-operator with a byte-identical 404 (apiexternal.go's gate), so "not |
| 3989 | // found" on its own would send someone hunting for a typo in a domain name |
| 3990 | // when the real answer is that their address is not on the deployment's |
| 3991 | // operator list. |
| 3992 | func externalIdentityError(status int, body []byte) error { |
| 3993 | var e struct { |
| 3994 | Error string `json:"error"` |
| 3995 | } |
| 3996 | if json.Unmarshal(body, &e) == nil && e.Error != "" { |
| 3997 | return errors.New(e.Error) |
| 3998 | } |
| 3999 | if status == http.StatusNotFound { |
| 4000 | return errors.New("not found. check the domain, and that your login is on this deployment's CARLOS_MAIL_OPERATORS list") |
| 4001 | } |
| 4002 | return fmt.Errorf("unexpected response (status %d)", status) |
| 4003 | } |
| 4004 | ``` |
| 4005 | |
| 4006 | and `externalIdentityPath(domain, tail string) string` returning |
| 4007 | `"/api/cli/email/external-identities/" + pathEscape(domain) + tail`, so the five calls cannot disagree about the path. `pathEscape`'s existing doc comment holds: a valid domain carries no `/`, and the escape is exact for the reserved set anyway. |
| 4008 | |
| 4009 | - [ ] **Step 5: Run it to verify it passes** |
| 4010 | |
| 4011 | ```bash |
| 4012 | go test -run TestEmailExternalIdentity ./ && go build ./... |
| 4013 | ``` |
| 4014 | |
| 4015 | Expected: PASS and a clean build. |
| 4016 | |
| 4017 | - [ ] **Step 6: Gate and commit** |
| 4018 | |
| 4019 | ```bash |
| 4020 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 4021 | git add mail_cli.go apimode.go mail_cli_test.go |
| 4022 | git commit -m "carlos: email external-identity register, deregister, list, claims and grant (plan 3, task 14)" |
| 4023 | ``` |
| 4024 | |
| 4025 | --- |
| 4026 | |
| 4027 | ### Task 15: `carlos email enable --from jelly@tito.io` takes the address path |
| 4028 | |
| 4029 | **Files:** |
| 4030 | - Modify: `internal/console/apimail.go` (`handleAPIEmailAddress`, task 13), `mail_cli.go` (`runEmailEnableFlow` at `:652`, `printEmailStatus` at `:1129`, `printEmailEnabled` at `:1253`, `emailUsage`'s `enable` block at `:62`), `mail_cli_test.go` |
| 4031 | |
| 4032 | **Interfaces:** |
| 4033 | - Consumes: `sess.emailStatus`, `sess.emailEnable`, `apiMailError` (`mail_cli.go:312`), `apiEmailReply.Domains`/`DefaultHost`. |
| 4034 | - Produces: |
| 4035 | ```go |
| 4036 | func (s *apiSession) emailAddress(ctx context.Context, a apiAcct, app, address, envPrefix string) (apiEmailReply, error) |
| 4037 | func mailRefusalReason(err error) string // "unregistered" | "not_open" | "claimed" | "" |
| 4038 | ``` |
| 4039 | The address door's four refusals gain a machine-readable `reason` beside their `error`. |
| 4040 | |
| 4041 | **Why the `reason` field lands here and not in task 13.** It has exactly one consumer: this flow, deciding whether the From address it was handed belongs to a registered identity or to an ordinary sending domain. Adding it in task 13 would have been a field with no reader, which is the kind of speculative surface this repo's wire-shape rule exists to prevent. It is additive: the body still carries `error`, and an older CLI reads the sentence exactly as it does today. |
| 4042 | |
| 4043 | - [ ] **Step 1: Write the failing test** |
| 4044 | |
| 4045 | `mail_cli_test.go` (append): |
| 4046 | |
| 4047 | ```go |
| 4048 | // `carlos email enable --from jelly@tito.io` is the one command the whole of |
| 4049 | // #147 exists to make work. It must NOT go near `domains add`: tito.io is an |
| 4050 | // identity CARLOS does not own, EnsureDomain refuses it outright (task 3), |
| 4051 | // and the only path that ends in a working credential is claim, then enable. |
| 4052 | func TestEnableWithAnExternalAddressClaimsInsteadOfAddingADomain(t *testing.T) { |
| 4053 | // a console that answers the address door 200 and records every path: |
| 4054 | // want GET .../email, POST .../email/address, POST .../email/enable |
| 4055 | // and no POST .../email/domains anywhere in the trace. |
| 4056 | // The address door's body carries the address and the env prefix. |
| 4057 | } |
| 4058 | |
| 4059 | // An ordinary custom domain is untouched by any of this. The address door is |
| 4060 | // tried, answers "unregistered", and the existing domain path runs exactly as |
| 4061 | // it does today, including the verify wait. |
| 4062 | func TestEnableFallsBackToTheDomainPathWhenNothingIsRegistered(t *testing.T) { |
| 4063 | // address door -> 409 {"error": "...", "reason": "unregistered"} |
| 4064 | // want POST .../email/domains, the verify poll, then POST .../email. |
| 4065 | } |
| 4066 | |
| 4067 | // A domain the app ALREADY has a record for skips the address door entirely: |
| 4068 | // no wasted round trip on the path every existing app takes. |
| 4069 | func TestEnableDoesNotAskTheAddressDoorForAKnownDomain(t *testing.T) { |
| 4070 | // status answers with domains: [harbourmail.bag.oncarlos.com] |
| 4071 | // want no POST .../email/address at all. |
| 4072 | } |
| 4073 | |
| 4074 | // The two refusals a member can act on are surfaced as themselves, with the |
| 4075 | // console's own sentence, and nothing is attempted afterwards: adding tito.io |
| 4076 | // as a sending domain is exactly what must not happen next. |
| 4077 | func TestEnableSurfacesAClosedIdentityWithoutFallingBack(t *testing.T) { |
| 4078 | // address door -> 409 reason "not_open" -> error names the operator |
| 4079 | // grant, no domains call. |
| 4080 | // address door -> 409 reason "claimed" -> error names the sibling app |
| 4081 | // refusal, no domains call. |
| 4082 | } |
| 4083 | |
| 4084 | // An address sender is delivered seven variables, and the two extra ones are |
| 4085 | // useless unless the member is told what to do with them: the app has to set |
| 4086 | // both as headers on every message, or SES refuses the send outright (§4.1). |
| 4087 | func TestEnabledOutputNamesTheTwoHeadersAnAddressSenderMustSet(t *testing.T) { |
| 4088 | // printEmailEnabled over a reply whose sender is an address sender names |
| 4089 | // <P>_CONFIG_SET and <P>_TENANT and says which header each one goes in. |
| 4090 | // Over a domain sender it prints exactly what it prints today. |
| 4091 | } |
| 4092 | |
| 4093 | // The honour system, said out loud (design §7). An address sender's events |
| 4094 | // reach CARLOS only when the message names CARLOS's configuration set, and |
| 4095 | // nothing here can enforce that, so zero counts have two explanations and the |
| 4096 | // status must not quietly pick one. A domain sender's zero is not ambiguous |
| 4097 | // and gains no line. |
| 4098 | func TestStatusSaysWhenAnAddressSenderHasCountedNothing(t *testing.T) { |
| 4099 | // a provisioned address sender with all-zero counts -> the line appears. |
| 4100 | // the same sender with one send -> it does not. |
| 4101 | // a provisioned DOMAIN sender with all-zero counts -> it does not. |
| 4102 | } |
| 4103 | ``` |
| 4104 | |
| 4105 | - [ ] **Step 2: Run it to verify it fails** |
| 4106 | |
| 4107 | ```bash |
| 4108 | go test -run 'TestEnableWithAnExternalAddress|TestEnableFallsBack|TestEnableDoesNotAskTheAddressDoor|TestEnableSurfacesAClosedIdentity|TestEnabledOutputNames|TestStatusSaysWhenAnAddressSender' ./ |
| 4109 | ``` |
| 4110 | |
| 4111 | Expected: the trace shows `POST .../email/domains` for the external address, `printEmailEnabled` says nothing about headers, and the status prints no line about what it has not counted. |
| 4112 | |
| 4113 | - [ ] **Step 3: Take the new strings through the copy-review gate** |
| 4114 | |
| 4115 | This is member-facing copy, so the gate is not optional. Extract, review, apply verbatim. The drafts: |
| 4116 | |
| 4117 | - `<domain> is an identity this deployment does not own, so <address> was claimed rather than added as a sending domain.` |
| 4118 | - `your app must set two headers on every message it sends. X-SES-CONFIGURATION-SET from <P>_CONFIG_SET, and X-SES-TENANT from <P>_TENANT. a message missing either one is refused by SES, so this fails loudly rather than sending uncounted.` |
| 4119 | - the status line for a provisioned address sender that has no counts at all: `nothing has been counted for this sender. either it has sent nothing, or it is sending without the X-SES-CONFIGURATION-SET header, and only the app can tell you which.` §7 asks for exactly this, and it is the honest half of the honour system: a domain sender with no counts has sent nothing, but an address sender with no counts might be sending perfectly well into somebody else's event destination, and a status that said "0 sends" flat would be stating something this deployment cannot know; |
| 4120 | - the `enable` usage block's new sentence: `--from at a registered external identity claims the single address instead of bringing a sending domain into existence, and skips the verification wait entirely.` |
| 4121 | |
| 4122 | - [ ] **Step 4: Write the code** |
| 4123 | |
| 4124 | `internal/console/apimail.go`, `handleAPIEmailAddress`'s four refusals write the reason beside the sentence: |
| 4125 | |
| 4126 | ```go |
| 4127 | // The reason is for one caller: `carlos email enable`, deciding whether |
| 4128 | // the From address it was given belongs to a registered identity or to |
| 4129 | // an ordinary sending domain. It cannot tell from the sentence without |
| 4130 | // matching prose, and it cannot tell from the status code because all |
| 4131 | // four of these are conflicts. Additive: the body still carries error. |
| 4132 | case errors.Is(err, mail.ErrNoExternalIdentity): |
| 4133 | writeJSON(w, http.StatusConflict, map[string]string{ |
| 4134 | "error": "no identity is registered here for that address. ask a deployment operator to register it", |
| 4135 | "reason": "unregistered", |
| 4136 | }) |
| 4137 | return |
| 4138 | ``` |
| 4139 | |
| 4140 | and likewise `not_open` for `ErrAddressNotOpen` and `claimed` for `ErrAddressClaimed`. |
| 4141 | |
| 4142 | `mail_cli.go`, the decoder and the flow: |
| 4143 | |
| 4144 | ```go |
| 4145 | // mailRefusalReason reads the address door's machine-readable reason off a |
| 4146 | // refusal. Empty for every other error, including a transport failure, so a |
| 4147 | // caller switching on it treats "something else went wrong" as itself rather |
| 4148 | // than as any of the four it knows about. |
| 4149 | func mailRefusalReason(err error) string { |
| 4150 | var me *apiMailError |
| 4151 | if !errors.As(err, &me) { |
| 4152 | return "" |
| 4153 | } |
| 4154 | return me.reason |
| 4155 | } |
| 4156 | ``` |
| 4157 | |
| 4158 | `apiMailError` gains a `reason string` field, set by `mailAPIError` from the same body it already reads for the message. |
| 4159 | |
| 4160 | `runEmailEnableFlow` (`:652`), after `from` and `domain` are resolved and before the `domains add` half: |
| 4161 | |
| 4162 | ```go |
| 4163 | // THE ADDRESS PATH (senders design §7). Asked only when the From |
| 4164 | // address's domain is one this app has no record of: every app that |
| 4165 | // already sends, and every app whose From is its own platform host, |
| 4166 | // takes the path it takes today with no extra round trip. |
| 4167 | // |
| 4168 | // "Unregistered" is the ORDINARY answer here, not a failure: it means |
| 4169 | // the member named a custom domain, which is what the rest of this |
| 4170 | // function has always been about. The other two refusals stop the flow |
| 4171 | // dead, deliberately: falling through to `domains add tito.io` after |
| 4172 | // being told the address is somebody else's would take a member who |
| 4173 | // needs an operator and hand them EnsureDomain's refusal instead. |
| 4174 | if from != "" && !knownSendingDomain(cur, domain) { |
| 4175 | res, err := sess.emailAddress(ctx, acct, app, from, envPrefix) |
| 4176 | switch mailRefusalReason(err) { |
| 4177 | case "": |
| 4178 | if err != nil { |
| 4179 | return fmt.Errorf("claiming %s: %w", from, err) |
| 4180 | } |
| 4181 | fmt.Fprintf(w, "%s is an identity this deployment does not own, so %s was claimed rather than added as a sending domain.\n", |
| 4182 | domain, from) |
| 4183 | return finishEmailEnable(ctx, w, sess, acct, app, res) |
| 4184 | case "unregistered": |
| 4185 | // An ordinary custom domain. Carry on below. |
| 4186 | default: |
| 4187 | return err |
| 4188 | } |
| 4189 | } |
| 4190 | ``` |
| 4191 | |
| 4192 | where `knownSendingDomain(cur apiEmailReply, domain string) bool` answers from `cur.Domains` and `cur.DefaultHost`, and `finishEmailEnable` is the existing tail: `sess.emailEnable` then `printEmailEnabled`. The address path skips `domains add` and the verification poll entirely, because there is nothing of CARLOS's to verify: the identity's owner verified it years ago, and `CheckExternalIdentity` is what confirms it at enable time. |
| 4193 | |
| 4194 | `printEmailEnabled` (`:1253`) gains the header sentence when the reply's sender is an address sender, after the delivered names it already prints. It is the one thing a member cannot discover from anywhere else: the two variables arrive in the bundle, and nothing in the bundle says they are headers. |
| 4195 | |
| 4196 | `printEmailStatus` (`:1129`) gains the counted-nothing line, on an address sender that is provisioned and whose counts are all zero. It reads off the sender entry plan 2's task 14 already prints a line per sender from, so it is one `if` inside that loop rather than a second pass. |
| 4197 | |
| 4198 | `emailUsage`'s `enable` block gains the reviewed sentence. |
| 4199 | |
| 4200 | - [ ] **Step 5: Run it to verify it passes** |
| 4201 | |
| 4202 | ```bash |
| 4203 | go test ./ ./internal/console/ -count=1 |
| 4204 | ``` |
| 4205 | |
| 4206 | Expected: PASS, including every existing enable-flow test unchanged. |
| 4207 | |
| 4208 | - [ ] **Step 6: Gate and commit** |
| 4209 | |
| 4210 | ```bash |
| 4211 | go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l . |
| 4212 | git add mail_cli.go mail_cli_test.go internal/console |
| 4213 | git commit -m "carlos: enable --from at a registered identity claims the address instead of adding a domain (plan 3, task 15)" |
| 4214 | ``` |
| 4215 | |
| 4216 | --- |
| 4217 | |
| 4218 | ## What this plan does not do |
| 4219 | |
| 4220 | Step 3 ends here, with Jelly sending as `jelly@tito.io` from two accounts. The |
| 4221 | things it stops short of are steps 4 and 5, and they are left out on purpose |
| 4222 | rather than forgotten. |
| 4223 | |
| 4224 | **The per-instance config layer (step 4, spec §6.2, §6.5).** There is no |
| 4225 | third bundle layer at `apps/<acct>/<app>/instances/<host>.json`, no change to |
| 4226 | `ConfigAck`, and no way for one instance of an app to be delivered different |
| 4227 | variables from its siblings. Every sender this plan builds delivers into the |
| 4228 | app's ONE bundle, which every instance renders, so an address sender is an |
| 4229 | app-wide fact. That is exactly right for Jelly, which is one app sending as |
| 4230 | one address, and it is the thing that has to change before an app can hold |
| 4231 | more than one sender at a time. |
| 4232 | |
| 4233 | **Instance senders and per-instance mode (step 5, spec §6.3, §6.4).** No |
| 4234 | sender in this plan has `Scope == ScopeInstance`. `Sender.Slug` is always |
| 4235 | empty and `SenderNames` therefore always returns today's names, which is why |
| 4236 | `ensureAddressPrincipal` takes `UserName(acct, app)` and `TenantName(acct)` |
| 4237 | directly and says so in a comment: when instance senders land, those two |
| 4238 | values come from `SenderNames` instead, and `EnvDeliveredSender`'s two |
| 4239 | expected values come from the sender rather than from the account. Nothing |
| 4240 | here enters or leaves per-instance mode, there are no instance doors, and |
| 4241 | `PerInstance` is carried forward by plan 2's record and read by nothing. |
| 4242 | |
| 4243 | **The §8 pause verbs.** The spike proved a tenant can be paused directly and |
| 4244 | that CARLOS can tell an SES-imposed pause from its own (§4.1, answer 4), but |
| 4245 | this plan adds no `TenantStatus`, no `SetTenantSending` and no |
| 4246 | `ses:caller-identity` attribution. An address sender pauses the way every |
| 4247 | sender pauses today, through `SetKeyActive` on its own principal, and its |
| 4248 | counts are the app's counts. |
| 4249 | |
| 4250 | **The honour system around counting.** An address sender's events reach |
| 4251 | CARLOS only when the message names CARLOS's configuration set, and nothing |
| 4252 | here can force that. What this plan does instead is make the failure LOUD: |
| 4253 | the `ses:TenantName` condition means a message missing either header is |
| 4254 | refused outright rather than sent uncounted, and `<P>_CONFIG_SET` and |
| 4255 | `<P>_TENANT` are delivered so the app has no excuse. The relay is what |
| 4256 | removes the honour system, and it is out of scope (spec §10, §13). |
| 4257 | |
| 4258 | **A `Local` allowlist on a registration.** `Open` is all-or-nothing per |
| 4259 | identity, and the trade-off is recorded in §7: with it, any account in the |
| 4260 | deployment can claim `billing@tito.io`. The mitigation is an allowlist on the |
| 4261 | external-identity record, and it is deliberately not built now. The operator |
| 4262 | grant is the answer until it bites. |
| 4263 | |