package hostops import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "os" "slices" "sort" "strings" "github.com/carlosframework/platform/internal/controlname" "github.com/carlosframework/platform/internal/release" ) // ErrUnclassified refuses a write whose key family has not been reviewed. var ErrUnclassified = errors.New("hostops: unreviewed storage family") // AppScope is one app identity a mutation affects. type AppScope struct{ Account, App string } // Classification is the classifier's verdict before permits are checked. type Classification struct { Family string Scopes []AppScope // exact affected apps; may be empty only for NonApp/Lifecycle MailGate bool // the held shared mail gate is also required NonApp string // reviewed reason for a deployment/account-only family } // nonAppFamily is a reviewed deployment or account-level family. Every // entry needs a reason. Do not add a root prefix because it "looks like // telemetry": app-owned data under any root is app scope. Families that // need their body checked against the key live in storage_classifier.go. type nonAppFamily struct { prefix, exact string deletes bool reason string } // Only exact deployment-level families whose objects never hold app state. // Everything else (economics, usage, fleets, maintenance, telemetry roots) // refuses until reviewed into this table with a reason or classified to its // apps. The route/worker census adds entries; it never adds root prefixes. var nonAppFamilies = []nonAppFamily{ {exact: "control/serial", reason: "deployment convergence notification counter; contains no app state"}, {exact: "control/relay-writer.json", reason: "deployment relay writer lease; app effects remain separately admitted"}, {exact: "control/seeds-protocol.json", reason: "operator-published seeds floor; a protocol number and a release, no app state"}, {exact: "control/plans.json", reason: "plans deployment marker published by the console; says whether boxes read limits; no app state"}, {prefix: "status/boxes/", deletes: true, reason: "per-box heartbeats; no app state"}, {exact: "status/fleet.json", reason: "deployment fleet status summary"}, {exact: "status/index.html", reason: "deployment status page"}, {exact: "status/status.css", reason: "deployment status page style"}, {prefix: "alerts/boxes/", deletes: true, reason: "per-box alert state"}, {prefix: "metrics/boxes/", deletes: true, reason: "per-box resource metrics"}, {prefix: "control/edges/", deletes: true, reason: "edge membership files keyed by box"}, {prefix: "control/boxes/", deletes: true, reason: "box identity records keyed by box"}, {prefix: "control/box-volumes/", deletes: true, reason: "box volume inventory keyed by enrollment"}, {prefix: "control/config-ack/", deletes: true, reason: "per-box config acknowledgements"}, {prefix: "control/relay-ack/", deletes: true, reason: "per-box relay heartbeats"}, {exact: "control/layout.json", reason: "deployment layout intent"}, {exact: "control/restore-verify.json", reason: "console database restore verification marker"}, {prefix: "control/registry-snapshot", deletes: true, reason: "console registry snapshots"}, {prefix: "control/housekeeper-lease", deletes: true, reason: "deployment housekeeper lease"}, } // lifecycleFamily: the claim engine's own CAS roots. A claim write needs a // lifecycle grant scoped to it (lifecycle_grant.go); the roster and backfill // plan need a valid root envelope; census reports are create-only. Claims // are never deleted (C3). func lifecycleFamily(ctx context.Context, raw release.Store, w *ControlWriter, t MutationTarget) (string, bool, error) { switch { case strings.HasPrefix(t.Key, "control/hosts/") && strings.HasSuffix(t.Key, ".json") && strings.Count(t.Key, "/") == 2: host := strings.TrimSuffix(strings.TrimPrefix(t.Key, "control/hosts/"), ".json") if t.Method != "Put" || !controlname.ValidHostShape(host) { return "", true, ErrIdentity } if w == nil { return "", true, ErrContract } return "lifecycle-claim", true, authorizeClaimWrite(ctx, raw, host, t) case t.Key == "control/host-actors.json", t.Key == BackfillKey: if w == nil { return "", true, ErrContract } return "lifecycle-signed", true, authorizeSignedControl(w.pub, t) case strings.HasPrefix(t.Key, "control/host-census/"): if t.Method != "Put" || t.ETag != "" { return "", true, ErrIdentity } return "lifecycle-census", true, nil } return "", false, nil } // ClassifyMutation resolves the complete affected scope set. App scopes are // authorized later from permits; lifecycle families are authorized here // from their grant or signature. func ClassifyMutation(ctx context.Context, raw release.Store, w *ControlWriter, t MutationTarget) (Classification, error) { if err := structuralRefusal(t.Key); err != nil { return Classification{}, err } if fam, ok, err := lifecycleFamily(ctx, raw, w, t); ok { return Classification{Family: fam, NonApp: "lifecycle CAS root"}, err } p := strings.Split(t.Key, "/") switch { case p[0] == "apps" || p[0] == "ledgers": return Classification{Family: p[0], Scopes: []AppScope{{p[1], p[2]}}}, nil case strings.HasPrefix(t.Key, "control/door-keys/") && len(p) == 4 && strings.HasSuffix(p[3], ".json"): return appScoped("door-keys", p[2], strings.TrimSuffix(p[3], ".json")) case strings.HasPrefix(t.Key, "control/maintenance/apps/") && len(p) >= 6: return appScoped("maintenance-app", p[3], p[4]) case strings.HasPrefix(t.Key, "domains/") && len(p) == 2 && strings.HasSuffix(p[1], ".json"): return ownerUnion("domain-marker", t, func(b []byte) ([]AppScope, error) { return ownerOf(b, true) }) case strings.HasPrefix(t.Key, "objstore/name-claims/buckets/") || strings.HasPrefix(t.Key, "objstore/name-claims/users/"): return ownerUnion("objstore-name-claim", t, func(b []byte) ([]AppScope, error) { return ownerOf(b, true) }) case strings.HasPrefix(t.Key, "email/callers/"): return ownerUnion("email-caller", t, func(b []byte) ([]AppScope, error) { return ownerOf(b, true) }) case strings.HasPrefix(t.Key, "email/domain-claims/") && len(p) == 3: c, err := ownerUnion("email-domain-claim", t, func(b []byte) ([]AppScope, error) { return ownerOf(b, false) }) if err != nil { return c, err } // The account's domain record names every member the claim covers. domain := strings.TrimSuffix(p[2], ".json") for _, acct := range accountsOf(t) { members, err := domainMembers(ctx, raw, acct, domain) if err != nil { return Classification{}, err } c.Scopes = append(c.Scopes, members...) } c.Scopes = dedupe(c.Scopes) c.MailGate = true return c, nil case p[0] == "accounts" && len(p) == 5 && p[2] == "email" && p[3] == "domains" && controlname.ValidAccount(p[1]): c, err := ownerUnion("email-domain-record", t, func(b []byte) ([]AppScope, error) { return domainRecordApps(p[1], b) }) c.MailGate = true return c, err case p[0] == "accounts" && len(p) == 3 && p[2] == "account.json" && controlname.ValidAccount(p[1]): return Classification{Family: "account-manifest", NonApp: "account manifest; account deletion cascades are scoped by the SQL boundary"}, nil case p[0] == "accounts" && len(p) == 3 && p[2] == "limits.json" && controlname.ValidAccount(p[1]): // plans-caps-packs spec §7: the console publishes it; nothing deletes it. if t.Method != "Put" { return Classification{}, fmt.Errorf("%w: %s refuses deletes", ErrUnclassified, t.Key) } return Classification{Family: "account-limits", NonApp: "account limits record published from the plan catalog; no app state"}, nil case t.Key == RelayAuthorityKey: return classifyRelayAuthority(t) case t.Key == RelayIssuerKey: return classifyRelayIssuer(t) case strings.HasPrefix(t.Key, "control/geoip/"): return classifyGeoip(t) case strings.HasPrefix(t.Key, "store/entitlements/") && len(p) == 3: return Classification{Family: "store-entitlement", NonApp: "operator-set account entitlement; no app state"}, nil case p[0] == "releases" && len(p) == 5 && p[3] == "promoted": return classifyPromotionRecord(t, p) case p[0] == "releases": return classifyPointerDigestMarker(t, p) } if c, ok, err := classifyDeploymentFamily(w, t); ok { return c, err } for _, f := range nonAppFamilies { if (f.exact != "" && t.Key == f.exact) || (f.prefix != "" && strings.HasPrefix(t.Key, f.prefix)) { if t.Method != "Put" && !f.deletes { return Classification{}, fmt.Errorf("%w: %s refuses deletes", ErrUnclassified, t.Key) } return Classification{Family: "non-app", NonApp: f.reason}, nil } } return Classification{}, fmt.Errorf("%w: %s", ErrUnclassified, t.Key) } // classifyPointerDigestMarker admits exactly one key under releases/: the // account's pointer digest marker, releases//pointer-digests (#253, // spec §4.6), written by `carlos channels digests on` and deleted by `off`. // It is a root-signed switch that holds no app state. Anything else under // releases/ stays unreviewed. func classifyPointerDigestMarker(t MutationTarget, p []string) (Classification, error) { if len(p) != 3 || !controlname.ValidAccount(p[1]) || t.Key != release.PointerDigestsKey(p[1]) || (t.Method != "Put" && t.Method != "Delete") { return Classification{}, fmt.Errorf("%w: %s", ErrUnclassified, t.Key) } return Classification{Family: "pointer-digest-marker", NonApp: "root-signed account switch for pointer digest emission; no app state"}, nil } // classifyPromotionRecord admits a build's signed promotion record, // releases///promoted/ (settings plan 2, spec §4.1), // written when a version is promoted or authorized and deleted when the app // is purged. It is app state, scoped to exactly that app. Anything else // under releases/// stays unreviewed. func classifyPromotionRecord(t MutationTarget, p []string) (Classification, error) { if len(p) != 5 || p[3] != "promoted" || !controlname.ValidAccount(p[1]) || !controlname.ValidApp(p[2]) || !release.ValidVersion(p[4]) || p[4] == "dev" || t.Key != release.PromotionRecordKey(p[1], p[2], p[4]) || (t.Method != "Put" && t.Method != "Delete") { return Classification{}, fmt.Errorf("%w: %s", ErrUnclassified, t.Key) } return appScoped("promotion-record", p[1], p[2]) } func appScoped(family, account, app string) (Classification, error) { if !controlname.ValidAccount(account) || !controlname.ValidApp(app) { return Classification{}, ErrIdentity } return Classification{Family: family, Scopes: []AppScope{{account, app}}}, nil } // ownerUnion authorizes the union of the old and the proposed owners. A // record whose owner cannot be read refuses; a successor can never be // deleted after an old-owner check because the boundary deletes only the // exact ETag it classified. func ownerUnion(family string, t MutationTarget, owners func([]byte) ([]AppScope, error)) (Classification, error) { var all []AppScope for _, b := range [][]byte{t.Before, t.After} { if len(b) == 0 { continue } s, err := owners(b) if err != nil { return Classification{}, err } all = append(all, s...) } if len(all) == 0 && t.Method == "Put" { return Classification{}, ErrIdentity } return Classification{Family: family, Scopes: dedupe(all)}, nil } func ownerOf(b []byte, appRequired bool) ([]AppScope, error) { var o struct { Account string `json:"account"` App string `json:"app"` } if err := json.Unmarshal(b, &o); err != nil { return nil, err } if !controlname.ValidAccount(o.Account) { return nil, ErrIdentity } if o.App == "" { if appRequired { return nil, ErrIdentity } return nil, nil } if !controlname.ValidApp(o.App) { return nil, ErrIdentity } return []AppScope{{o.Account, o.App}}, nil } func accountsOf(t MutationTarget) []string { set := map[string]bool{} for _, b := range [][]byte{t.Before, t.After} { var o struct { Account string `json:"account"` } if len(b) != 0 && json.Unmarshal(b, &o) == nil && controlname.ValidAccount(o.Account) { set[o.Account] = true } } var out []string for a := range set { out = append(out, a) } sort.Strings(out) return out } func domainRecordApps(account string, b []byte) ([]AppScope, error) { var rec struct { Domain string `json:"domain"` Apps []string `json:"apps"` } if err := json.Unmarshal(b, &rec); err != nil { return nil, err } var out []AppScope for _, app := range rec.Apps { if !controlname.ValidApp(app) { return nil, ErrIdentity } out = append(out, AppScope{account, app}) } return out, nil } func domainMembers(ctx context.Context, raw release.Store, account, domain string) ([]AppScope, error) { body, _, err := raw.Get(ctx, "accounts/"+account+"/email/domains/"+domain+".json") if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } return domainRecordApps(account, body) } func dedupe(in []AppScope) []AppScope { seen := map[AppScope]bool{} var out []AppScope for _, s := range in { if !seen[s] { seen[s] = true out = append(out, s) } } sort.Slice(out, func(i, j int) bool { if out[i].Account != out[j].Account { return out[i].Account < out[j].Account } return out[i].App < out[j].App }) return out } // NewProductionMutationCheck is the one checker shared by the bucket, // versioned, SQL and backend boundaries. func NewProductionMutationCheck(raw release.Store, control *ControlWriter) (MutationCheck, error) { if raw == nil || control == nil { return nil, ErrContract } return func(ctx context.Context, t MutationTarget) error { c, err := ClassifyMutation(ctx, raw, control, t) if err != nil { return err } return AuthorizeScopes(ctx, raw, control, c) }, nil } // AuthorizeScopes requires a live permit for every scope (and the held mail // gate when flagged). A NonApp/lifecycle verdict with no scopes needs none. func AuthorizeScopes(ctx context.Context, raw release.Store, control *ControlWriter, c Classification) error { if len(c.Scopes) == 0 && c.NonApp == "" && !c.MailGate { return ErrIdentity } if c.MailGate { lease, ok := MailGateLeaseFrom(ctx) if !ok { return ErrPending } if err := control.CheckMailGate(ctx, lease); err != nil { return err } } for _, s := range c.Scopes { a, err := ReadCanonicalAdmission(ctx, raw, s.Account, s.App) if err != nil { // Absent admission is an unenrolled app: enabled mode refuses. return err } if err = RequireAppMutation(ctx, raw, s.Account, s.App, a.Value.AppID); err != nil { return err } } return nil } // RelayAuthorityKey is relay.AuthorityKey: the published aggregate of every // enabled app's relay entry. It is per-app authority (a relay box serves an // app exactly when its entry is present), so it is classified per entry. const RelayAuthorityKey = "control/relay.json" // RelayIssuerKey is the operator's regional issuer pointer {region: kid}. const RelayIssuerKey = "control/relay-issuer.json" type relayAuthorityEntry struct { Account string `json:"account"` App string `json:"app"` Incarnation string `json:"incarnation"` Regions []string `json:"regions"` } type relayAuthorityDoc struct { Apps []relayAuthorityEntry `json:"apps"` WrittenAt json.RawMessage `json:"written_at"` } // strictJSON decodes exactly one JSON value with no unknown fields, so a // field the classifier does not compare can never move unpermitted. func strictJSON(b []byte, v any) error { d := json.NewDecoder(bytes.NewReader(b)) d.DisallowUnknownFields() if err := d.Decode(v); err != nil { return fmt.Errorf("%w: %v", ErrIdentity, err) } if _, err := d.Token(); err != io.EOF { return ErrIdentity } return nil } func relayAuthorityEntries(b []byte) (map[AppScope]relayAuthorityEntry, error) { out := map[AppScope]relayAuthorityEntry{} if len(b) == 0 { return out, nil } var doc relayAuthorityDoc if err := strictJSON(b, &doc); err != nil { return nil, err } for _, e := range doc.Apps { if !controlname.ValidAccount(e.Account) || !controlname.ValidApp(e.App) { return nil, ErrIdentity } k := AppScope{e.Account, e.App} if _, dup := out[k]; dup { return nil, fmt.Errorf("%w: %s/%s appears twice in %s", ErrIdentity, e.Account, e.App, RelayAuthorityKey) } out[k] = e } return out, nil } // RelayAuthorityScopes is the exact app set a rewrite of control/relay.json // changes: every (account, app) entry added, removed or altered between // before and after, sorted and deduplicated. Entries identical on both sides // are preserved by the exact-ETag CAS and need no permit. A malformed // document or a duplicate entry on either side refuses. The relay publisher // calls this same function to choose its permits, so the publisher and the // boundary cannot disagree about the set. func RelayAuthorityScopes(before, after []byte) ([]AppScope, error) { b, err := relayAuthorityEntries(before) if err != nil { return nil, err } a, err := relayAuthorityEntries(after) if err != nil { return nil, err } var changed []AppScope for k, old := range b { cur, ok := a[k] if !ok || cur.Incarnation != old.Incarnation || !slices.Equal(cur.Regions, old.Regions) { changed = append(changed, k) } } for k := range a { if _, ok := b[k]; !ok { changed = append(changed, k) } } return dedupe(changed), nil } func classifyRelayAuthority(t MutationTarget) (Classification, error) { if t.Method != "Put" || len(t.After) == 0 { return Classification{}, fmt.Errorf("%w: %s refuses deletes", ErrUnclassified, t.Key) } scopes, err := RelayAuthorityScopes(t.Before, t.After) if err != nil { return Classification{}, err } if len(scopes) == 0 { return Classification{Family: "relay-authority", NonApp: "relay aggregate rewrite with every app entry identical; only written_at moves"}, nil } return Classification{Family: "relay-authority", Scopes: scopes}, nil } // classifyRelayIssuer admits the operator's regional issuance pointer. It // names which staged master each region issues from and holds no app // record; every app re-issued under a moved pointer is its own gated app // mutation. func classifyRelayIssuer(t MutationTarget) (Classification, error) { if t.Method != "Put" || len(t.After) == 0 { return Classification{}, fmt.Errorf("%w: %s refuses deletes", ErrUnclassified, t.Key) } var issuers map[string]string if err := strictJSON(t.After, &issuers); err != nil { return Classification{}, err } if issuers == nil { return Classification{}, ErrIdentity } for region, kid := range issuers { if !validRelayRegion(region) || !validRelayKid(kid) { return Classification{}, ErrIdentity } } return Classification{Family: "relay-issuer", NonApp: "operator regional relay issuer pointer; names a staged master kid per region and holds no app record"}, nil } func validRelayRegion(s string) bool { if s == "" || len(s) > 64 { return false } for _, c := range s { if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { return false } } return true } // validRelayKid mirrors relay.ValidKid (1-8 lowercase letters or digits); // hostops does not import the relay server package. func validRelayKid(s string) bool { if s == "" || len(s) > 8 { return false } for _, c := range s { if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9') { return false } } return true }