carlos / platform Public

mail: rotate, pause, standalone credentials, teardown

Rotation leaves the old key active because adoption swaps a symlink and does
not restart processes — killing it at rotation is an outage. The pause
toggles the key, never the policy, and toggles both keys so an app
mid-rotation cannot keep sending. Teardown removes a domain's identity only
when the last referring app goes, or one app's teardown silently revokes a
sibling's verified domain.
Paul Campbell 28f32a085e76e596ac203a6adc3fe16295373942 parent f5cd53f
2 files changed, +521 −0
  • internal/mail/walk.go +303 −0
  • internal/mail/walk_lifecycle_test.go +218 −0
diff --git a/internal/mail/walk.go b/internal/mail/walk.go
index 60ca11d76..41a194573 100644
--- a/internal/mail/walk.go
+++ b/internal/mail/walk.go
@@ -8,6 +8,7 @@ import (
"crypto/ecdh"
"errors"
"fmt"
+ "regexp"
"sort"
"strings"
"time"
@@ -520,3 +521,305 @@ func writeSMTPEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey,
}
return nil
}
+
+// StandaloneCreds is a displayed credential, returned ONCE. Nothing stores
+// the password: it is derived here and handed straight back.
+type StandaloneCreds struct {
+ Host, Port, Username, Password, From string
+}
+
+// Rotate mints a fresh key, delivers it, and leaves the OLD key ACTIVE.
+//
+// The overlap is the whole point. Adoption swaps a symlink; it does not
+// restart processes, so an instance may still be authenticating with the old
+// credential for minutes after delivery. Killing it at rotation time is an
+// outage with no upside. RotateFinish closes the window explicitly.
+//
+// The overlap consumes IAM's ceiling of two access keys per user, which is
+// exactly why standalone credentials get their own principal.
+func Rotate(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
+ }
+ if rec.Provisioned == nil {
+ return nil, ErrNotProvisioned
+ }
+ user := rec.Provisioned.IAMUser
+
+ // A pending key from an UNFINISHED earlier rotation goes first —
+ // rotating twice without finishing is ordinary member behaviour, and
+ // both slots cannot hold three keys.
+ if rec.Provisioned.PrevKeyID != "" {
+ if err := p.DeleteKey(ctx, user, rec.Provisioned.PrevKeyID); err != nil {
+ return nil, err
+ }
+ }
+ // Then reap anything the record does not name — a key stranded by a
+ // crash between mint and record is nameable only by listing.
+ if err := p.ReapKeys(ctx, user, []string{rec.Provisioned.KeyID}); err != nil {
+ return nil, 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
+ }
+ rec.Provisioned.PrevKeyID = rec.Provisioned.KeyID
+ rec.Provisioned.KeyID = keyID
+ rec.Provisioned.At = now
+ if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
+ return nil, err
+ }
+ return rec, nil
+}
+
+// RotateFinish closes the two-key window. This is the only walk that
+// destroys a credential an app might still be using, which is why it is a
+// separate, explicit verb rather than the tail of Rotate.
+func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app string) (*Record, error) {
+ rec, etag, err := Get(ctx, s, acct, app)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil || rec.Provisioned == nil {
+ return nil, ErrNotProvisioned
+ }
+ if rec.Provisioned.PrevKeyID == "" {
+ return rec, nil // nothing to finish; idempotent
+ }
+ if err := p.DeleteKey(ctx, rec.Provisioned.IAMUser, rec.Provisioned.PrevKeyID); err != nil {
+ return nil, err
+ }
+ rec.Provisioned.PrevKeyID = ""
+ if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
+ return nil, err
+ }
+ return rec, nil
+}
+
+// SetPaused stops or resumes sending.
+//
+// It toggles the KEY, never the policy. A policy rewrite would have to be
+// undone correctly to resume, and a half-failed rewrite leaves a grant that
+// is neither the old one nor the new one. Deactivating a key is atomic at
+// IAM and trivially reversible.
+//
+// BOTH keys are toggled: an app mid-rotation still holds the previous
+// credential, and pausing only the current one would leave it sending.
+func SetPaused(ctx context.Context, s release.Store, p Provisioner,
+ acct, app string, paused bool, now time.Time) (*Record, error) {
+
+ rec, etag, err := Get(ctx, s, acct, app)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil || rec.Provisioned == nil {
+ return nil, ErrNotProvisioned
+ }
+ user := rec.Provisioned.IAMUser
+ for _, id := range []string{rec.Provisioned.KeyID, rec.Provisioned.PrevKeyID} {
+ if id == "" {
+ continue
+ }
+ if err := p.SetKeyActive(ctx, user, id, !paused); err != nil {
+ return nil, err
+ }
+ }
+ rec.Provisioned.Active = !paused
+ rec.Provisioned.At = now
+ if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
+ return nil, err
+ }
+ return rec, nil
+}
+
+// CreateStandalone mints a displayed credential on its OWN IAM principal.
+func CreateStandalone(ctx context.Context, s release.Store, p Provisioner, d Deployment,
+ acct, app, name, actor string, now time.Time) (StandaloneCreds, error) {
+
+ // A credential name is not an env prefix — this is just
+ // validStandaloneName, nothing else (controller ruling F5).
+ if !validStandaloneName(name) {
+ return StandaloneCreds{}, fmt.Errorf("mail: invalid credential name %q", name)
+ }
+ rec, etag, err := Get(ctx, s, acct, app)
+ if err != nil {
+ return StandaloneCreds{}, err
+ }
+ if rec == nil || rec.Declared == nil {
+ return StandaloneCreds{}, ErrNotDeclared
+ }
+ domains, err := grantedDomains(ctx, s, acct, app)
+ if err != nil {
+ return StandaloneCreds{}, err
+ }
+ if len(domains) == 0 {
+ return StandaloneCreds{}, 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)
+
+ user := StandaloneUserName(acct, app, name)
+ if _, err := p.EnsureUser(ctx, user, UserPath, arns, names); err != nil {
+ return StandaloneCreds{}, err
+ }
+ // A re-mint for the same name replaces the credential rather than
+ // stacking one: two slots is still the ceiling here too.
+ if err := p.ReapKeys(ctx, user, nil); err != nil {
+ return StandaloneCreds{}, err
+ }
+ keyID, secret, err := p.MintKey(ctx, user)
+ if err != nil {
+ return StandaloneCreds{}, err
+ }
+
+ replaced := false
+ for _, sa := range rec.Standalone {
+ if sa.Name == name {
+ sa.IAMUser, sa.KeyID, sa.At, sa.By = user, keyID, now, actor
+ replaced = true
+ }
+ }
+ if !replaced {
+ rec.Standalone = append(rec.Standalone,
+ &Standalone{Name: name, IAMUser: user, KeyID: keyID, At: now, By: actor})
+ }
+ if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
+ return StandaloneCreds{}, err
+ }
+ return StandaloneCreds{
+ Host: SMTPHost(d.Region), Port: SMTPPort,
+ Username: keyID, Password: SMTPPassword(secret, d.Region),
+ From: rec.Declared.From,
+ }, nil
+}
+
+var standaloneNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,30}$`)
+
+func validStandaloneName(n string) bool { return standaloneNameRe.MatchString(n) }
+
+// RevokeStandalone deletes one displayed credential and its principal. It
+// cannot disturb delivery, which is the point of the separate user.
+func RevokeStandalone(ctx context.Context, s release.Store, p Provisioner, acct, app, name string) error {
+ rec, etag, err := Get(ctx, s, acct, app)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return nil
+ }
+ kept := rec.Standalone[:0]
+ found := false
+ for _, sa := range rec.Standalone {
+ if sa.Name == name {
+ found = true
+ if err := p.DeleteUser(ctx, sa.IAMUser); err != nil {
+ return err
+ }
+ continue
+ }
+ kept = append(kept, sa)
+ }
+ if !found {
+ return nil
+ }
+ rec.Standalone = kept
+ _, err = Put(ctx, s, acct, app, rec, etag)
+ return err
+}
+
+// Teardown dismantles an app's email estate: credentials, principals, and
+// its claim on every domain it referenced.
+//
+// A domain's SES identity and DNS are removed only when the LAST app
+// referencing it goes. Getting that backwards revokes a sibling app's
+// verified domain — silently, and with the customer's own DNS still
+// pointing at an identity that no longer exists.
+func Teardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer,
+ d Deployment, acct, app string) error {
+
+ rec, etag, err := Get(ctx, s, acct, app)
+ if err != nil {
+ return err
+ }
+ if rec != nil {
+ if rec.Provisioned != nil {
+ if err := p.DeleteUser(ctx, rec.Provisioned.IAMUser); err != nil {
+ return err
+ }
+ }
+ for _, sa := range rec.Standalone {
+ if err := p.DeleteUser(ctx, sa.IAMUser); err != nil {
+ return err
+ }
+ }
+ }
+
+ domains, err := ListDomains(ctx, s, acct)
+ if err != nil {
+ return err
+ }
+ for _, dom := range domains {
+ if !dom.RemoveApp(app) {
+ continue
+ }
+ if len(dom.Apps) > 0 {
+ // ListDomains does not carry etags (nothing else needs them), so
+ // the record is re-read here to get a CAS position that is not
+ // "" — an empty etag is CREATE-ONLY, and every one of these
+ // records already exists (controller ruling F6).
+ cur, curEtag, err := GetDomain(ctx, s, acct, dom.Domain)
+ if err != nil {
+ return err
+ }
+ if cur == nil {
+ continue // gone since the list; nothing to write
+ }
+ cur.RemoveApp(app)
+ if _, err := PutDomain(ctx, s, acct, cur, curEtag); err != nil {
+ return err
+ }
+ continue
+ }
+ // Last referrer: remove the identity and, for a platform domain,
+ // the records CARLOS published. A custom domain's records are the
+ // customer's to remove.
+ for region, st := range dom.Regions {
+ if dom.Kind == KindPlatform {
+ for _, r := range RecordSet(dom.Domain, region, st.DKIMTokens, d.DMARCRua) {
+ if err := w.DeleteRecord(ctx, r); err != nil {
+ return err
+ }
+ }
+ }
+ if err := p.DeleteIdentity(ctx, region, dom.Domain); err != nil {
+ return err
+ }
+ }
+ if err := s.Delete(ctx, DomainKey(acct, dom.Domain)); err != nil {
+ return err
+ }
+ }
+
+ if rec != nil {
+ _ = etag
+ if err := s.Delete(ctx, Key(acct, app)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/mail/walk_lifecycle_test.go b/internal/mail/walk_lifecycle_test.go
new file mode 100644
index 000000000..d99ec9aab
--- /dev/null
+++ b/internal/mail/walk_lifecycle_test.go
@@ -0,0 +1,218 @@
+package mail
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/dnsrec"
+)
+
+// Rotation leaves the OLD key ACTIVE. Adoption swaps a symlink; it does not
+// restart processes, so an instance may still be using the old credential
+// for minutes after delivery. Killing it at rotation time is an outage.
+func TestRotateKeepsTheOldKeyLive(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("Enable: %v", err)
+ }
+ old := first.Provisioned.KeyID
+
+ rec, err := Rotate(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now)
+ if err != nil {
+ t.Fatalf("Rotate: %v", err)
+ }
+ if rec.Provisioned.KeyID == old {
+ t.Fatal("Rotate did not mint a new key")
+ }
+ if rec.Provisioned.PrevKeyID != old {
+ t.Fatalf("PrevKeyID = %q, want %q", rec.Provisioned.PrevKeyID, old)
+ }
+ keys := p.Users[UserName("bag", "seapointish")].Keys
+ if _, ok := keys[old]; !ok {
+ t.Fatal("the old key was deleted at rotation — instances still using it would fail")
+ }
+ if !keys[old].Active {
+ t.Fatal("the old key was deactivated at rotation")
+ }
+}
+
+// Finish is the only walk that destroys a credential an app might still be
+// using, so it is explicit and separate.
+func TestRotateFinishClosesTheWindow(t *testing.T) {
+ ctx, s, p, pub, now := enableFixture(t)
+ Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now)
+ rec, _ := Rotate(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now)
+ old := rec.Provisioned.PrevKeyID
+
+ done, err := RotateFinish(ctx, s, p, "bag", "seapointish")
+ if err != nil {
+ t.Fatalf("RotateFinish: %v", err)
+ }
+ if done.Provisioned.PrevKeyID != "" {
+ t.Fatal("PrevKeyID survived the finish")
+ }
+ if _, ok := p.Users[UserName("bag", "seapointish")].Keys[old]; ok {
+ t.Fatal("the old key survived the finish")
+ }
+}
+
+// The pause is SetKeyActive, and it must be reversible with no policy
+// rewrite — a policy rewrite would have to be undone correctly to resume
+// and could half-fail into a broken grant.
+func TestPauseAndResumeToggleTheKeyNotThePolicy(t *testing.T) {
+ ctx, s, p, pub, now := enableFixture(t)
+ rec, _ := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now)
+ user := UserName("bag", "seapointish")
+ policyBefore := p.Users[user].Policy
+
+ paused, err := SetPaused(ctx, s, p, "bag", "seapointish", true, now)
+ if err != nil {
+ t.Fatalf("SetPaused(true): %v", err)
+ }
+ if paused.Provisioned.Active {
+ t.Fatal("record still says active after a pause")
+ }
+ if p.Users[user].Keys[rec.Provisioned.KeyID].Active {
+ t.Fatal("the key is still active after a pause")
+ }
+ if p.Users[user].Policy != policyBefore {
+ t.Fatal("the pause rewrote the policy — it must only toggle the key")
+ }
+
+ resumed, err := SetPaused(ctx, s, p, "bag", "seapointish", false, now)
+ if err != nil {
+ t.Fatalf("SetPaused(false): %v", err)
+ }
+ if !resumed.Provisioned.Active || !p.Users[user].Keys[rec.Provisioned.KeyID].Active {
+ t.Fatal("resume did not reactivate")
+ }
+}
+
+// SetPaused must toggle BOTH keys — an app mid-rotation still holds the
+// previous credential, so pausing only the current one leaves it sending.
+func TestPauseTogglesBothKeysDuringARotationOverlap(t *testing.T) {
+ ctx, s, p, pub, now := enableFixture(t)
+ Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now)
+ rec, err := Rotate(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now)
+ if err != nil {
+ t.Fatalf("Rotate: %v", err)
+ }
+ user := UserName("bag", "seapointish")
+ current := rec.Provisioned.KeyID
+ prev := rec.Provisioned.PrevKeyID
+ if prev == "" {
+ t.Fatal("fixture broken: expected a PrevKeyID after Rotate")
+ }
+
+ paused, err := SetPaused(ctx, s, p, "bag", "seapointish", true, now)
+ if err != nil {
+ t.Fatalf("SetPaused(true): %v", err)
+ }
+ if paused.Provisioned.Active {
+ t.Fatal("record still says active after a pause")
+ }
+ if p.Users[user].Keys[current].Active {
+ t.Fatal("the current key is still active after a pause")
+ }
+ if p.Users[user].Keys[prev].Active {
+ t.Fatal("the previous key (mid-rotation overlap) is still active after a pause — it could still be sending")
+ }
+}
+
+// A standalone credential lives on its OWN IAM user, because the delivered
+// user's two key slots are spent on the delivered key and the rotation
+// overlap. Sharing would work until the first rotation and then break it.
+func TestStandaloneGetsItsOwnUserAndDoesNotDisturbDelivery(t *testing.T) {
+ ctx, s, p, pub, now := enableFixture(t)
+ rec, _ := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now)
+ delivered := rec.Provisioned.KeyID
+
+ creds, err := CreateStandalone(ctx, s, p, testDeployment(), "bag", "seapointish", "laptop", "paul@rslw.com", now)
+ if err != nil {
+ t.Fatalf("CreateStandalone: %v", err)
+ }
+ if creds.Password == "" || creds.Username == "" {
+ t.Fatalf("standalone creds incomplete: %+v", creds)
+ }
+ if creds.Host != "email-smtp.eu-west-1.amazonaws.com" || creds.Port != "587" {
+ t.Fatalf("standalone coordinates wrong: %+v", creds)
+ }
+ own := StandaloneUserName("bag", "seapointish", "laptop")
+ if _, ok := p.Users[own]; !ok {
+ t.Fatal("standalone credential did not get its own IAM user")
+ }
+ // The delivered user still holds exactly its own key.
+ if len(p.Users[UserName("bag", "seapointish")].Keys) != 1 {
+ t.Fatal("standalone minting touched the delivered user's key slots")
+ }
+
+ if err := RevokeStandalone(ctx, s, p, "bag", "seapointish", "laptop"); err != nil {
+ t.Fatalf("RevokeStandalone: %v", err)
+ }
+ if _, ok := p.Users[own]; ok {
+ t.Fatal("revoke left the standalone user behind")
+ }
+ after, _, _ := Get(ctx, s, "bag", "seapointish")
+ if after.Provisioned.KeyID != delivered {
+ t.Fatal("revoking a standalone credential disturbed delivery")
+ }
+}
+
+// CreateStandalone must refuse an invalid name — a credential name is not
+// an env prefix, so ValidEnvPrefix must play no part in the guard (F5).
+func TestCreateStandaloneRefusesAnInvalidName(t *testing.T) {
+ ctx, s, p, _, now := enableFixture(t)
+ if _, err := CreateStandalone(ctx, s, p, testDeployment(), "bag", "seapointish",
+ "Not Valid!", "paul@rslw.com", now); err == nil {
+ t.Fatal("CreateStandalone accepted an invalid name")
+ }
+}
+
+// Teardown removes the identity only when the LAST app referencing it goes.
+// Otherwise one app's teardown revokes a sibling's verified domain.
+func TestTeardownKeepsASharedDomainAlive(t *testing.T) {
+ ctx, s, p, pub, now := enableFixture(t)
+ Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now)
+ // A sibling app joins the same domain.
+ if _, _, err := EnsureDomain(ctx, s, p, memWriterForTest(), testDeployment(),
+ "bag", "marketing", "seapointish.bag.oncarlos.com", now); err != nil {
+ t.Fatalf("sibling EnsureDomain: %v", err)
+ }
+
+ if err := Teardown(ctx, s, p, memWriterForTest(), testDeployment(), "bag", "seapointish"); err != nil {
+ t.Fatalf("Teardown: %v", err)
+ }
+ if _, ok := p.Identities[identityKey("eu-west-1", "seapointish.bag.oncarlos.com")]; !ok {
+ t.Fatal("teardown deleted an identity a sibling app still references")
+ }
+ dom, _, _ := GetDomain(ctx, s, "bag", "seapointish.bag.oncarlos.com")
+ if len(dom.Apps) != 1 || dom.Apps[0] != "marketing" {
+ t.Fatalf("apps = %v, want [marketing]", dom.Apps)
+ }
+}
+
+// Teardown removes the identity and the app's record once it is the LAST
+// referrer.
+func TestTeardownRemovesTheIdentityWhenTheLastAppGoes(t *testing.T) {
+ ctx, s, p, pub, now := enableFixture(t)
+ Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now)
+
+ if err := Teardown(ctx, s, p, memWriterForTest(), testDeployment(), "bag", "seapointish"); err != nil {
+ t.Fatalf("Teardown: %v", err)
+ }
+ if _, ok := p.Identities[identityKey("eu-west-1", "seapointish.bag.oncarlos.com")]; ok {
+ t.Fatal("teardown left the identity behind after the last app went")
+ }
+ if _, ok := p.Users[UserName("bag", "seapointish")]; ok {
+ t.Fatal("teardown left the delivered IAM user behind")
+ }
+ rec, _, err := Get(ctx, s, "bag", "seapointish")
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if rec != nil {
+ t.Fatal("teardown left the app's email record behind")
+ }
+}
+
+func memWriterForTest() dnsrec.Writer { return dnsrec.NewMemWriter() }