| 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 |
| +} |