carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The identity record (spec §6a): accounts/<sqid>/email/domains/<domain>.json.
2//
3// ACCOUNT-scoped, not app-scoped, and the reason is narrow: two apps in one
4// account may legitimately share one custom domain, and only one of them may
5// create the SES identity and write its DNS. A double-create is the failure
6// this split exists to prevent. In the common case there is exactly one such
7// record per app, auto-created, and nobody looks at it.
8package mail
9
10import (
11 "context"
12 "encoding/json"
13 "errors"
14 "fmt"
15 "os"
16 "regexp"
17 "sort"
18 "strings"
19 "time"
20
21 "github.com/carlosframework/platform/internal/release"
22)
23
24// The closed status set. Only StatusVerified puts an identity's ARN into a
25// credential's policy, so a pending domain grants nothing.
26const (
27 StatusPending = "pending"
28 StatusVerified = "verified"
29 StatusFailed = "failed"
30)
31
32// KindPlatform means CARLOS owns the zone and publishes DNS itself;
33// KindCustom means the customer does. It is the one bit that decides who
34// publishes (spec §7).
35const (
36 KindPlatform = "platform"
37 KindCustom = "custom"
38)
39
40// RegionState is one region's identity. Everything in here is regional:
41// SES issues a distinct identity, distinct DKIM tokens and a distinct
42// MAIL FROM MX target per region (spec §8).
43type RegionState struct {
44 IdentityARN string `json:"identity_arn"`
45 DKIMTokens []string `json:"dkim_tokens"`
46 MailFrom string `json:"mail_from"`
47 ConfigSet string `json:"config_set"`
48 Status string `json:"status"`
49 CheckedAt time.Time `json:"checked_at"`
50 // Adopted marks an identity this account did not create but proved it
51 // controls the domain of (#216). CARLOS sends on it and never deletes
52 // it. Absent on every record written before it existed, which keeps
53 // those identities torn down as they always were.
54 Adopted bool `json:"adopted,omitempty"`
55}
56
57// DomainRecord is one sending domain.
58//
59// Regions is a MAP rather than a scalar field. Every SES dimension that
60// matters is regional, so a scalar would have to be migrated across the
61// fleet the first time a second region appears. Keying costs nothing today
62// and is the difference between adding a region and a migration.
63type DomainRecord struct {
64 Schema int `json:"schema"`
65 Domain string `json:"domain"`
66 Kind string `json:"kind"`
67 Apps []string `json:"apps"`
68 Regions map[string]*RegionState `json:"regions"`
69 CreatedAt time.Time `json:"created_at"`
70}
71
72func DomainsPrefix(acct string) string { return "accounts/" + acct + "/email/domains/" }
73
74func DomainKey(acct, domain string) string { return DomainsPrefix(acct) + domain + ".json" }
75
76// A conservative LDH hostname: lowercase labels of alphanumerics and inner
77// hyphens, at least two labels. Deliberately stricter than DNS allows,
78// because this string becomes a bucket key AND an IAM policy resource, and
79// the only safe place to refuse a traversal or an injection is the one
80// gate every caller passes through.
81var domainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`)
82
83func ValidDomain(d string) bool {
84 return len(d) <= 253 && domainRe.MatchString(d)
85}
86
87func GetDomain(ctx context.Context, s release.Store, acct, domain string) (*DomainRecord, string, error) {
88 if !release.ValidAccount(acct) {
89 return nil, "", fmt.Errorf("mail: invalid account %q", acct)
90 }
91 if !ValidDomain(domain) {
92 return nil, "", fmt.Errorf("mail: invalid domain %q", domain)
93 }
94 raw, etag, err := s.Get(ctx, DomainKey(acct, domain))
95 if err != nil {
96 if errors.Is(err, os.ErrNotExist) {
97 return nil, "", nil
98 }
99 return nil, "", err
100 }
101 var rec DomainRecord
102 if err := json.Unmarshal(raw, &rec); err != nil {
103 return nil, "", fmt.Errorf("mail: %s: %w", DomainKey(acct, domain), err)
104 }
105 return &rec, etag, nil
106}
107
108func claimUnserializedPutDomain(ctx context.Context, s release.Store, acct string, rec *DomainRecord, etag string) (string, error) {
109 if !ValidDomain(rec.Domain) {
110 return "", fmt.Errorf("mail: invalid domain %q", rec.Domain)
111 }
112 rec.Schema = Schema
113 body, err := json.MarshalIndent(rec, "", " ")
114 if err != nil {
115 return "", err
116 }
117 return s.Put(ctx, DomainKey(acct, rec.Domain), append(body, '\n'), etag)
118}
119
120// ListDomains reads every identity record for an account. A key that fails
121// to parse is skipped rather than failing the listing: one malformed record
122// must not hide an account's other domains from the console.
123func ListDomains(ctx context.Context, s release.Store, acct string) ([]*DomainRecord, error) {
124 keys, err := s.List(ctx, DomainsPrefix(acct))
125 if err != nil {
126 return nil, err
127 }
128 var out []*DomainRecord
129 for _, k := range keys {
130 if !strings.HasSuffix(k, ".json") {
131 continue
132 }
133 raw, _, err := s.Get(ctx, k)
134 if err != nil {
135 continue
136 }
137 var rec DomainRecord
138 if json.Unmarshal(raw, &rec) != nil {
139 continue
140 }
141 out = append(out, &rec)
142 }
143 sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain })
144 return out, nil
145}
146
147// StrictListDomains is ListDomains for the enabled paths that must see EVERY
148// record of the account: an unreadable, malformed, newer-schema or
149// key-mismatched record aborts with ErrDomainInventory instead of being
150// skipped.
151func StrictListDomains(ctx context.Context, s release.Store, acct string) ([]*DomainRecord, error) {
152 if !release.ValidAccount(acct) {
153 return nil, fmt.Errorf("mail: invalid account %q", acct)
154 }
155 keys, err := s.List(ctx, DomainsPrefix(acct))
156 if err != nil {
157 return nil, err
158 }
159 out := make([]*DomainRecord, 0, len(keys))
160 seen := make(map[string]struct{}, len(keys))
161 for _, k := range keys {
162 // A repeated key in the listing must count once.
163 if _, dup := seen[k]; dup {
164 continue
165 }
166 seen[k] = struct{}{}
167 raw, _, err := s.Get(ctx, k)
168 if err != nil {
169 return nil, fmt.Errorf("%w: record %s: %v", ErrDomainInventory, k, err)
170 }
171 var rec DomainRecord
172 if err := json.Unmarshal(raw, &rec); err != nil {
173 return nil, fmt.Errorf("%w: record %s: unparseable: %v", ErrDomainInventory, k, err)
174 }
175 if rec.Schema < 0 || rec.Schema > Schema || !ValidDomain(rec.Domain) || DomainKey(acct, rec.Domain) != k {
176 return nil, fmt.Errorf("%w: record %s: unsupported or mismatched body", ErrDomainInventory, k)
177 }
178 for _, app := range rec.Apps {
179 if !release.ValidApp(app) {
180 return nil, fmt.Errorf("%w: record %s: invalid member", ErrDomainInventory, k)
181 }
182 }
183 for region, state := range rec.Regions {
184 if region == "" || state == nil {
185 return nil, fmt.Errorf("%w: record %s: invalid region", ErrDomainInventory, k)
186 }
187 }
188 out = append(out, &rec)
189 }
190 sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain })
191 return out, nil
192}
193
194// VerifiedARNs is every identity ARN this domain contributes to a
195// credential's policy. Only verified regions count.
196func (r *DomainRecord) VerifiedARNs() []string {
197 var out []string
198 for _, st := range r.Regions {
199 if st.Status == StatusVerified && st.IdentityARN != "" {
200 out = append(out, st.IdentityARN)
201 }
202 }
203 sort.Strings(out)
204 return out
205}
206
207// VerifiedConfigSetARNs is every configuration-set ARN this domain
208// contributes to a credential's policy (issue #271). Only a verified region
209// that actually HAS a set bound contributes one — a pending region's
210// identity is not usable yet, and a verified region with no set is the
211// ordinary shape on a deployment with no SNS topic.
212//
213// The region and account are taken from the region's own IdentityARN rather
214// than passed in, so the config-set ARN cannot name a different account or
215// region from the identity it pairs with.
216func (r *DomainRecord) VerifiedConfigSetARNs() []string {
217 var out []string
218 for _, st := range r.Regions {
219 if st == nil || st.Status != StatusVerified || st.ConfigSet == "" || st.IdentityARN == "" {
220 continue
221 }
222 if arn, ok := ConfigSetARN(st.IdentityARN, st.ConfigSet); ok {
223 out = append(out, arn)
224 }
225 }
226 sort.Strings(out)
227 return out
228}
229
230// ConfigSetARN composes a configuration set's ARN from an identity ARN in
231// the same account and region, which is the only place this package has
232// those two values. Reports false for an ARN it cannot parse, so a caller
233// never builds a resource string out of a malformed one.
234func ConfigSetARN(identityARN, configSet string) (string, bool) {
235 if identityARN == "" || configSet == "" {
236 return "", false
237 }
238 parts := strings.Split(identityARN, ":")
239 if len(parts) != 6 {
240 return "", false
241 }
242 return strings.Join(parts[:5], ":") + ":configuration-set/" + configSet, true
243}
244
245// AddApp records an app as a referrer. Reports whether it changed anything.
246func (r *DomainRecord) AddApp(app string) bool {
247 for _, a := range r.Apps {
248 if a == app {
249 return false
250 }
251 }
252 r.Apps = append(r.Apps, app)
253 sort.Strings(r.Apps)
254 return true
255}
256
257// RemoveApp drops a referrer. The SES identity and its DNS are deleted only
258// when the set empties — without this, one app's teardown would silently
259// revoke a sibling app's verified domain.
260func (r *DomainRecord) RemoveApp(app string) bool {
261 for i, a := range r.Apps {
262 if a == app {
263 r.Apps = append(r.Apps[:i], r.Apps[i+1:]...)
264 return true
265 }
266 }
267 return false
268}
269
270func PutDomain(ctx context.Context, s release.Store, acct string, rec *DomainRecord, etag string) (string, error) {
271 if _, enabled := s.(ClaimMailStore); !enabled {
272 return claimUnserializedPutDomain(ctx, s, acct, rec, etag)
273 }
274 var claimResult0 string
275 claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error {
276 return claimMailAdmitted(ctx, s, acct, claimPutDomainPlan(rec), func(ctx context.Context) error {
277 var err error
278 claimResult0, err = claimUnserializedPutDomain(ctx, s, acct, rec, etag)
279 return err
280 })
281 })
282 return claimResult0, claimErr
283}
284