carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "strings"
11
12 "github.com/carlosframework/platform/internal/appconfig"
13 "github.com/carlosframework/platform/internal/dnsrec"
14 "github.com/carlosframework/platform/internal/hostops"
15 "github.com/carlosframework/platform/internal/mail"
16 "github.com/carlosframework/platform/internal/objstore"
17 "github.com/carlosframework/platform/internal/release"
18)
19
20// Backend boundary. Each wrapper holds its raw backend in a NAMED private
21// field and implements every interface method explicitly; a method added
22// to the interface fails to compile here (compile-time assertions) and the
23// reflect inventory in TestClaimConsoleMutationInvariant fails if a wrapper
24// would forward it unguarded. Mutating methods resolve the resource's owners
25// from durable name records and require a live permit for each before the
26// raw call. Reads keep their values and errors.
27
28var errBackendUnresolved = fmt.Errorf("%w: backend resource has no resolvable owner", hostops.ErrUnclassified)
29
30// errBackendUnproven refuses a resource whose ownership is not proven by a
31// durable create-only name record in its exact deterministic form (review
32// P2-8). App-writable records (store records, grant blocks) never prove it.
33var errBackendUnproven = fmt.Errorf("%w: backend resource ownership is not proven by a durable name claim", hostops.ErrUnclassified)
34
35// backendObserver is the fixture hook (nil in production).
36type backendEvent struct {
37 Surface, Method, Resource string
38 Scopes []hostops.AppScope
39 Verdict error
40}
41
42type backendGuard struct {
43 data release.Store
44 control *hostops.ControlWriter
45 observe func(backendEvent)
46 // deployment names minted buckets (objstore.MintedBucketIn). Required:
47 // a zero value proves no minted bucket, so every bucket effect but
48 // EnsureBucket refuses.
49 deployment objstore.Deployment
50}
51
52func (g backendGuard) authorize(ctx context.Context, surface, method, resource string, scopes []hostops.AppScope, mailGate bool, resolveErr error) error {
53 err := resolveErr
54 if err == nil && len(scopes) == 0 {
55 err = errBackendUnresolved
56 }
57 if err == nil {
58 err = hostops.AuthorizeScopes(ctx, g.data, g.control, hostops.Classification{Family: surface, Scopes: scopes, MailGate: mailGate})
59 }
60 if g.observe != nil {
61 g.observe(backendEvent{Surface: surface, Method: method, Resource: resource, Scopes: scopes, Verdict: err})
62 }
63 if err == nil {
64 hostops.MarkAppMutationIssued(ctx)
65 }
66 return err
67}
68
69// permittedApps lists the distinct apps holding a valid permit in ctx: the
70// only candidates a name may be matched against when no name record exists.
71func (g backendGuard) permittedApps(ctx context.Context) ([]hostops.AppScope, error) {
72 permits, err := hostops.LivePermits(ctx, g.data)
73 if err != nil {
74 return nil, err
75 }
76 seen := map[hostops.AppScope]bool{}
77 var out []hostops.AppScope
78 for _, p := range permits {
79 s := hostops.AppScope{Account: p.Account, App: p.App}
80 if !seen[s] {
81 seen[s] = true
82 out = append(out, s)
83 }
84 }
85 return out, nil
86}
87
88// ---- object storage ----------------------------------------------------------
89
90// nameClaim strictly reads the create-only objstore name claim for name.
91func (g backendGuard) nameClaim(ctx context.Context, kind, name string) (objstore.NameClaim, bool, error) {
92 body, _, err := g.data.Get(ctx, "objstore/name-claims/"+kind+"/"+name+".json")
93 if errors.Is(err, os.ErrNotExist) {
94 return objstore.NameClaim{}, false, nil
95 }
96 if err != nil {
97 return objstore.NameClaim{}, false, err
98 }
99 dec := json.NewDecoder(bytes.NewReader(body))
100 dec.DisallowUnknownFields()
101 var c objstore.NameClaim
102 if err = dec.Decode(&c); err != nil {
103 return objstore.NameClaim{}, false, hostops.ErrIdentity
104 }
105 if c.Schema != 1 || c.Name != name || !release.ValidAccount(c.Account) || !release.ValidApp(c.App) ||
106 (c.Environment != "" && !appconfig.ValidEnvironmentName(c.Environment)) {
107 return objstore.NameClaim{}, false, hostops.ErrIdentity
108 }
109 return c, true, nil
110}
111
112// resolveStore (review P2-8). The ONLY owner evidence is the durable name
113// claim, which the grant walk writes before any backend call. No claim:
114// every mutating method refuses (no fallback to an app-writable store
115// record, which let an app point its record at another tenant's unclaimed
116// bucket and then empty or delete it). With a claim, the name must be the
117// claim holder's exact deterministic form, derived forward (never parsed
118// back at hyphens): users are always objstore.UserNameIn; a minted bucket is
119// objstore.MintedBucketIn. An adopted (non-minted) bucket name is unproven:
120// only EnsureBucket (create-if-absent, no destruction, no credential) is
121// allowed on it; policy grants (EnsureUser), scanner writes, empty and
122// delete refuse.
123func (g backendGuard) resolveStore(ctx context.Context, kind, name, method string) ([]hostops.AppScope, error) {
124 if name == "" || strings.ContainsAny(name, "/") || (kind == "buckets" && !objstore.ValidBucketName(name)) {
125 return nil, hostops.ErrIdentity
126 }
127 c, ok, err := g.nameClaim(ctx, kind, name)
128 if err != nil {
129 return nil, err
130 }
131 if !ok {
132 return nil, errBackendUnproven
133 }
134 switch kind {
135 case "users":
136 if name != objstore.UserNameIn(c.Account, c.App, c.Environment) {
137 return nil, errBackendUnproven
138 }
139 case "buckets":
140 if name != objstore.MintedBucketIn(g.deployment, c.Account, c.App, c.Environment) && method != "EnsureBucket" {
141 return nil, errBackendUnproven
142 }
143 default:
144 return nil, hostops.ErrIdentity
145 }
146 return []hostops.AppScope{{Account: c.Account, App: c.App}}, nil
147}
148
149type claimStoreProvisioner struct {
150 raw objstore.Provisioner
151 g backendGuard
152}
153
154var _ objstore.Provisioner = claimStoreProvisioner{}
155
156func newClaimStoreProvisioner(raw objstore.Provisioner, g backendGuard) objstore.Provisioner {
157 if raw == nil {
158 return nil
159 }
160 return claimStoreProvisioner{raw: raw, g: g}
161}
162
163func (p claimStoreProvisioner) bucket(ctx context.Context, method, name string) error {
164 scopes, err := p.g.resolveStore(ctx, "buckets", name, method)
165 return p.g.authorize(ctx, "objstore", method, "bucket:"+name, scopes, false, err)
166}
167func (p claimStoreProvisioner) user(ctx context.Context, method, name string) error {
168 scopes, err := p.g.resolveStore(ctx, "users", name, method)
169 return p.g.authorize(ctx, "objstore", method, "iam-user:"+name, scopes, false, err)
170}
171
172// userPolicy authorizes EnsureUser: the user, the bucket its policy names,
173// and that both belong to the SAME app (a context carrying permits for two
174// apps must not grant one app's principal the other's bucket).
175func (p claimStoreProvisioner) userPolicy(ctx context.Context, name, bucketARN string) error {
176 if err := p.user(ctx, "EnsureUser", name); err != nil {
177 return err
178 }
179 b, ok := strings.CutPrefix(bucketARN, "arn:aws:s3:::")
180 if !ok {
181 return errBackendUnresolved
182 }
183 if err := p.bucket(ctx, "EnsureUser:bucket", b); err != nil {
184 return err
185 }
186 uc, _, err := p.g.nameClaim(ctx, "users", name)
187 if err != nil {
188 return err
189 }
190 bc, _, err := p.g.nameClaim(ctx, "buckets", b)
191 if err != nil {
192 return err
193 }
194 if uc.Account != bc.Account || uc.App != bc.App || uc.Environment != bc.Environment {
195 return errBackendUnproven
196 }
197 return nil
198}
199func (p claimStoreProvisioner) EnsureBucket(ctx context.Context, name string) (string, error) {
200 if err := p.bucket(ctx, "EnsureBucket", name); err != nil {
201 return "", err
202 }
203 return p.raw.EnsureBucket(ctx, name)
204}
205func (p claimStoreProvisioner) EnsureUser(ctx context.Context, name, path, bucketARN string, deny *objstore.DenyTag) (string, error) {
206 if err := p.userPolicy(ctx, name, bucketARN); err != nil {
207 return "", err
208 }
209 return p.raw.EnsureUser(ctx, name, path, bucketARN, deny)
210}
211func (p claimStoreProvisioner) MintKey(ctx context.Context, user string) (string, string, error) {
212 if err := p.user(ctx, "MintKey", user); err != nil {
213 return "", "", err
214 }
215 return p.raw.MintKey(ctx, user)
216}
217func (p claimStoreProvisioner) DeleteKey(ctx context.Context, user, id string) error {
218 if err := p.user(ctx, "DeleteKey", user); err != nil {
219 return err
220 }
221 return p.raw.DeleteKey(ctx, user, id)
222}
223func (p claimStoreProvisioner) ReapKeys(ctx context.Context, user string, keep []string) error {
224 if err := p.user(ctx, "ReapKeys", user); err != nil {
225 return err
226 }
227 return p.raw.ReapKeys(ctx, user, keep)
228}
229func (p claimStoreProvisioner) EmptyBucket(ctx context.Context, name string) error {
230 if err := p.bucket(ctx, "EmptyBucket", name); err != nil {
231 return err
232 }
233 return p.raw.EmptyBucket(ctx, name)
234}
235func (p claimStoreProvisioner) DeleteBucket(ctx context.Context, name string) error {
236 if err := p.bucket(ctx, "DeleteBucket", name); err != nil {
237 return err
238 }
239 return p.raw.DeleteBucket(ctx, name)
240}
241func (p claimStoreProvisioner) DeleteUser(ctx context.Context, user string) error {
242 if err := p.user(ctx, "DeleteUser", user); err != nil {
243 return err
244 }
245 return p.raw.DeleteUser(ctx, user)
246}
247
248type claimStoreScanner struct {
249 raw objstore.Scanner
250 g backendGuard
251}
252
253var _ objstore.Scanner = claimStoreScanner{}
254
255func newClaimStoreScanner(raw objstore.Scanner, g backendGuard) objstore.Scanner {
256 if raw == nil {
257 return nil
258 }
259 return claimStoreScanner{raw: raw, g: g}
260}
261
262func (s claimStoreScanner) bucket(ctx context.Context, method, name string) error {
263 scopes, err := s.g.resolveStore(ctx, "buckets", name, method)
264 return s.g.authorize(ctx, "scanner", method, "bucket:"+name, scopes, false, err)
265}
266func (s claimStoreScanner) EnsureEnabled(ctx context.Context, bucket string) error {
267 if err := s.bucket(ctx, "EnsureEnabled", bucket); err != nil {
268 return err
269 }
270 return s.raw.EnsureEnabled(ctx, bucket)
271}
272func (s claimStoreScanner) EnsureDisabled(ctx context.Context, bucket string) error {
273 if err := s.bucket(ctx, "EnsureDisabled", bucket); err != nil {
274 return err
275 }
276 return s.raw.EnsureDisabled(ctx, bucket)
277}
278func (s claimStoreScanner) Verdict(ctx context.Context, bucket, key, version string) (objstore.Verdict, error) {
279 return s.raw.Verdict(ctx, bucket, key, version)
280}
281func (s claimStoreScanner) Status(ctx context.Context, bucket, key string) (objstore.Verdict, objstore.ScanObject, error) {
282 return s.raw.Status(ctx, bucket, key)
283}
284
285// Scan records a verdict on the object: an effect.
286func (s claimStoreScanner) Scan(ctx context.Context, bucket, key, version string, size int64) (objstore.Verdict, bool, error) {
287 if err := s.bucket(ctx, "Scan", bucket); err != nil {
288 return objstore.VerdictUnknown, false, err
289 }
290 return s.raw.Scan(ctx, bucket, key, version, size)
291}
292func (s claimStoreScanner) Enforcement() objstore.DenyTag { return s.raw.Enforcement() }
293func (s claimStoreScanner) Capacity(ctx context.Context) (int, int, error) {
294 return s.raw.Capacity(ctx)
295}
296func (s claimStoreScanner) Provenance(ctx context.Context) (string, string, error) {
297 return s.raw.Provenance(ctx)
298}
299
300// ---- mail and DNS -------------------------------------------------------------
301
302// accountsInScope: accounts of every permitted app plus the held gate scope.
303func (g backendGuard) accountsInScope(ctx context.Context) ([]string, error) {
304 apps, err := g.permittedApps(ctx)
305 if err != nil {
306 return nil, err
307 }
308 seen := map[string]bool{}
309 var out []string
310 for _, a := range apps {
311 if !seen[a.Account] {
312 seen[a.Account] = true
313 out = append(out, a.Account)
314 }
315 }
316 return out, nil
317}
318
319// domainUse is what one SES identity or DNS effect needs from the owner of
320// the domain it names.
321type domainUse int
322
323const (
324 // domainTouch: the account domain record's members, or, before the
325 // record exists, the app on this account's global claim. Binds and
326 // associations (written after the record), disassociations and DNS
327 // deletes.
328 domainTouch domainUse = iota
329 // domainCreate: a NEW identity or published record (EnsureIdentity,
330 // EnsureRecord). The domain must be held by a permitted account's
331 // global claim, and every permitted account record for it must be that
332 // account's. A record alone never proves it: it is account data any
333 // permitted mail walk of the account can write, while the claim is the
334 // create-only, cross-account unique gate EnsureDomain passes first.
335 domainCreate
336 // domainDestroy: DeleteIdentity. domainCreate's binding, and the claim
337 // must say this account's walk created the identity in that region (the
338 // rule every delete in the mail package applies). An identity adopted,
339 // found, or left by a lapsed holder is never deletable here.
340 domainDestroy
341)
342
343// domainClaim strictly reads the global mail domain claim for domain.
344func (g backendGuard) domainClaim(ctx context.Context, domain string) (mail.DomainClaim, bool, error) {
345 body, _, err := g.data.Get(ctx, mail.DomainClaimKey(domain))
346 if errors.Is(err, os.ErrNotExist) {
347 return mail.DomainClaim{}, false, nil
348 }
349 if err != nil {
350 return mail.DomainClaim{}, false, err
351 }
352 var c mail.DomainClaim
353 if err = json.Unmarshal(body, &c); err != nil {
354 return mail.DomainClaim{}, false, hostops.ErrIdentity
355 }
356 if c.Schema < 0 || c.Schema > 1 || c.Domain != domain || !release.ValidAccount(c.Account) || (c.App != "" && !release.ValidApp(c.App)) {
357 return mail.DomainClaim{}, false, hostops.ErrIdentity
358 }
359 return c, true, nil
360}
361
362// domainRecordApps reads acct's record for exactly domain.
363func (g backendGuard) domainRecordApps(ctx context.Context, acct, domain string) ([]string, bool, error) {
364 body, _, err := g.data.Get(ctx, mail.DomainKey(acct, domain))
365 if errors.Is(err, os.ErrNotExist) {
366 return nil, false, nil
367 }
368 if err != nil {
369 return nil, false, err
370 }
371 var rec mail.DomainRecord
372 if err = json.Unmarshal(body, &rec); err != nil {
373 return nil, false, hostops.ErrIdentity
374 }
375 if rec.Domain != domain {
376 return nil, false, hostops.ErrIdentity
377 }
378 for _, app := range rec.Apps {
379 if !release.ValidApp(app) {
380 return nil, false, hostops.ErrIdentity
381 }
382 }
383 return rec.Apps, true, nil
384}
385
386// domainCandidates: name itself, then (suffix) each parent with at least
387// two labels, longest first. Names that are not valid domains (a DKIM
388// "_domainkey" label) are skipped; no key is ever built from them.
389func domainCandidates(name string, suffix bool) []string {
390 var out []string
391 for cand := name; strings.Count(cand, ".") >= 1; {
392 if mail.ValidDomain(cand) {
393 out = append(out, cand)
394 }
395 if !suffix {
396 break
397 }
398 _, rest, _ := strings.Cut(cand, ".")
399 cand = rest
400 }
401 return out
402}
403
404// domainMembers resolves an SES identity (exact) or DNS name (longest owning
405// suffix) to the complete set of apps it affects, bound to the domain's
406// owner by use. Permitted accounts are the accounts of the apps holding
407// live permits in ctx.
408//
409// At each candidate, longest first: a global claim held by an account with
410// no permit here stops the search and refuses (another account owns that
411// name, whatever a shorter suffix says). Otherwise a permitted account's
412// record, or a permitted account's claim, resolves it: the record's members
413// plus the claim's app. EnsureDomain writes the claim before EnsureIdentity
414// and before any DNS publish, so the identity it creates is bound to the
415// claim from its first call, before the domain record exists.
416func (g backendGuard) domainMembers(ctx context.Context, name string, suffix bool, use domainUse, region string) ([]hostops.AppScope, error) {
417 accounts, err := g.accountsInScope(ctx)
418 if err != nil {
419 return nil, err
420 }
421 if len(accounts) == 0 {
422 return nil, errBackendUnresolved
423 }
424 permitted := map[string]bool{}
425 for _, a := range accounts {
426 permitted[a] = true
427 }
428 for _, cand := range domainCandidates(strings.TrimSuffix(strings.ToLower(name), "."), suffix) {
429 claim, claimed, err := g.domainClaim(ctx, cand)
430 if err != nil {
431 return nil, err
432 }
433 if claimed && !permitted[claim.Account] {
434 return nil, errBackendUnproven
435 }
436 var scopes []hostops.AppScope
437 var owners []string
438 for _, acct := range accounts {
439 apps, ok, err := g.domainRecordApps(ctx, acct, cand)
440 if err != nil {
441 return nil, err
442 }
443 if !ok {
444 continue
445 }
446 owners = append(owners, acct)
447 for _, app := range apps {
448 scopes = append(scopes, hostops.AppScope{Account: acct, App: app})
449 }
450 }
451 if len(owners) == 0 && !claimed {
452 continue
453 }
454 if use != domainTouch {
455 if !claimed {
456 return nil, errBackendUnproven
457 }
458 for _, o := range owners {
459 if o != claim.Account {
460 return nil, errBackendUnproven
461 }
462 }
463 if use == domainDestroy && (region == "" || !claim.CreatedIdentityIn(region)) {
464 return nil, errBackendUnproven
465 }
466 }
467 if claimed && claim.App != "" {
468 scopes = append(scopes, hostops.AppScope{Account: claim.Account, App: claim.App})
469 }
470 if len(scopes) == 0 {
471 // A record with no members and a claim with no app: nothing
472 // names an app whose permit could cover it.
473 return nil, errBackendUnresolved
474 }
475 return dedupeScopes(scopes), nil
476 }
477 return nil, errBackendUnresolved
478}
479
480func dedupeScopes(in []hostops.AppScope) []hostops.AppScope {
481 seen := map[hostops.AppScope]bool{}
482 var out []hostops.AppScope
483 for _, s := range in {
484 if !seen[s] {
485 seen[s] = true
486 out = append(out, s)
487 }
488 }
489 return out
490}
491
492func (g backendGuard) accountByName(ctx context.Context, name string, derive func(string) string) ([]hostops.AppScope, error) {
493 apps, err := g.permittedApps(ctx)
494 if err != nil {
495 return nil, err
496 }
497 var out []hostops.AppScope
498 for _, a := range apps {
499 if derive(a.Account) == name {
500 out = append(out, a)
501 }
502 }
503 return out, nil
504}
505
506// resolveMailUser (review P2-8). Two proofs, both exact and forward:
507//
508// - The strict caller index for this user name, and the name must be one
509// the indexed (account, app, sender) renders (mail.SenderOwnsUserName).
510// An index can therefore never pull a principal out of the namespace
511// its own owner renders, whatever an index writer put in it.
512// - No index: a permitted app whose OWN forward rendering is this name:
513// the delivered user mail.UserName(account, app), or a standalone
514// credential mail.StandaloneUserName(account, app, n) for a valid n.
515// That covers CreateStandalone, whose EnsureUser runs before its index
516// is written.
517//
518// Nothing is parsed back out of user. The "+s+" form is matched by rendering
519// the permitted app's own prefix and re-rendering the whole name, so a name
520// in another app's namespace never matches: an account sqid has no hyphen,
521// and neither an app nor a credential name can contain "+", so the rendered
522// forms of two different (account, app, credential) triples never collide
523// (app "shop" with credential "eu" is "...-shop+s+eu", never "...-shop-eu").
524func (g backendGuard) resolveMailUser(ctx context.Context, user string) ([]hostops.AppScope, error) {
525 if user == "" || strings.Contains(user, "/") {
526 return nil, hostops.ErrIdentity
527 }
528 body, _, err := g.data.Get(ctx, mail.CallerKey(user))
529 switch {
530 case errors.Is(err, os.ErrNotExist):
531 case err != nil:
532 return nil, err
533 default:
534 dec := json.NewDecoder(bytes.NewReader(body))
535 dec.DisallowUnknownFields()
536 var c mail.CallerIndex
537 if err = dec.Decode(&c); err != nil || c.Schema != 1 || c.IAMUser != user || !release.ValidAccount(c.Account) || !release.ValidApp(c.App) {
538 return nil, hostops.ErrIdentity
539 }
540 sender := c.Sender
541 if sender == "" {
542 sender = mail.SenderAppID // written before senders existed
543 }
544 if !mail.SenderOwnsUserName(c.Account, c.App, sender, user) {
545 return nil, errBackendUnproven
546 }
547 return []hostops.AppScope{{Account: c.Account, App: c.App}}, nil
548 }
549 apps, err := g.permittedApps(ctx)
550 if err != nil {
551 return nil, err
552 }
553 var out []hostops.AppScope
554 for _, a := range apps {
555 if mail.SenderOwnsUserName(a.Account, a.App, mail.SenderAppID, user) {
556 out = append(out, a)
557 }
558 }
559 if len(out) == 0 {
560 return nil, errBackendUnproven
561 }
562 return out, nil
563}
564
565type claimMailProvisioner struct {
566 raw mail.Provisioner
567 g backendGuard
568}
569
570var _ mail.Provisioner = claimMailProvisioner{}
571
572func newClaimMailProvisioner(raw mail.Provisioner, g backendGuard) mail.Provisioner {
573 if raw == nil {
574 return nil
575 }
576 return claimMailProvisioner{raw: raw, g: g}
577}
578
579func (p claimMailProvisioner) account(ctx context.Context, method, name string, derive func(string) string) error {
580 scopes, err := p.g.accountByName(ctx, name, derive)
581 return p.g.authorize(ctx, "mail", method, "account-resource:"+name, scopes, true, err)
582}
583func (p claimMailProvisioner) domain(ctx context.Context, method, domain string, use domainUse, region string) error {
584 scopes, err := p.g.domainMembers(ctx, domain, false, use, region)
585 return p.g.authorize(ctx, "mail", method, "ses-identity:"+domain, scopes, true, err)
586}
587func (p claimMailProvisioner) user(ctx context.Context, method, user string) error {
588 scopes, err := p.g.resolveMailUser(ctx, user)
589 return p.g.authorize(ctx, "mail", method, "iam-user:"+user, scopes, true, err)
590}
591
592// arnResource resolves an SES ARN: an identity names its domain; anything
593// else (configuration set) is the tenant's account-level resource.
594func (p claimMailProvisioner) arnResource(ctx context.Context, method, tenant, arn string) error {
595 if _, domain, ok := strings.Cut(arn, ":identity/"); ok {
596 if err := p.domain(ctx, method, domain, domainTouch, ""); err != nil {
597 return err
598 }
599 }
600 return p.account(ctx, method, tenant, mail.TenantName)
601}
602
603func (p claimMailProvisioner) EnsureTenant(ctx context.Context, name string) error {
604 if err := p.account(ctx, "EnsureTenant", name, mail.TenantName); err != nil {
605 return err
606 }
607 return p.raw.EnsureTenant(ctx, name)
608}
609func (p claimMailProvisioner) EnsureConfigSet(ctx context.Context, region, name, topic string) error {
610 if err := p.account(ctx, "EnsureConfigSet", name, mail.ConfigSetName); err != nil {
611 return err
612 }
613 return p.raw.EnsureConfigSet(ctx, region, name, topic)
614}
615func (p claimMailProvisioner) SetIdentityConfigSet(ctx context.Context, region, domain, configSet string) error {
616 if err := p.domain(ctx, "SetIdentityConfigSet", domain, domainTouch, region); err != nil {
617 return err
618 }
619 // An empty configSet is an unbinding (SNS disabled): there is no set
620 // resource to resolve, and the domain authorization above is the whole
621 // of what the call needs.
622 if configSet != "" {
623 if err := p.account(ctx, "SetIdentityConfigSet", configSet, mail.ConfigSetName); err != nil {
624 return err
625 }
626 }
627 return p.raw.SetIdentityConfigSet(ctx, region, domain, configSet)
628}
629func (p claimMailProvisioner) AssociateTenantResource(ctx context.Context, tenant, arn string) error {
630 if err := p.arnResource(ctx, "AssociateTenantResource", tenant, arn); err != nil {
631 return err
632 }
633 return p.raw.AssociateTenantResource(ctx, tenant, arn)
634}
635func (p claimMailProvisioner) DisassociateTenantResource(ctx context.Context, tenant, arn string) error {
636 if err := p.arnResource(ctx, "DisassociateTenantResource", tenant, arn); err != nil {
637 return err
638 }
639 return p.raw.DisassociateTenantResource(ctx, tenant, arn)
640}
641func (p claimMailProvisioner) EnsureIdentity(ctx context.Context, region, domain, mailFrom string, adopt bool) (mail.Identity, error) {
642 if err := p.domain(ctx, "EnsureIdentity", domain, domainCreate, region); err != nil {
643 return mail.Identity{}, err
644 }
645 return p.raw.EnsureIdentity(ctx, region, domain, mailFrom, adopt)
646}
647func (p claimMailProvisioner) IdentityStatus(ctx context.Context, region, domain string) (mail.Status, error) {
648 return p.raw.IdentityStatus(ctx, region, domain)
649}
650func (p claimMailProvisioner) DeleteIdentity(ctx context.Context, region, domain string) error {
651 if err := p.domain(ctx, "DeleteIdentity", domain, domainDestroy, region); err != nil {
652 return err
653 }
654 return p.raw.DeleteIdentity(ctx, region, domain)
655}
656func (p claimMailProvisioner) EnsureUser(ctx context.Context, name, path string, identityARNs, configSetARNs, domains []string) (string, error) {
657 if err := p.user(ctx, "EnsureUser", name); err != nil {
658 return "", err
659 }
660 // Every domain the policy grants must be one this call already holds.
661 for _, d := range domains {
662 if err := p.domain(ctx, "EnsureUser:domain", d, domainTouch, ""); err != nil {
663 return "", err
664 }
665 }
666 return p.raw.EnsureUser(ctx, name, path, identityARNs, configSetARNs, domains)
667}
668func (p claimMailProvisioner) MintKey(ctx context.Context, user string) (string, string, error) {
669 if err := p.user(ctx, "MintKey", user); err != nil {
670 return "", "", err
671 }
672 return p.raw.MintKey(ctx, user)
673}
674func (p claimMailProvisioner) SetKeyActive(ctx context.Context, user, keyID string, active bool) error {
675 if err := p.user(ctx, "SetKeyActive", user); err != nil {
676 return err
677 }
678 return p.raw.SetKeyActive(ctx, user, keyID, active)
679}
680func (p claimMailProvisioner) DeleteKey(ctx context.Context, user, keyID string) error {
681 if err := p.user(ctx, "DeleteKey", user); err != nil {
682 return err
683 }
684 return p.raw.DeleteKey(ctx, user, keyID)
685}
686func (p claimMailProvisioner) ReapKeys(ctx context.Context, user string, keep []string) error {
687 if err := p.user(ctx, "ReapKeys", user); err != nil {
688 return err
689 }
690 return p.raw.ReapKeys(ctx, user, keep)
691}
692func (p claimMailProvisioner) DeleteUser(ctx context.Context, user string) error {
693 if err := p.user(ctx, "DeleteUser", user); err != nil {
694 return err
695 }
696 return p.raw.DeleteUser(ctx, user)
697}
698
699// claimBackendCheck adapts this file's owner resolution to the parent's
700// generated hostops backend wrappers (route-work/backend_boundary.go),
701// whose MutationTarget carries Surface/Method and the call's arguments as a
702// JSON object keyed by the interface's parameter names. Use EITHER those
703// wrappers with this check OR the explicit wrappers above, not both.
704func claimBackendCheck(g backendGuard) hostops.MutationCheck {
705 return func(ctx context.Context, t hostops.MutationTarget) error {
706 var args map[string]json.RawMessage
707 if err := json.Unmarshal(t.After, &args); err != nil {
708 return hostops.ErrIdentity
709 }
710 str := func(name string) string {
711 var v string
712 _ = json.Unmarshal(args[name], &v)
713 return v
714 }
715 strs := func(name string) []string {
716 var v []string
717 _ = json.Unmarshal(args[name], &v)
718 return v
719 }
720 sp := claimStoreProvisioner{g: g}
721 sc := claimStoreScanner{g: g}
722 mp := claimMailProvisioner{g: g}
723 switch t.Surface + "." + t.Method {
724 case "objstore.Provisioner.EnsureBucket", "objstore.Provisioner.EmptyBucket", "objstore.Provisioner.DeleteBucket":
725 return sp.bucket(ctx, t.Method, str("name"))
726 case "objstore.Provisioner.EnsureUser":
727 return sp.userPolicy(ctx, str("name"), str("bucketARN"))
728 case "objstore.Provisioner.MintKey", "objstore.Provisioner.DeleteKey", "objstore.Provisioner.ReapKeys", "objstore.Provisioner.DeleteUser":
729 return sp.user(ctx, t.Method, str("user"))
730 case "objstore.Scanner.EnsureEnabled", "objstore.Scanner.EnsureDisabled", "objstore.Scanner.Scan":
731 return sc.bucket(ctx, t.Method, str("bucket"))
732 case "mail.Provisioner.EnsureTenant":
733 return mp.account(ctx, t.Method, str("name"), mail.TenantName)
734 case "mail.Provisioner.EnsureConfigSet":
735 return mp.account(ctx, t.Method, str("name"), mail.ConfigSetName)
736 case "mail.Provisioner.SetIdentityConfigSet":
737 if err := mp.domain(ctx, t.Method, str("domain"), domainTouch, str("region")); err != nil {
738 return err
739 }
740 if set := str("configSet"); set != "" {
741 return mp.account(ctx, t.Method, set, mail.ConfigSetName)
742 }
743 return nil
744 case "mail.Provisioner.AssociateTenantResource", "mail.Provisioner.DisassociateTenantResource":
745 return mp.arnResource(ctx, t.Method, str("tenant"), str("resourceARN"))
746 case "mail.Provisioner.EnsureIdentity":
747 return mp.domain(ctx, t.Method, str("domain"), domainCreate, str("region"))
748 case "mail.Provisioner.DeleteIdentity":
749 return mp.domain(ctx, t.Method, str("domain"), domainDestroy, str("region"))
750 case "mail.Provisioner.EnsureUser":
751 if err := mp.user(ctx, t.Method, str("name")); err != nil {
752 return err
753 }
754 for _, d := range strs("domains") {
755 if err := mp.domain(ctx, "EnsureUser:domain", d, domainTouch, ""); err != nil {
756 return err
757 }
758 }
759 return nil
760 case "mail.Provisioner.MintKey", "mail.Provisioner.SetKeyActive", "mail.Provisioner.DeleteKey", "mail.Provisioner.ReapKeys", "mail.Provisioner.DeleteUser":
761 return mp.user(ctx, t.Method, str("user"))
762 case "dnsrec.Writer.EnsureRecord", "dnsrec.Writer.DeleteRecord":
763 var r dnsrec.Record
764 if err := json.Unmarshal(args["r"], &r); err != nil {
765 return hostops.ErrIdentity
766 }
767 return claimDNSWriter{g: g}.check(ctx, t.Method, r)
768 }
769 return fmt.Errorf("%w: %s.%s", hostops.ErrUnclassified, t.Surface, t.Method)
770 }
771}
772
773type claimDNSWriter struct {
774 raw dnsrec.Writer
775 g backendGuard
776}
777
778var _ dnsrec.Writer = claimDNSWriter{}
779
780func newClaimDNSWriter(raw dnsrec.Writer, g backendGuard) dnsrec.Writer {
781 if raw == nil {
782 return nil
783 }
784 return claimDNSWriter{raw: raw, g: g}
785}
786
787// check: publishing a record is a create (bound to the owning domain's
788// global claim); deleting one needs only the owner's members.
789func (w claimDNSWriter) check(ctx context.Context, method string, r dnsrec.Record) error {
790 name := strings.TrimSuffix(strings.ToLower(r.Name), ".")
791 use := domainTouch
792 if method == "EnsureRecord" {
793 use = domainCreate
794 }
795 scopes, err := w.g.domainMembers(ctx, name, true, use, "")
796 return w.g.authorize(ctx, "dns", method, "dns:"+r.Key(), scopes, true, err)
797}
798func (w claimDNSWriter) EnsureRecord(ctx context.Context, r dnsrec.Record) error {
799 if err := w.check(ctx, "EnsureRecord", r); err != nil {
800 return err
801 }
802 return w.raw.EnsureRecord(ctx, r)
803}
804func (w claimDNSWriter) DeleteRecord(ctx context.Context, r dnsrec.Record) error {
805 if err := w.check(ctx, "DeleteRecord", r); err != nil {
806 return err
807 }
808 return w.raw.DeleteRecord(ctx, r)
809}
810