mail: the enable walk — mint, derive, deliver
Delivers the five variables seapointish already reads, so the v1 criterion needs no diff in that repo. The DERIVED password goes out, never the IAM secret; a test pins that, because delivering the secret would authenticate against nothing and put a live AWS credential in an app's environment. A From address outside every verified domain is refused here rather than at runtime, where the policy condition would make it look like email simply does not work.
2 files changed,
+445
−0
internal/mail/walk.go+241 −0internal/mail/walk_enable_test.go+204 −0
diff --git a/internal/mail/walk.go b/internal/mail/walk.go| index a887fa8d0..10cb56ec6 100644 |
| --- a/internal/mail/walk.go |
| +++ b/internal/mail/walk.go |
| @@ -5,11 +5,14 @@ package mail |
| import ( |
| "context" |
| + "crypto/ecdh" |
| "errors" |
| "fmt" |
| + "sort" |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/dnsrec" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -206,3 +209,241 @@ func VerifyDomain(ctx context.Context, s release.Store, p Provisioner, |
| } |
| return rec, nil |
| } |
| + |
| +// SMTPPort is the submission port every delivered credential uses. 587 with |
| +// STARTTLS is the whole of the delivered contract's transport (spec §1). |
| +const SMTPPort = "587" |
| + |
| +// SMTPHost is the submission endpoint for a region. Delivery resolves this |
| +// per instance, which is what makes region-local sending a lookup rather |
| +// than a subsystem (spec §8). |
| +func SMTPHost(region string) string { return "email-smtp." + region + ".amazonaws.com" } |
| + |
| +// Declare records the member's half. It does NOT validate the From address |
| +// against a domain, because a member may declare before any domain is |
| +// verified; Enable is where the two meet and where a mismatch is refused. |
| +func Declare(ctx context.Context, s release.Store, acct, app, envPrefix, from, actor string, |
| + now time.Time) (*Record, error) { |
| + |
| + if envPrefix == "" { |
| + envPrefix = DefaultEnvPrefix |
| + } |
| + if !ValidEnvPrefix(envPrefix) { |
| + return nil, fmt.Errorf("mail: invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$", envPrefix) |
| + } |
| + if !strings.Contains(from, "@") { |
| + return nil, fmt.Errorf("mail: From %q is not an address", from) |
| + } |
| + rec, etag, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if rec == nil { |
| + rec = &Record{} |
| + } |
| + // The env prefix is LOCKED once delivery has happened: moving it would |
| + // leave the old variables standing in every instance's config, and the |
| + // app would keep reading the stale pair. |
| + if rec.Declared != nil && rec.Provisioned != nil && rec.Declared.EnvPrefix != envPrefix { |
| + return nil, fmt.Errorf("mail: env prefix is %q and cannot move after delivery", |
| + rec.Declared.EnvPrefix) |
| + } |
| + rec.Declared = &Declared{EnvPrefix: envPrefix, From: from, At: now, By: actor} |
| + if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + return nil, err |
| + } |
| + return rec, nil |
| +} |
| + |
| +// grantedDomains is every domain in the account whose record names this app |
| +// and which has at least one VERIFIED region. |
| +func grantedDomains(ctx context.Context, s release.Store, acct, app string) ([]*DomainRecord, error) { |
| + all, err := ListDomains(ctx, s, acct) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []*DomainRecord |
| + for _, d := range all { |
| + if len(d.VerifiedARNs()) == 0 { |
| + continue |
| + } |
| + for _, a := range d.Apps { |
| + if a == app { |
| + out = append(out, d) |
| + break |
| + } |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// EnvDelivered reports which delivered variables are NOT present, of the |
| +// right kind, in the app's config. It is the walk's idempotence check and |
| +// the console's "what is missing" answer, and it exists because "the record |
| +// says provisioned" is not the same claim as "the app can actually send". |
| +func EnvDelivered(ctx context.Context, s release.Store, acct, app, prefix string) ([]string, error) { |
| + man, err := appconfig.Get(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + host, port, user, pass, from := EnvNames(prefix) |
| + var missing []string |
| + check := func(name, wantKind string) { |
| + v, ok := man.Vars[name] |
| + if !ok || v.Kind != wantKind { |
| + missing = append(missing, name) |
| + } |
| + } |
| + check(host, "env") |
| + check(port, "env") |
| + check(from, "env") |
| + check(user, "secret") |
| + check(pass, "secret") |
| + sort.Strings(missing) |
| + return missing, nil |
| +} |
| + |
| +// Enable provisions the declared app's credential and delivers it. |
| +// |
| +// Idempotent: a healthy provisioned app makes this a no-op — no fresh key, |
| +// no rewrite — because minting on every pass would burn IAM's two-key |
| +// ceiling and churn every instance's config. "Healthy" means the record |
| +// says provisioned AND every delivered variable is present, of the right |
| +// kind; anything less and the walk runs, because a missing variable means |
| +// the app cannot send. |
| +func Enable(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, |
| + d Deployment, acct, app string, now time.Time) (*Record, error) { |
| + |
| + rec, etag, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if rec == nil || rec.Declared == nil { |
| + return nil, ErrNotDeclared |
| + } |
| + |
| + domains, err := grantedDomains(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(domains) == 0 { |
| + return nil, ErrNoDomains |
| + } |
| + var arns, names []string |
| + for _, dom := range domains { |
| + arns = append(arns, dom.VerifiedARNs()...) |
| + names = append(names, dom.Domain) |
| + } |
| + sort.Strings(arns) |
| + sort.Strings(names) |
| + |
| + // The From address must be at a granted domain, or the policy's |
| + // ses:FromAddress condition refuses every send at RUNTIME — which |
| + // presents as "email silently does not work" instead of as this refusal. |
| + at := strings.LastIndex(rec.Declared.From, "@") |
| + fromDomain := rec.Declared.From[at+1:] |
| + if !containsString(names, fromDomain) { |
| + return nil, fmt.Errorf("mail: From %q is not at any verified domain (%s)", |
| + rec.Declared.From, strings.Join(names, ", ")) |
| + } |
| + |
| + // Idempotence short-circuit. |
| + if rec.Provisioned != nil { |
| + missing, err := EnvDelivered(ctx, s, acct, app, rec.Declared.EnvPrefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(missing) == 0 { |
| + return rec, nil |
| + } |
| + } |
| + |
| + user := UserName(acct, app) |
| + arn, err := p.EnsureUser(ctx, user, UserPath, arns, names) |
| + if err != nil { |
| + return nil, fmt.Errorf("mail: ensure user %s: %w", user, err) |
| + } |
| + // Reap before minting: reaching here means delivery is missing, so any |
| + // current key is either undelivered (its secret died with a failed |
| + // config write) or stranded by a crash between mint and record. No |
| + // record names either; only list-and-delete finds them. A pending-finish |
| + // key from an unfinished rotation is kept for its explicit finish. |
| + var keep []string |
| + if rec.Provisioned != nil && rec.Provisioned.PrevKeyID != "" { |
| + keep = append(keep, rec.Provisioned.PrevKeyID) |
| + } |
| + if err := p.ReapKeys(ctx, user, keep); err != nil { |
| + return nil, fmt.Errorf("mail: reap keys for %s: %w", user, err) |
| + } |
| + keyID, secret, err := p.MintKey(ctx, user) |
| + if err != nil { |
| + return nil, fmt.Errorf("mail: mint key for %s: %w", user, err) |
| + } |
| + |
| + if err := writeSMTPEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, |
| + d.Region, keyID, secret, rec.Declared.From); err != nil { |
| + return nil, err |
| + } |
| + |
| + prev := "" |
| + if rec.Provisioned != nil { |
| + prev = rec.Provisioned.PrevKeyID |
| + } |
| + rec.Provisioned = &Provisioned{ |
| + IAMUser: user, UserARN: arn, KeyID: keyID, PrevKeyID: prev, Active: true, At: now, |
| + } |
| + if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + return nil, err |
| + } |
| + return rec, nil |
| +} |
| + |
| +func containsString(xs []string, want string) bool { |
| + for _, x := range xs { |
| + if x == want { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// writeSMTPEnv delivers the five variables. The DERIVED password goes out, |
| +// never the IAM secret — delivering the secret would authenticate against |
| +// nothing and would put a live AWS credential into an app's environment. |
| +// |
| +// Both credential halves are sealed. The username is not itself sensitive, |
| +// but a plaintext access key id in a config manifest is a free inventory of |
| +// the estate for anyone who reads one, and sealing both costs nothing. |
| +func writeSMTPEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey, |
| + acct, app, prefix, region, keyID, secret, from string) error { |
| + |
| + hostName, portName, userName, passName, fromName := EnvNames(prefix) |
| + password := SMTPPassword(secret, region) |
| + |
| + sets := map[string]appconfig.Var{ |
| + hostName: {Kind: "env", Value: SMTPHost(region)}, |
| + portName: {Kind: "env", Value: SMTPPort}, |
| + fromName: {Kind: "env", Value: from}, |
| + } |
| + // Validate BEFORE sealing. A sealed value reaches appconfig as an opaque |
| + // envelope, so an unrenderable byte would sail through and stall the |
| + // app's whole config render on every host, hours later and out of sight. |
| + // The errors name the VARIABLE and never the value — these strings reach |
| + // logs and HTTP bodies, and one half is a live credential. |
| + for _, half := range []struct{ name, plain string }{ |
| + {userName, keyID}, {passName, password}, |
| + } { |
| + if err := appconfig.ValidVarValue(half.plain); err != nil { |
| + return fmt.Errorf("mail: minted credential for %s is not EnvironmentFile-safe: %w", half.name, err) |
| + } |
| + env, err := appconfig.Seal(sealPub, []byte(half.plain)) |
| + if err != nil { |
| + return fmt.Errorf("mail: seal %s: %w", half.name, err) |
| + } |
| + sets[half.name] = appconfig.Var{Kind: "secret", Envelope: env} |
| + } |
| + if _, err := appconfig.Apply(ctx, s, acct, app, sets, nil); err != nil { |
| + return fmt.Errorf("mail: deliver smtp config for %s/%s: %w", acct, app, err) |
| + } |
| + return nil |
| +} |
diff --git a/internal/mail/walk_enable_test.go b/internal/mail/walk_enable_test.go| new file mode 100644 |
| index 000000000..49701750b |
| --- /dev/null |
| +++ b/internal/mail/walk_enable_test.go |
| @@ -0,0 +1,204 @@ |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// enableFixture seeds a declared app with one VERIFIED domain — the state |
| +// Enable expects to find. |
| +func enableFixture(t *testing.T) (context.Context, release.Store, *MemProvisioner, *ecdh.PublicKey, time.Time) { |
| + t.Helper() |
| + ctx := context.Background() |
| + s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + |
| + _, pubB64, err := appconfig.GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + pub, err := appconfig.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + |
| + if _, err := Declare(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP", |
| + "noreply@seapointish.bag.oncarlos.com", "paul@rslw.com", now); err != nil { |
| + t.Fatalf("Declare: %v", err) |
| + } |
| + if _, _, err := EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), |
| + "bag", "seapointish", "seapointish.bag.oncarlos.com", now); err != nil { |
| + t.Fatalf("EnsureDomain: %v", err) |
| + } |
| + p.SetIdentityStatus("eu-west-1", "seapointish.bag.oncarlos.com", |
| + Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}) |
| + if _, err := VerifyDomain(ctx, s, p, "bag", "seapointish.bag.oncarlos.com", "eu-west-1", now); err != nil { |
| + t.Fatalf("VerifyDomain: %v", err) |
| + } |
| + return ctx, s, p, pub, now |
| +} |
| + |
| +// The v1 criterion in one test: the five variables seapointish already |
| +// reads arrive, with the right values, and the app changes nothing. |
| +func TestEnableDeliversSeapointishsEnv(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + rec, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("Enable: %v", err) |
| + } |
| + if rec.Provisioned == nil || rec.Provisioned.KeyID == "" { |
| + t.Fatalf("nothing provisioned: %+v", rec) |
| + } |
| + man, err := appconfig.Get(ctx, s, "bag", "seapointish") |
| + if err != nil { |
| + t.Fatalf("appconfig.Get: %v", err) |
| + } |
| + if got := man.Vars["SEAPOINTISH_SMTP_HOST"].Value; got != "email-smtp.eu-west-1.amazonaws.com" { |
| + t.Fatalf("HOST = %q", got) |
| + } |
| + if got := man.Vars["SEAPOINTISH_SMTP_PORT"].Value; got != "587" { |
| + t.Fatalf("PORT = %q", got) |
| + } |
| + if got := man.Vars["SEAPOINTISH_SMTP_FROM"].Value; got != "noreply@seapointish.bag.oncarlos.com" { |
| + t.Fatalf("FROM = %q", got) |
| + } |
| + // The username is the access key id and is NOT a secret; the password |
| + // is, and must arrive sealed. |
| + if man.Vars["SEAPOINTISH_SMTP_USER"].Kind != "secret" { |
| + t.Fatal("USER must be delivered as a secret alongside PASS") |
| + } |
| + if man.Vars["SEAPOINTISH_SMTP_PASS"].Kind != "secret" { |
| + t.Fatal("PASS was not delivered as a sealed secret") |
| + } |
| + if man.Vars["SEAPOINTISH_SMTP_PASS"].Envelope == "" { |
| + t.Fatal("PASS has no envelope") |
| + } |
| +} |
| + |
| +// The delivered password is the DERIVED one, not the raw IAM secret. This |
| +// is the test that catches delivering the secret access key by mistake — |
| +// which would authenticate against nothing and leak a real AWS credential |
| +// into an app's environment. |
| +// |
| +// This opens the actual delivered envelope rather than just re-deriving the |
| +// expected value from the fake's plaintext: a version that only checked |
| +// "the derivation function returns something different from its input" |
| +// would pass even if Enable delivered the raw secret, because it never |
| +// looks at what was actually sealed and sent. |
| +func TestEnableDeliversTheDerivedPasswordNotTheSecret(t *testing.T) { |
| + ctx, s, p, _, now := enableFixture(t) |
| + privB64, pubB64, err := appconfig.GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + priv, err := appconfig.ParsePrivateKey(privB64, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + ownPub, err := appconfig.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + |
| + rec, err := Enable(ctx, s, p, ownPub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("Enable: %v", err) |
| + } |
| + raw := p.Users[UserName("bag", "seapointish")].Keys[rec.Provisioned.KeyID].Secret |
| + want := SMTPPassword(raw, "eu-west-1") |
| + if want == raw { |
| + t.Fatal("test is not meaningful — derivation returned the input") |
| + } |
| + |
| + man, err := appconfig.Get(ctx, s, "bag", "seapointish") |
| + if err != nil { |
| + t.Fatalf("appconfig.Get: %v", err) |
| + } |
| + env := man.Vars["SEAPOINTISH_SMTP_PASS"].Envelope |
| + if env == "" { |
| + t.Fatal("PASS has no envelope") |
| + } |
| + plain, err := appconfig.Open(priv, env) |
| + if err != nil { |
| + t.Fatalf("Open: %v", err) |
| + } |
| + if string(plain) != want { |
| + t.Fatalf("delivered password = %q, want the derived password %q", plain, want) |
| + } |
| + if string(plain) == raw { |
| + t.Fatal("Enable delivered the raw IAM secret instead of the derived SMTP password") |
| + } |
| +} |
| + |
| +// A healthy provisioned app makes a re-run a no-op: no fresh key, no |
| +// rewrite. Minting on every pass would burn IAM's two-key ceiling and |
| +// churn every instance's config. |
| +func TestEnableIsIdempotent(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + first, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("first Enable: %v", err) |
| + } |
| + second, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("second Enable: %v", err) |
| + } |
| + if first.Provisioned.KeyID != second.Provisioned.KeyID { |
| + t.Fatal("a re-run minted a fresh key") |
| + } |
| +} |
| + |
| +// Enable must refuse an app with no VERIFIED domain. A credential with no |
| +// identity is a principal that can send nothing while looking provisioned. |
| +func TestEnableRefusesWithoutAVerifiedDomain(t *testing.T) { |
| + ctx := context.Background() |
| + s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + _, pubB64, _ := appconfig.GenerateKey() |
| + pub, _ := appconfig.ParsePublicKey(pubB64, "test") |
| + |
| + Declare(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP", "n@x.com", "paul@rslw.com", now) |
| + EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), |
| + "bag", "seapointish", "seapointish.bag.oncarlos.com", now) // left pending |
| + if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now); err == nil { |
| + t.Fatal("Enable succeeded with only a pending domain") |
| + } |
| +} |
| + |
| +func TestEnableRefusesAnUndeclaredApp(t *testing.T) { |
| + ctx := context.Background() |
| + s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| + now := time.Now().UTC() |
| + _, pubB64, _ := appconfig.GenerateKey() |
| + pub, _ := appconfig.ParsePublicKey(pubB64, "test") |
| + if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now); err != ErrNotDeclared { |
| + t.Fatalf("Enable on an undeclared app = %v, want ErrNotDeclared", err) |
| + } |
| +} |
| + |
| +// The From address must be at a granted domain. Otherwise the policy's |
| +// ses:FromAddress condition refuses every send at runtime — a failure that |
| +// surfaces as "email silently does not work" rather than as a refusal here. |
| +// |
| +// Declare itself accepts any From (a member may declare before any domain |
| +// is verified); Enable, which knows the granted domains, is what refuses |
| +// one outside every verified domain. |
| +func TestDeclareAcceptsAnyFromButEnableRefusesAnUngrantedOne(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + _ = p |
| + _ = pub |
| + if _, err := Declare(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP", |
| + "noreply@somewhere-else.com", "paul@rslw.com", now); err != nil { |
| + t.Fatalf("Declare itself may accept any From: %v", err) |
| + } |
| + // but Enable, which knows the granted domains, must refuse it |
| + if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now); err == nil { |
| + t.Fatal("Enable accepted a From address outside every granted domain") |
| + } |
| +} |