mail: associate each account's tenant with its identities and its configuration set (plan 2, task 7)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 files changed,
+244
−22
internal/mail/aws.go+50 −12internal/mail/aws_test.go+8 −0internal/mail/domain.go+17 −4internal/mail/memprovisioner.go+43 −6internal/mail/memprovisioner_test.go+28 −0internal/mail/provisioner.go+12 −0internal/mail/walk.go+33 −0internal/mail/walk_domain_test.go+53 −0
diff --git a/internal/mail/aws.go b/internal/mail/aws.go| index f97c7f9e6..0c68bd433 100644 |
| --- a/internal/mail/aws.go |
| +++ b/internal/mail/aws.go |
| @@ -19,10 +19,12 @@ |
| // provisioner. EnsureTenant below therefore does the one thing the Provisioner |
| // interface actually asks of it — create the account's named tenant container, |
| // idempotently — and stops there: it does not call |
| -// CreateTenantResourceAssociation, because the interface gives EnsureTenant no |
| -// identity or config-set to associate. Isolation is unaffected either way: |
| -// the tenant buys REPUTATION isolation, and AUTHORITY isolation is the IAM |
| -// policy in policy.go (spec §4). |
| +// CreateTenantResourceAssociation itself, because the interface gives |
| +// EnsureTenant no identity or config-set to associate; that association is |
| +// wired separately, by walk.go's EnsureDomain, once it has both (plan 2, |
| +// task 7). Isolation is unaffected either way: the tenant buys REPUTATION |
| +// isolation, and AUTHORITY isolation is the IAM policy in policy.go (spec |
| +// §4). |
| package mail |
| import ( |
| @@ -60,6 +62,8 @@ type sesAPI interface { |
| CreateConfigurationSet(context.Context, *sesv2.CreateConfigurationSetInput, ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetOutput, error) |
| CreateConfigurationSetEventDestination(context.Context, *sesv2.CreateConfigurationSetEventDestinationInput, ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetEventDestinationOutput, error) |
| PutEmailIdentityConfigurationSetAttributes(context.Context, *sesv2.PutEmailIdentityConfigurationSetAttributesInput, ...func(*sesv2.Options)) (*sesv2.PutEmailIdentityConfigurationSetAttributesOutput, error) |
| + CreateTenantResourceAssociation(context.Context, *sesv2.CreateTenantResourceAssociationInput, ...func(*sesv2.Options)) (*sesv2.CreateTenantResourceAssociationOutput, error) |
| + DeleteTenantResourceAssociation(context.Context, *sesv2.DeleteTenantResourceAssociationInput, ...func(*sesv2.Options)) (*sesv2.DeleteTenantResourceAssociationOutput, error) |
| } |
| // iamAPI is the subset of *iam.Client awsProvisioner calls. |
| @@ -168,17 +172,19 @@ func isNotFound(err error) bool { |
| // unaffected by that gap: the tenant buys reputation isolation, and |
| // authority isolation is the IAM policy (spec §4), which this does not touch. |
| // |
| -// PRECONDITION FOR SENDING, NOT YET MET HERE: per |
| +// PRECONDITION FOR SENDING, NOW MET BY EnsureDomain, NOT HERE: per |
| // docs.aws.amazon.com/ses/latest/dg/tenants.html, a tenant needs at least |
| // one verified identity AND one configuration set associated via |
| // CreateTenantResourceAssociation before SES will accept a send attributed |
| -// to it — SES validates that association on every send. Nothing in this |
| -// package wires that association yet. Consequently: the send path MUST NOT |
| -// emit the `X-SES-TENANT: <name>` header until associations are wired, |
| -// wherever that lands (identities and config sets are ensured by separate |
| -// calls, so it is not naturally EnsureTenant's job). Emitting the header |
| -// against a tenant with no associated resources fails every send, the |
| -// opposite of what tenants exist to do. |
| +// to it — SES validates that association on every send. walk.go's |
| +// EnsureDomain wires both associations now, after PutDomain, using |
| +// AssociateTenantResource below (plan 2, task 7). This still does not make |
| +// any send carry the `X-SES-TENANT: <name>` header: nothing in step 2 |
| +// composes an outgoing message or writes a tenant-conditioned policy, so |
| +// that header waits for step 3. The spike's answer (design §4.1, confirmed |
| +// against the live API): an association is inert for the identity's owner |
| +// in every respect except deletion, and `ses:TenantName` IS enforced over |
| +// SMTP. |
| func (p *awsProvisioner) EnsureTenant(ctx context.Context, name string) error { |
| _, err := p.ses.CreateTenant(ctx, &sesv2.CreateTenantInput{ |
| TenantName: aws.String(name), |
| @@ -189,6 +195,38 @@ func (p *awsProvisioner) EnsureTenant(ctx context.Context, name string) error { |
| return nil |
| } |
| +// AssociateTenantResource associates an identity or a configuration set with |
| +// a tenant. This is the call the file header said nothing here made yet: SES |
| +// validates the association on every tenant-attributed send, so it must exist |
| +// before any send carries X-SES-TENANT. Associating changes nothing about the |
| +// resource itself, so it is safe on an identity another product owns. |
| +func (p *awsProvisioner) AssociateTenantResource(ctx context.Context, tenant, resourceARN string) error { |
| + _, err := p.ses.CreateTenantResourceAssociation(ctx, &sesv2.CreateTenantResourceAssociationInput{ |
| + TenantName: aws.String(tenant), |
| + ResourceArn: aws.String(resourceARN), |
| + }) |
| + if err != nil && !isAlreadyExists(err) { |
| + return fmt.Errorf("mail: associate %s with tenant %q: %w", resourceARN, tenant, err) |
| + } |
| + return nil |
| +} |
| + |
| +// DisassociateTenantResource removes one association. It has to run before |
| +// DeleteEmailIdentity: SES refuses to delete an identity while an |
| +// association exists, which is also the cross-team contract for a borrowed |
| +// identity (design §7) — while CARLOS's association stands, the owner cannot |
| +// delete their own identity. |
| +func (p *awsProvisioner) DisassociateTenantResource(ctx context.Context, tenant, resourceARN string) error { |
| + _, err := p.ses.DeleteTenantResourceAssociation(ctx, &sesv2.DeleteTenantResourceAssociationInput{ |
| + TenantName: aws.String(tenant), |
| + ResourceArn: aws.String(resourceARN), |
| + }) |
| + if err != nil && !isNotFound(err) { |
| + return fmt.Errorf("mail: disassociate %s from tenant %q: %w", resourceARN, tenant, err) |
| + } |
| + return nil |
| +} |
| + |
| func (p *awsProvisioner) EnsureConfigSet(ctx context.Context, region, name, snsTopicARN string) error { |
| if err := p.checkRegion(region); err != nil { |
| return err |
diff --git a/internal/mail/aws_test.go b/internal/mail/aws_test.go| index 0f28fa06f..ae10580bb 100644 |
| --- a/internal/mail/aws_test.go |
| +++ b/internal/mail/aws_test.go |
| @@ -113,6 +113,14 @@ func (s *stubSES) PutEmailIdentityConfigurationSetAttributes(_ context.Context, |
| return &sesv2.PutEmailIdentityConfigurationSetAttributesOutput{}, nil |
| } |
| +func (s *stubSES) CreateTenantResourceAssociation(_ context.Context, in *sesv2.CreateTenantResourceAssociationInput, _ ...func(*sesv2.Options)) (*sesv2.CreateTenantResourceAssociationOutput, error) { |
| + return &sesv2.CreateTenantResourceAssociationOutput{}, nil |
| +} |
| + |
| +func (s *stubSES) DeleteTenantResourceAssociation(_ context.Context, in *sesv2.DeleteTenantResourceAssociationInput, _ ...func(*sesv2.Options)) (*sesv2.DeleteTenantResourceAssociationOutput, error) { |
| + return &sesv2.DeleteTenantResourceAssociationOutput{}, nil |
| +} |
| + |
| var _ sesAPI = (*stubSES)(nil) |
| // TestEnsureIdentityAlreadyExistsReadsBackTokensAndComposesTheARN pins the |
diff --git a/internal/mail/domain.go b/internal/mail/domain.go| index 1ce9499e1..b41fe8d8f 100644 |
| --- a/internal/mail/domain.go |
| +++ b/internal/mail/domain.go |
| @@ -167,16 +167,29 @@ func (r *DomainRecord) VerifiedConfigSetARNs() []string { |
| if st == nil || st.Status != StatusVerified || st.ConfigSet == "" || st.IdentityARN == "" { |
| continue |
| } |
| - parts := strings.Split(st.IdentityARN, ":") |
| - if len(parts) != 6 { |
| - continue |
| + if arn, ok := ConfigSetARN(st.IdentityARN, st.ConfigSet); ok { |
| + out = append(out, arn) |
| } |
| - out = append(out, strings.Join(parts[:5], ":")+":configuration-set/"+st.ConfigSet) |
| } |
| sort.Strings(out) |
| return out |
| } |
| +// ConfigSetARN composes a configuration set's ARN from an identity ARN in |
| +// the same account and region, which is the only place this package has |
| +// those two values. Reports false for an ARN it cannot parse, so a caller |
| +// never builds a resource string out of a malformed one. |
| +func ConfigSetARN(identityARN, configSet string) (string, bool) { |
| + if identityARN == "" || configSet == "" { |
| + return "", false |
| + } |
| + parts := strings.Split(identityARN, ":") |
| + if len(parts) != 6 { |
| + return "", false |
| + } |
| + return strings.Join(parts[:5], ":") + ":configuration-set/" + configSet, true |
| +} |
| + |
| // AddApp records an app as a referrer. Reports whether it changed anything. |
| func (r *DomainRecord) AddApp(app string) bool { |
| for _, a := range r.Apps { |
diff --git a/internal/mail/memprovisioner.go b/internal/mail/memprovisioner.go| index 75bfb1795..5d1f2480f 100644 |
| --- a/internal/mail/memprovisioner.go |
| +++ b/internal/mail/memprovisioner.go |
| @@ -48,6 +48,11 @@ type MemProvisioner struct { |
| // Identities is keyed "region|domain". |
| Identities map[string]*MemIdentity |
| Users map[string]*MemUser |
| + // TenantResources is tenant -> resource ARN -> associated. An identity |
| + // with an association cannot be deleted, mirroring SES, because a walk |
| + // that tore down in the wrong order would pass against a permissive fake |
| + // and fail on the first real teardown. |
| + TenantResources map[string]map[string]bool |
| // FailNext, keyed by method name, is consumed one-shot. Consumption |
| // happens BEFORE any state change so a failed call leaves state |
| // untouched — a walk test needs the retry to start from a clean point. |
| @@ -61,12 +66,13 @@ type MemProvisioner struct { |
| func NewMemProvisioner(account string) *MemProvisioner { |
| return &MemProvisioner{ |
| - account: account, |
| - Tenants: map[string]bool{}, |
| - ConfigSets: map[string]string{}, |
| - Identities: map[string]*MemIdentity{}, |
| - Users: map[string]*MemUser{}, |
| - FailNext: map[string]error{}, |
| + account: account, |
| + Tenants: map[string]bool{}, |
| + ConfigSets: map[string]string{}, |
| + Identities: map[string]*MemIdentity{}, |
| + Users: map[string]*MemUser{}, |
| + TenantResources: map[string]map[string]bool{}, |
| + FailNext: map[string]error{}, |
| StatusOnEnsure: Status{ |
| DKIM: "PENDING", MailFrom: "PENDING", Sending: "PENDING", |
| }, |
| @@ -194,10 +200,41 @@ func (p *MemProvisioner) DeleteIdentity(_ context.Context, region, domain string |
| if err := p.takeFailure("DeleteIdentity"); err != nil { |
| return err |
| } |
| + got, ok := p.Identities[identityKey(region, domain)] |
| + if ok { |
| + for tenant, resources := range p.TenantResources { |
| + if resources[got.ARN] { |
| + return fmt.Errorf("mail: identity %s is still associated with tenant %s", domain, tenant) |
| + } |
| + } |
| + } |
| delete(p.Identities, identityKey(region, domain)) |
| return nil |
| } |
| +func (p *MemProvisioner) AssociateTenantResource(_ context.Context, tenant, resourceARN string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("AssociateTenantResource"); err != nil { |
| + return err |
| + } |
| + if p.TenantResources[tenant] == nil { |
| + p.TenantResources[tenant] = map[string]bool{} |
| + } |
| + p.TenantResources[tenant][resourceARN] = true |
| + return nil |
| +} |
| + |
| +func (p *MemProvisioner) DisassociateTenantResource(_ context.Context, tenant, resourceARN string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("DisassociateTenantResource"); err != nil { |
| + return err |
| + } |
| + delete(p.TenantResources[tenant], resourceARN) |
| + return nil |
| +} |
| + |
| func (p *MemProvisioner) EnsureUser(_ context.Context, name, path string, identityARNs, configSetARNs, domains []string) (string, error) { |
| p.mu.Lock() |
| defer p.mu.Unlock() |
diff --git a/internal/mail/memprovisioner_test.go b/internal/mail/memprovisioner_test.go| index 12ccdfbb9..db81baf13 100644 |
| --- a/internal/mail/memprovisioner_test.go |
| +++ b/internal/mail/memprovisioner_test.go |
| @@ -131,3 +131,31 @@ func TestMemFailNextIsOneShot(t *testing.T) { |
| t.Fatalf("second call = %v, want nil", err) |
| } |
| } |
| + |
| +// The fake refuses to delete an identity that is still associated with a |
| +// tenant, because SES does. A walk that got the order wrong would pass |
| +// against a permissive fake and fail on the first real teardown. |
| +func TestMemProvisionerRefusesToDeleteAnAssociatedIdentity(t *testing.T) { |
| + ctx := context.Background() |
| + p := NewMemProvisioner("1") |
| + id, err := p.EnsureIdentity(ctx, "eu-west-1", "x.com", "mail-eu-west-1.x.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.AssociateTenantResource(ctx, "carlos-bag", id.ARN); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.DeleteIdentity(ctx, "eu-west-1", "x.com"); err == nil { |
| + t.Fatal("an associated identity was deleted") |
| + } |
| + if err := p.DisassociateTenantResource(ctx, "carlos-bag", id.ARN); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Disassociating twice is a success, like every other verb on this seam. |
| + if err := p.DisassociateTenantResource(ctx, "carlos-bag", id.ARN); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.DeleteIdentity(ctx, "eu-west-1", "x.com"); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/internal/mail/provisioner.go b/internal/mail/provisioner.go| index 30dfaa5ee..1b2d0bf00 100644 |
| --- a/internal/mail/provisioner.go |
| +++ b/internal/mail/provisioner.go |
| @@ -65,6 +65,18 @@ type Provisioner interface { |
| // once per (region, domain) — idempotent, like every verb here. |
| SetIdentityConfigSet(ctx context.Context, region, domain, configSet string) error |
| + // AssociateTenantResource associates an identity, a configuration set or |
| + // a template with a tenant. Associating does not modify the resource and |
| + // one resource may belong to many tenants, so this is safe to call on an |
| + // identity another product owns. It has exactly one side effect on the |
| + // owner: an identity cannot be deleted while an association exists, |
| + // which is why DisassociateTenantResource has to run before |
| + // DeleteIdentity. Idempotent, like every verb here. |
| + AssociateTenantResource(ctx context.Context, tenant, resourceARN string) error |
| + // DisassociateTenantResource removes one association. "Already gone" is |
| + // success. |
| + DisassociateTenantResource(ctx context.Context, tenant, resourceARN string) error |
| + |
| // EnsureIdentity creates the domain identity, enables Easy DKIM and |
| // sets the custom MAIL FROM subdomain, all idempotently. |
| EnsureIdentity(ctx context.Context, region, domain, mailFrom string) (Identity, error) |
diff --git a/internal/mail/walk.go b/internal/mail/walk.go| index a69f85dd9..e43b745cf 100644 |
| --- a/internal/mail/walk.go |
| +++ b/internal/mail/walk.go |
| @@ -281,6 +281,30 @@ func EnsureDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec. |
| return nil, nil, err |
| } |
| + // The account's tenant gets this identity, and its configuration set |
| + // (design §7). Association does not modify the resource and one resource |
| + // may belong to many tenants, so this changes nothing anyone can observe |
| + // today. It is wired now because SES validates the association on every |
| + // tenant-attributed send and step 3 sends that way. |
| + // |
| + // AFTER PutDomain, not beside EnsureIdentity, and that ordering is the |
| + // whole of what keeps Teardown's orphan-claim sweep working. An |
| + // association is the one thing that stops an identity being deleted, and |
| + // the sweep (walk.go's orphaned-claims loop, below) deletes the identity |
| + // behind a claim that has NO record — so it never runs the disassociate |
| + // in Teardown's region loop. Associate before the record is durable and |
| + // a crash between the two leaves an identity nothing can ever reap. |
| + if err := p.AssociateTenantResource(ctx, tenant, ident.ARN); err != nil { |
| + return nil, nil, fmt.Errorf("mail: associate identity %s with tenant %s: %w", domain, tenant, err) |
| + } |
| + if configSet != "" { |
| + if setARN, ok := ConfigSetARN(ident.ARN, configSet); ok { |
| + if err := p.AssociateTenantResource(ctx, tenant, setARN); err != nil { |
| + return nil, nil, fmt.Errorf("mail: associate configuration set %s with tenant %s: %w", configSet, tenant, err) |
| + } |
| + } |
| + } |
| + |
| // GRANT BEFORE BIND. Every app that sends on this domain — and every |
| // standalone credential those apps hold — gets its policy recomputed |
| // from the record just written, so the configuration-set ARN is in |
| @@ -1234,6 +1258,15 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writ |
| } |
| } |
| } |
| + // SES refuses to delete an identity while a tenant association |
| + // exists, so the association goes first. The account's |
| + // configuration set is NOT disassociated: it is per account and |
| + // outlives any one app. |
| + if st.IdentityARN != "" { |
| + if err := p.DisassociateTenantResource(ctx, TenantName(acct), st.IdentityARN); err != nil { |
| + return err |
| + } |
| + } |
| if err := p.DeleteIdentity(ctx, region, cur.Domain); err != nil { |
| return err |
| } |
diff --git a/internal/mail/walk_domain_test.go b/internal/mail/walk_domain_test.go| index d261f8b87..a3f584a43 100644 |
| --- a/internal/mail/walk_domain_test.go |
| +++ b/internal/mail/walk_domain_test.go |
| @@ -370,3 +370,56 @@ func TestEnsureDomainRepairsAPartialPublish(t *testing.T) { |
| t.Fatalf("after repair, published %d records, want 6 with no duplicates", len(inner.Records)) |
| } |
| } |
| + |
| +// Step 2 associates each account's EXISTING tenant with its identities and |
| +// its configuration set (design §7, "Associations are wired in step 2, not |
| +// here"). Associating a resource does not modify it and changes no |
| +// behaviour on its own: no send carries X-SES-TENANT yet. It is done now so |
| +// that step 3 can rely on it, and because SES validates the association on |
| +// every tenant-attributed send. |
| +func TestEnsureDomainAssociatesTheIdentityAndTheSetWithTheAccountsTenant(t *testing.T) { |
| + ctx := context.Background() |
| + s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| + now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| + if _, err := Declare(ctx, s, "bag", "harbourmail", "HARBOURMAIL_SMTP", |
| + "noreply@harbourmail.bag.oncarlos.com", "owner@example.test", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec, _, err := EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), |
| + "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tenant := TenantName("bag") |
| + identity := rec.Regions["eu-west-1"].IdentityARN |
| + set, ok := ConfigSetARN(identity, ConfigSetName("bag")) |
| + if !ok { |
| + t.Fatalf("no configuration-set ARN composed from %q", identity) |
| + } |
| + for _, arn := range []string{identity, set} { |
| + if !p.TenantResources[tenant][arn] { |
| + t.Errorf("%s is not associated with %s: %v", arn, tenant, p.TenantResources[tenant]) |
| + } |
| + } |
| + // A re-run associates the same pair again without error: every verb on |
| + // this seam is idempotent, which is what makes the walk re-runnable. |
| + if _, _, err := EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), |
| + "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err != nil { |
| + t.Fatalf("re-run: %v", err) |
| + } |
| + |
| + // An identity cannot be deleted while an association exists (design §3), |
| + // so teardown removes the association first. MemProvisioner models that |
| + // refusal, because without it the first real teardown would fail. |
| + if err := Teardown(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "bag", "harbourmail"); err != nil { |
| + t.Fatalf("Teardown: %v", err) |
| + } |
| + if p.TenantResources[tenant][identity] { |
| + t.Error("the identity association survived teardown, so the identity could never be deleted") |
| + } |
| + // The configuration set is per ACCOUNT and outlives any one app, so its |
| + // association is never removed by an app's teardown. |
| + if !p.TenantResources[tenant][set] { |
| + t.Error("an app's teardown removed the account's configuration-set association") |
| + } |
| +} |