package console import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "strings" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/dnsrec" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/mail" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/release" ) // Backend boundary. Each wrapper holds its raw backend in a NAMED private // field and implements every interface method explicitly; a method added // to the interface fails to compile here (compile-time assertions) and the // reflect inventory in TestClaimConsoleMutationInvariant fails if a wrapper // would forward it unguarded. Mutating methods resolve the resource's owners // from durable name records and require a live permit for each before the // raw call. Reads keep their values and errors. var errBackendUnresolved = fmt.Errorf("%w: backend resource has no resolvable owner", hostops.ErrUnclassified) // errBackendUnproven refuses a resource whose ownership is not proven by a // durable create-only name record in its exact deterministic form (review // P2-8). App-writable records (store records, grant blocks) never prove it. var errBackendUnproven = fmt.Errorf("%w: backend resource ownership is not proven by a durable name claim", hostops.ErrUnclassified) // backendObserver is the fixture hook (nil in production). type backendEvent struct { Surface, Method, Resource string Scopes []hostops.AppScope Verdict error } type backendGuard struct { data release.Store control *hostops.ControlWriter observe func(backendEvent) // deployment names minted buckets (objstore.MintedBucketIn). Required: // a zero value proves no minted bucket, so every bucket effect but // EnsureBucket refuses. deployment objstore.Deployment } func (g backendGuard) authorize(ctx context.Context, surface, method, resource string, scopes []hostops.AppScope, mailGate bool, resolveErr error) error { err := resolveErr if err == nil && len(scopes) == 0 { err = errBackendUnresolved } if err == nil { err = hostops.AuthorizeScopes(ctx, g.data, g.control, hostops.Classification{Family: surface, Scopes: scopes, MailGate: mailGate}) } if g.observe != nil { g.observe(backendEvent{Surface: surface, Method: method, Resource: resource, Scopes: scopes, Verdict: err}) } if err == nil { hostops.MarkAppMutationIssued(ctx) } return err } // permittedApps lists the distinct apps holding a valid permit in ctx: the // only candidates a name may be matched against when no name record exists. func (g backendGuard) permittedApps(ctx context.Context) ([]hostops.AppScope, error) { permits, err := hostops.LivePermits(ctx, g.data) if err != nil { return nil, err } seen := map[hostops.AppScope]bool{} var out []hostops.AppScope for _, p := range permits { s := hostops.AppScope{Account: p.Account, App: p.App} if !seen[s] { seen[s] = true out = append(out, s) } } return out, nil } // ---- object storage ---------------------------------------------------------- // nameClaim strictly reads the create-only objstore name claim for name. func (g backendGuard) nameClaim(ctx context.Context, kind, name string) (objstore.NameClaim, bool, error) { body, _, err := g.data.Get(ctx, "objstore/name-claims/"+kind+"/"+name+".json") if errors.Is(err, os.ErrNotExist) { return objstore.NameClaim{}, false, nil } if err != nil { return objstore.NameClaim{}, false, err } dec := json.NewDecoder(bytes.NewReader(body)) dec.DisallowUnknownFields() var c objstore.NameClaim if err = dec.Decode(&c); err != nil { return objstore.NameClaim{}, false, hostops.ErrIdentity } if c.Schema != 1 || c.Name != name || !release.ValidAccount(c.Account) || !release.ValidApp(c.App) || (c.Environment != "" && !appconfig.ValidEnvironmentName(c.Environment)) { return objstore.NameClaim{}, false, hostops.ErrIdentity } return c, true, nil } // resolveStore (review P2-8). The ONLY owner evidence is the durable name // claim, which the grant walk writes before any backend call. No claim: // every mutating method refuses (no fallback to an app-writable store // record, which let an app point its record at another tenant's unclaimed // bucket and then empty or delete it). With a claim, the name must be the // claim holder's exact deterministic form, derived forward (never parsed // back at hyphens): users are always objstore.UserNameIn; a minted bucket is // objstore.MintedBucketIn. An adopted (non-minted) bucket name is unproven: // only EnsureBucket (create-if-absent, no destruction, no credential) is // allowed on it; policy grants (EnsureUser), scanner writes, empty and // delete refuse. func (g backendGuard) resolveStore(ctx context.Context, kind, name, method string) ([]hostops.AppScope, error) { if name == "" || strings.ContainsAny(name, "/") || (kind == "buckets" && !objstore.ValidBucketName(name)) { return nil, hostops.ErrIdentity } c, ok, err := g.nameClaim(ctx, kind, name) if err != nil { return nil, err } if !ok { return nil, errBackendUnproven } switch kind { case "users": if name != objstore.UserNameIn(c.Account, c.App, c.Environment) { return nil, errBackendUnproven } case "buckets": if name != objstore.MintedBucketIn(g.deployment, c.Account, c.App, c.Environment) && method != "EnsureBucket" { return nil, errBackendUnproven } default: return nil, hostops.ErrIdentity } return []hostops.AppScope{{Account: c.Account, App: c.App}}, nil } type claimStoreProvisioner struct { raw objstore.Provisioner g backendGuard } var _ objstore.Provisioner = claimStoreProvisioner{} func newClaimStoreProvisioner(raw objstore.Provisioner, g backendGuard) objstore.Provisioner { if raw == nil { return nil } return claimStoreProvisioner{raw: raw, g: g} } func (p claimStoreProvisioner) bucket(ctx context.Context, method, name string) error { scopes, err := p.g.resolveStore(ctx, "buckets", name, method) return p.g.authorize(ctx, "objstore", method, "bucket:"+name, scopes, false, err) } func (p claimStoreProvisioner) user(ctx context.Context, method, name string) error { scopes, err := p.g.resolveStore(ctx, "users", name, method) return p.g.authorize(ctx, "objstore", method, "iam-user:"+name, scopes, false, err) } // userPolicy authorizes EnsureUser: the user, the bucket its policy names, // and that both belong to the SAME app (a context carrying permits for two // apps must not grant one app's principal the other's bucket). func (p claimStoreProvisioner) userPolicy(ctx context.Context, name, bucketARN string) error { if err := p.user(ctx, "EnsureUser", name); err != nil { return err } b, ok := strings.CutPrefix(bucketARN, "arn:aws:s3:::") if !ok { return errBackendUnresolved } if err := p.bucket(ctx, "EnsureUser:bucket", b); err != nil { return err } uc, _, err := p.g.nameClaim(ctx, "users", name) if err != nil { return err } bc, _, err := p.g.nameClaim(ctx, "buckets", b) if err != nil { return err } if uc.Account != bc.Account || uc.App != bc.App || uc.Environment != bc.Environment { return errBackendUnproven } return nil } func (p claimStoreProvisioner) EnsureBucket(ctx context.Context, name string) (string, error) { if err := p.bucket(ctx, "EnsureBucket", name); err != nil { return "", err } return p.raw.EnsureBucket(ctx, name) } func (p claimStoreProvisioner) EnsureUser(ctx context.Context, name, path, bucketARN string, deny *objstore.DenyTag) (string, error) { if err := p.userPolicy(ctx, name, bucketARN); err != nil { return "", err } return p.raw.EnsureUser(ctx, name, path, bucketARN, deny) } func (p claimStoreProvisioner) MintKey(ctx context.Context, user string) (string, string, error) { if err := p.user(ctx, "MintKey", user); err != nil { return "", "", err } return p.raw.MintKey(ctx, user) } func (p claimStoreProvisioner) DeleteKey(ctx context.Context, user, id string) error { if err := p.user(ctx, "DeleteKey", user); err != nil { return err } return p.raw.DeleteKey(ctx, user, id) } func (p claimStoreProvisioner) ReapKeys(ctx context.Context, user string, keep []string) error { if err := p.user(ctx, "ReapKeys", user); err != nil { return err } return p.raw.ReapKeys(ctx, user, keep) } func (p claimStoreProvisioner) EmptyBucket(ctx context.Context, name string) error { if err := p.bucket(ctx, "EmptyBucket", name); err != nil { return err } return p.raw.EmptyBucket(ctx, name) } func (p claimStoreProvisioner) DeleteBucket(ctx context.Context, name string) error { if err := p.bucket(ctx, "DeleteBucket", name); err != nil { return err } return p.raw.DeleteBucket(ctx, name) } func (p claimStoreProvisioner) DeleteUser(ctx context.Context, user string) error { if err := p.user(ctx, "DeleteUser", user); err != nil { return err } return p.raw.DeleteUser(ctx, user) } type claimStoreScanner struct { raw objstore.Scanner g backendGuard } var _ objstore.Scanner = claimStoreScanner{} func newClaimStoreScanner(raw objstore.Scanner, g backendGuard) objstore.Scanner { if raw == nil { return nil } return claimStoreScanner{raw: raw, g: g} } func (s claimStoreScanner) bucket(ctx context.Context, method, name string) error { scopes, err := s.g.resolveStore(ctx, "buckets", name, method) return s.g.authorize(ctx, "scanner", method, "bucket:"+name, scopes, false, err) } func (s claimStoreScanner) EnsureEnabled(ctx context.Context, bucket string) error { if err := s.bucket(ctx, "EnsureEnabled", bucket); err != nil { return err } return s.raw.EnsureEnabled(ctx, bucket) } func (s claimStoreScanner) EnsureDisabled(ctx context.Context, bucket string) error { if err := s.bucket(ctx, "EnsureDisabled", bucket); err != nil { return err } return s.raw.EnsureDisabled(ctx, bucket) } func (s claimStoreScanner) Verdict(ctx context.Context, bucket, key, version string) (objstore.Verdict, error) { return s.raw.Verdict(ctx, bucket, key, version) } func (s claimStoreScanner) Status(ctx context.Context, bucket, key string) (objstore.Verdict, objstore.ScanObject, error) { return s.raw.Status(ctx, bucket, key) } // Scan records a verdict on the object: an effect. func (s claimStoreScanner) Scan(ctx context.Context, bucket, key, version string, size int64) (objstore.Verdict, bool, error) { if err := s.bucket(ctx, "Scan", bucket); err != nil { return objstore.VerdictUnknown, false, err } return s.raw.Scan(ctx, bucket, key, version, size) } func (s claimStoreScanner) Enforcement() objstore.DenyTag { return s.raw.Enforcement() } func (s claimStoreScanner) Capacity(ctx context.Context) (int, int, error) { return s.raw.Capacity(ctx) } func (s claimStoreScanner) Provenance(ctx context.Context) (string, string, error) { return s.raw.Provenance(ctx) } // ---- mail and DNS ------------------------------------------------------------- // accountsInScope: accounts of every permitted app plus the held gate scope. func (g backendGuard) accountsInScope(ctx context.Context) ([]string, error) { apps, err := g.permittedApps(ctx) if err != nil { return nil, err } seen := map[string]bool{} var out []string for _, a := range apps { if !seen[a.Account] { seen[a.Account] = true out = append(out, a.Account) } } return out, nil } // domainUse is what one SES identity or DNS effect needs from the owner of // the domain it names. type domainUse int const ( // domainTouch: the account domain record's members, or, before the // record exists, the app on this account's global claim. Binds and // associations (written after the record), disassociations and DNS // deletes. domainTouch domainUse = iota // domainCreate: a NEW identity or published record (EnsureIdentity, // EnsureRecord). The domain must be held by a permitted account's // global claim, and every permitted account record for it must be that // account's. A record alone never proves it: it is account data any // permitted mail walk of the account can write, while the claim is the // create-only, cross-account unique gate EnsureDomain passes first. domainCreate // domainDestroy: DeleteIdentity. domainCreate's binding, and the claim // must say this account's walk created the identity in that region (the // rule every delete in the mail package applies). An identity adopted, // found, or left by a lapsed holder is never deletable here. domainDestroy ) // domainClaim strictly reads the global mail domain claim for domain. func (g backendGuard) domainClaim(ctx context.Context, domain string) (mail.DomainClaim, bool, error) { body, _, err := g.data.Get(ctx, mail.DomainClaimKey(domain)) if errors.Is(err, os.ErrNotExist) { return mail.DomainClaim{}, false, nil } if err != nil { return mail.DomainClaim{}, false, err } var c mail.DomainClaim if err = json.Unmarshal(body, &c); err != nil { return mail.DomainClaim{}, false, hostops.ErrIdentity } if c.Schema < 0 || c.Schema > 1 || c.Domain != domain || !release.ValidAccount(c.Account) || (c.App != "" && !release.ValidApp(c.App)) { return mail.DomainClaim{}, false, hostops.ErrIdentity } return c, true, nil } // domainRecordApps reads acct's record for exactly domain. func (g backendGuard) domainRecordApps(ctx context.Context, acct, domain string) ([]string, bool, error) { body, _, err := g.data.Get(ctx, mail.DomainKey(acct, domain)) if errors.Is(err, os.ErrNotExist) { return nil, false, nil } if err != nil { return nil, false, err } var rec mail.DomainRecord if err = json.Unmarshal(body, &rec); err != nil { return nil, false, hostops.ErrIdentity } if rec.Domain != domain { return nil, false, hostops.ErrIdentity } for _, app := range rec.Apps { if !release.ValidApp(app) { return nil, false, hostops.ErrIdentity } } return rec.Apps, true, nil } // domainCandidates: name itself, then (suffix) each parent with at least // two labels, longest first. Names that are not valid domains (a DKIM // "_domainkey" label) are skipped; no key is ever built from them. func domainCandidates(name string, suffix bool) []string { var out []string for cand := name; strings.Count(cand, ".") >= 1; { if mail.ValidDomain(cand) { out = append(out, cand) } if !suffix { break } _, rest, _ := strings.Cut(cand, ".") cand = rest } return out } // domainMembers resolves an SES identity (exact) or DNS name (longest owning // suffix) to the complete set of apps it affects, bound to the domain's // owner by use. Permitted accounts are the accounts of the apps holding // live permits in ctx. // // At each candidate, longest first: a global claim held by an account with // no permit here stops the search and refuses (another account owns that // name, whatever a shorter suffix says). Otherwise a permitted account's // record, or a permitted account's claim, resolves it: the record's members // plus the claim's app. EnsureDomain writes the claim before EnsureIdentity // and before any DNS publish, so the identity it creates is bound to the // claim from its first call, before the domain record exists. func (g backendGuard) domainMembers(ctx context.Context, name string, suffix bool, use domainUse, region string) ([]hostops.AppScope, error) { accounts, err := g.accountsInScope(ctx) if err != nil { return nil, err } if len(accounts) == 0 { return nil, errBackendUnresolved } permitted := map[string]bool{} for _, a := range accounts { permitted[a] = true } for _, cand := range domainCandidates(strings.TrimSuffix(strings.ToLower(name), "."), suffix) { claim, claimed, err := g.domainClaim(ctx, cand) if err != nil { return nil, err } if claimed && !permitted[claim.Account] { return nil, errBackendUnproven } var scopes []hostops.AppScope var owners []string for _, acct := range accounts { apps, ok, err := g.domainRecordApps(ctx, acct, cand) if err != nil { return nil, err } if !ok { continue } owners = append(owners, acct) for _, app := range apps { scopes = append(scopes, hostops.AppScope{Account: acct, App: app}) } } if len(owners) == 0 && !claimed { continue } if use != domainTouch { if !claimed { return nil, errBackendUnproven } for _, o := range owners { if o != claim.Account { return nil, errBackendUnproven } } if use == domainDestroy && (region == "" || !claim.CreatedIdentityIn(region)) { return nil, errBackendUnproven } } if claimed && claim.App != "" { scopes = append(scopes, hostops.AppScope{Account: claim.Account, App: claim.App}) } if len(scopes) == 0 { // A record with no members and a claim with no app: nothing // names an app whose permit could cover it. return nil, errBackendUnresolved } return dedupeScopes(scopes), nil } return nil, errBackendUnresolved } func dedupeScopes(in []hostops.AppScope) []hostops.AppScope { seen := map[hostops.AppScope]bool{} var out []hostops.AppScope for _, s := range in { if !seen[s] { seen[s] = true out = append(out, s) } } return out } func (g backendGuard) accountByName(ctx context.Context, name string, derive func(string) string) ([]hostops.AppScope, error) { apps, err := g.permittedApps(ctx) if err != nil { return nil, err } var out []hostops.AppScope for _, a := range apps { if derive(a.Account) == name { out = append(out, a) } } return out, nil } // resolveMailUser (review P2-8). Two proofs, both exact and forward: // // - The strict caller index for this user name, and the name must be one // the indexed (account, app, sender) renders (mail.SenderOwnsUserName). // An index can therefore never pull a principal out of the namespace // its own owner renders, whatever an index writer put in it. // - No index: a permitted app whose OWN forward rendering is this name: // the delivered user mail.UserName(account, app), or a standalone // credential mail.StandaloneUserName(account, app, n) for a valid n. // That covers CreateStandalone, whose EnsureUser runs before its index // is written. // // Nothing is parsed back out of user. The "+s+" form is matched by rendering // the permitted app's own prefix and re-rendering the whole name, so a name // in another app's namespace never matches: an account sqid has no hyphen, // and neither an app nor a credential name can contain "+", so the rendered // forms of two different (account, app, credential) triples never collide // (app "shop" with credential "eu" is "...-shop+s+eu", never "...-shop-eu"). func (g backendGuard) resolveMailUser(ctx context.Context, user string) ([]hostops.AppScope, error) { if user == "" || strings.Contains(user, "/") { return nil, hostops.ErrIdentity } body, _, err := g.data.Get(ctx, mail.CallerKey(user)) switch { case errors.Is(err, os.ErrNotExist): case err != nil: return nil, err default: dec := json.NewDecoder(bytes.NewReader(body)) dec.DisallowUnknownFields() var c mail.CallerIndex if err = dec.Decode(&c); err != nil || c.Schema != 1 || c.IAMUser != user || !release.ValidAccount(c.Account) || !release.ValidApp(c.App) { return nil, hostops.ErrIdentity } sender := c.Sender if sender == "" { sender = mail.SenderAppID // written before senders existed } if !mail.SenderOwnsUserName(c.Account, c.App, sender, user) { return nil, errBackendUnproven } return []hostops.AppScope{{Account: c.Account, App: c.App}}, nil } apps, err := g.permittedApps(ctx) if err != nil { return nil, err } var out []hostops.AppScope for _, a := range apps { if mail.SenderOwnsUserName(a.Account, a.App, mail.SenderAppID, user) { out = append(out, a) } } if len(out) == 0 { return nil, errBackendUnproven } return out, nil } type claimMailProvisioner struct { raw mail.Provisioner g backendGuard } var _ mail.Provisioner = claimMailProvisioner{} func newClaimMailProvisioner(raw mail.Provisioner, g backendGuard) mail.Provisioner { if raw == nil { return nil } return claimMailProvisioner{raw: raw, g: g} } func (p claimMailProvisioner) account(ctx context.Context, method, name string, derive func(string) string) error { scopes, err := p.g.accountByName(ctx, name, derive) return p.g.authorize(ctx, "mail", method, "account-resource:"+name, scopes, true, err) } func (p claimMailProvisioner) domain(ctx context.Context, method, domain string, use domainUse, region string) error { scopes, err := p.g.domainMembers(ctx, domain, false, use, region) return p.g.authorize(ctx, "mail", method, "ses-identity:"+domain, scopes, true, err) } func (p claimMailProvisioner) user(ctx context.Context, method, user string) error { scopes, err := p.g.resolveMailUser(ctx, user) return p.g.authorize(ctx, "mail", method, "iam-user:"+user, scopes, true, err) } // arnResource resolves an SES ARN: an identity names its domain; anything // else (configuration set) is the tenant's account-level resource. func (p claimMailProvisioner) arnResource(ctx context.Context, method, tenant, arn string) error { if _, domain, ok := strings.Cut(arn, ":identity/"); ok { if err := p.domain(ctx, method, domain, domainTouch, ""); err != nil { return err } } return p.account(ctx, method, tenant, mail.TenantName) } func (p claimMailProvisioner) EnsureTenant(ctx context.Context, name string) error { if err := p.account(ctx, "EnsureTenant", name, mail.TenantName); err != nil { return err } return p.raw.EnsureTenant(ctx, name) } func (p claimMailProvisioner) EnsureConfigSet(ctx context.Context, region, name, topic string) error { if err := p.account(ctx, "EnsureConfigSet", name, mail.ConfigSetName); err != nil { return err } return p.raw.EnsureConfigSet(ctx, region, name, topic) } func (p claimMailProvisioner) SetIdentityConfigSet(ctx context.Context, region, domain, configSet string) error { if err := p.domain(ctx, "SetIdentityConfigSet", domain, domainTouch, region); err != nil { return err } // An empty configSet is an unbinding (SNS disabled): there is no set // resource to resolve, and the domain authorization above is the whole // of what the call needs. if configSet != "" { if err := p.account(ctx, "SetIdentityConfigSet", configSet, mail.ConfigSetName); err != nil { return err } } return p.raw.SetIdentityConfigSet(ctx, region, domain, configSet) } func (p claimMailProvisioner) AssociateTenantResource(ctx context.Context, tenant, arn string) error { if err := p.arnResource(ctx, "AssociateTenantResource", tenant, arn); err != nil { return err } return p.raw.AssociateTenantResource(ctx, tenant, arn) } func (p claimMailProvisioner) DisassociateTenantResource(ctx context.Context, tenant, arn string) error { if err := p.arnResource(ctx, "DisassociateTenantResource", tenant, arn); err != nil { return err } return p.raw.DisassociateTenantResource(ctx, tenant, arn) } func (p claimMailProvisioner) EnsureIdentity(ctx context.Context, region, domain, mailFrom string, adopt bool) (mail.Identity, error) { if err := p.domain(ctx, "EnsureIdentity", domain, domainCreate, region); err != nil { return mail.Identity{}, err } return p.raw.EnsureIdentity(ctx, region, domain, mailFrom, adopt) } func (p claimMailProvisioner) IdentityStatus(ctx context.Context, region, domain string) (mail.Status, error) { return p.raw.IdentityStatus(ctx, region, domain) } func (p claimMailProvisioner) DeleteIdentity(ctx context.Context, region, domain string) error { if err := p.domain(ctx, "DeleteIdentity", domain, domainDestroy, region); err != nil { return err } return p.raw.DeleteIdentity(ctx, region, domain) } func (p claimMailProvisioner) EnsureUser(ctx context.Context, name, path string, identityARNs, configSetARNs, domains []string) (string, error) { if err := p.user(ctx, "EnsureUser", name); err != nil { return "", err } // Every domain the policy grants must be one this call already holds. for _, d := range domains { if err := p.domain(ctx, "EnsureUser:domain", d, domainTouch, ""); err != nil { return "", err } } return p.raw.EnsureUser(ctx, name, path, identityARNs, configSetARNs, domains) } func (p claimMailProvisioner) MintKey(ctx context.Context, user string) (string, string, error) { if err := p.user(ctx, "MintKey", user); err != nil { return "", "", err } return p.raw.MintKey(ctx, user) } func (p claimMailProvisioner) SetKeyActive(ctx context.Context, user, keyID string, active bool) error { if err := p.user(ctx, "SetKeyActive", user); err != nil { return err } return p.raw.SetKeyActive(ctx, user, keyID, active) } func (p claimMailProvisioner) DeleteKey(ctx context.Context, user, keyID string) error { if err := p.user(ctx, "DeleteKey", user); err != nil { return err } return p.raw.DeleteKey(ctx, user, keyID) } func (p claimMailProvisioner) ReapKeys(ctx context.Context, user string, keep []string) error { if err := p.user(ctx, "ReapKeys", user); err != nil { return err } return p.raw.ReapKeys(ctx, user, keep) } func (p claimMailProvisioner) DeleteUser(ctx context.Context, user string) error { if err := p.user(ctx, "DeleteUser", user); err != nil { return err } return p.raw.DeleteUser(ctx, user) } // claimBackendCheck adapts this file's owner resolution to the parent's // generated hostops backend wrappers (route-work/backend_boundary.go), // whose MutationTarget carries Surface/Method and the call's arguments as a // JSON object keyed by the interface's parameter names. Use EITHER those // wrappers with this check OR the explicit wrappers above, not both. func claimBackendCheck(g backendGuard) hostops.MutationCheck { return func(ctx context.Context, t hostops.MutationTarget) error { var args map[string]json.RawMessage if err := json.Unmarshal(t.After, &args); err != nil { return hostops.ErrIdentity } str := func(name string) string { var v string _ = json.Unmarshal(args[name], &v) return v } strs := func(name string) []string { var v []string _ = json.Unmarshal(args[name], &v) return v } sp := claimStoreProvisioner{g: g} sc := claimStoreScanner{g: g} mp := claimMailProvisioner{g: g} switch t.Surface + "." + t.Method { case "objstore.Provisioner.EnsureBucket", "objstore.Provisioner.EmptyBucket", "objstore.Provisioner.DeleteBucket": return sp.bucket(ctx, t.Method, str("name")) case "objstore.Provisioner.EnsureUser": return sp.userPolicy(ctx, str("name"), str("bucketARN")) case "objstore.Provisioner.MintKey", "objstore.Provisioner.DeleteKey", "objstore.Provisioner.ReapKeys", "objstore.Provisioner.DeleteUser": return sp.user(ctx, t.Method, str("user")) case "objstore.Scanner.EnsureEnabled", "objstore.Scanner.EnsureDisabled", "objstore.Scanner.Scan": return sc.bucket(ctx, t.Method, str("bucket")) case "mail.Provisioner.EnsureTenant": return mp.account(ctx, t.Method, str("name"), mail.TenantName) case "mail.Provisioner.EnsureConfigSet": return mp.account(ctx, t.Method, str("name"), mail.ConfigSetName) case "mail.Provisioner.SetIdentityConfigSet": if err := mp.domain(ctx, t.Method, str("domain"), domainTouch, str("region")); err != nil { return err } if set := str("configSet"); set != "" { return mp.account(ctx, t.Method, set, mail.ConfigSetName) } return nil case "mail.Provisioner.AssociateTenantResource", "mail.Provisioner.DisassociateTenantResource": return mp.arnResource(ctx, t.Method, str("tenant"), str("resourceARN")) case "mail.Provisioner.EnsureIdentity": return mp.domain(ctx, t.Method, str("domain"), domainCreate, str("region")) case "mail.Provisioner.DeleteIdentity": return mp.domain(ctx, t.Method, str("domain"), domainDestroy, str("region")) case "mail.Provisioner.EnsureUser": if err := mp.user(ctx, t.Method, str("name")); err != nil { return err } for _, d := range strs("domains") { if err := mp.domain(ctx, "EnsureUser:domain", d, domainTouch, ""); err != nil { return err } } return nil case "mail.Provisioner.MintKey", "mail.Provisioner.SetKeyActive", "mail.Provisioner.DeleteKey", "mail.Provisioner.ReapKeys", "mail.Provisioner.DeleteUser": return mp.user(ctx, t.Method, str("user")) case "dnsrec.Writer.EnsureRecord", "dnsrec.Writer.DeleteRecord": var r dnsrec.Record if err := json.Unmarshal(args["r"], &r); err != nil { return hostops.ErrIdentity } return claimDNSWriter{g: g}.check(ctx, t.Method, r) } return fmt.Errorf("%w: %s.%s", hostops.ErrUnclassified, t.Surface, t.Method) } } type claimDNSWriter struct { raw dnsrec.Writer g backendGuard } var _ dnsrec.Writer = claimDNSWriter{} func newClaimDNSWriter(raw dnsrec.Writer, g backendGuard) dnsrec.Writer { if raw == nil { return nil } return claimDNSWriter{raw: raw, g: g} } // check: publishing a record is a create (bound to the owning domain's // global claim); deleting one needs only the owner's members. func (w claimDNSWriter) check(ctx context.Context, method string, r dnsrec.Record) error { name := strings.TrimSuffix(strings.ToLower(r.Name), ".") use := domainTouch if method == "EnsureRecord" { use = domainCreate } scopes, err := w.g.domainMembers(ctx, name, true, use, "") return w.g.authorize(ctx, "dns", method, "dns:"+r.Key(), scopes, true, err) } func (w claimDNSWriter) EnsureRecord(ctx context.Context, r dnsrec.Record) error { if err := w.check(ctx, "EnsureRecord", r); err != nil { return err } return w.raw.EnsureRecord(ctx, r) } func (w claimDNSWriter) DeleteRecord(ctx context.Context, r dnsrec.Record) error { if err := w.check(ctx, "DeleteRecord", r); err != nil { return err } return w.raw.DeleteRecord(ctx, r) }