carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostops
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "slices"
12 "sort"
13 "strings"
14
15 "github.com/carlosframework/platform/internal/controlname"
16 "github.com/carlosframework/platform/internal/release"
17)
18
19// ErrUnclassified refuses a write whose key family has not been reviewed.
20var ErrUnclassified = errors.New("hostops: unreviewed storage family")
21
22// AppScope is one app identity a mutation affects.
23type AppScope struct{ Account, App string }
24
25// Classification is the classifier's verdict before permits are checked.
26type Classification struct {
27 Family string
28 Scopes []AppScope // exact affected apps; may be empty only for NonApp/Lifecycle
29 MailGate bool // the held shared mail gate is also required
30 NonApp string // reviewed reason for a deployment/account-only family
31}
32
33// nonAppFamily is a reviewed deployment or account-level family. Every
34// entry needs a reason. Do not add a root prefix because it "looks like
35// telemetry": app-owned data under any root is app scope. Families that
36// need their body checked against the key live in storage_classifier.go.
37type nonAppFamily struct {
38 prefix, exact string
39 deletes bool
40 reason string
41}
42
43// Only exact deployment-level families whose objects never hold app state.
44// Everything else (economics, usage, fleets, maintenance, telemetry roots)
45// refuses until reviewed into this table with a reason or classified to its
46// apps. The route/worker census adds entries; it never adds root prefixes.
47var nonAppFamilies = []nonAppFamily{
48 {exact: "control/serial", reason: "deployment convergence notification counter; contains no app state"},
49 {exact: "control/relay-writer.json", reason: "deployment relay writer lease; app effects remain separately admitted"},
50 {exact: "control/seeds-protocol.json", reason: "operator-published seeds floor; a protocol number and a release, no app state"},
51 {exact: "control/plans.json", reason: "plans deployment marker published by the console; says whether boxes read limits; no app state"},
52 {prefix: "status/boxes/", deletes: true, reason: "per-box heartbeats; no app state"},
53 {exact: "status/fleet.json", reason: "deployment fleet status summary"},
54 {exact: "status/index.html", reason: "deployment status page"},
55 {exact: "status/status.css", reason: "deployment status page style"},
56 {prefix: "alerts/boxes/", deletes: true, reason: "per-box alert state"},
57 {prefix: "metrics/boxes/", deletes: true, reason: "per-box resource metrics"},
58 {prefix: "control/edges/", deletes: true, reason: "edge membership files keyed by box"},
59 {prefix: "control/boxes/", deletes: true, reason: "box identity records keyed by box"},
60 {prefix: "control/box-volumes/", deletes: true, reason: "box volume inventory keyed by enrollment"},
61 {prefix: "control/config-ack/", deletes: true, reason: "per-box config acknowledgements"},
62 {prefix: "control/relay-ack/", deletes: true, reason: "per-box relay heartbeats"},
63 {exact: "control/layout.json", reason: "deployment layout intent"},
64 {exact: "control/restore-verify.json", reason: "console database restore verification marker"},
65 {prefix: "control/registry-snapshot", deletes: true, reason: "console registry snapshots"},
66 {prefix: "control/housekeeper-lease", deletes: true, reason: "deployment housekeeper lease"},
67}
68
69// lifecycleFamily: the claim engine's own CAS roots. A claim write needs a
70// lifecycle grant scoped to it (lifecycle_grant.go); the roster and backfill
71// plan need a valid root envelope; census reports are create-only. Claims
72// are never deleted (C3).
73func lifecycleFamily(ctx context.Context, raw release.Store, w *ControlWriter, t MutationTarget) (string, bool, error) {
74 switch {
75 case strings.HasPrefix(t.Key, "control/hosts/") && strings.HasSuffix(t.Key, ".json") && strings.Count(t.Key, "/") == 2:
76 host := strings.TrimSuffix(strings.TrimPrefix(t.Key, "control/hosts/"), ".json")
77 if t.Method != "Put" || !controlname.ValidHostShape(host) {
78 return "", true, ErrIdentity
79 }
80 if w == nil {
81 return "", true, ErrContract
82 }
83 return "lifecycle-claim", true, authorizeClaimWrite(ctx, raw, host, t)
84 case t.Key == "control/host-actors.json", t.Key == BackfillKey:
85 if w == nil {
86 return "", true, ErrContract
87 }
88 return "lifecycle-signed", true, authorizeSignedControl(w.pub, t)
89 case strings.HasPrefix(t.Key, "control/host-census/"):
90 if t.Method != "Put" || t.ETag != "" {
91 return "", true, ErrIdentity
92 }
93 return "lifecycle-census", true, nil
94 }
95 return "", false, nil
96}
97
98// ClassifyMutation resolves the complete affected scope set. App scopes are
99// authorized later from permits; lifecycle families are authorized here
100// from their grant or signature.
101func ClassifyMutation(ctx context.Context, raw release.Store, w *ControlWriter, t MutationTarget) (Classification, error) {
102 if err := structuralRefusal(t.Key); err != nil {
103 return Classification{}, err
104 }
105 if fam, ok, err := lifecycleFamily(ctx, raw, w, t); ok {
106 return Classification{Family: fam, NonApp: "lifecycle CAS root"}, err
107 }
108 p := strings.Split(t.Key, "/")
109 switch {
110 case p[0] == "apps" || p[0] == "ledgers":
111 return Classification{Family: p[0], Scopes: []AppScope{{p[1], p[2]}}}, nil
112 case strings.HasPrefix(t.Key, "control/door-keys/") && len(p) == 4 && strings.HasSuffix(p[3], ".json"):
113 return appScoped("door-keys", p[2], strings.TrimSuffix(p[3], ".json"))
114 case strings.HasPrefix(t.Key, "control/maintenance/apps/") && len(p) >= 6:
115 return appScoped("maintenance-app", p[3], p[4])
116 case strings.HasPrefix(t.Key, "domains/") && len(p) == 2 && strings.HasSuffix(p[1], ".json"):
117 return ownerUnion("domain-marker", t, func(b []byte) ([]AppScope, error) { return ownerOf(b, true) })
118 case strings.HasPrefix(t.Key, "objstore/name-claims/buckets/") || strings.HasPrefix(t.Key, "objstore/name-claims/users/"):
119 return ownerUnion("objstore-name-claim", t, func(b []byte) ([]AppScope, error) { return ownerOf(b, true) })
120 case strings.HasPrefix(t.Key, "email/callers/"):
121 return ownerUnion("email-caller", t, func(b []byte) ([]AppScope, error) { return ownerOf(b, true) })
122 case strings.HasPrefix(t.Key, "email/domain-claims/") && len(p) == 3:
123 c, err := ownerUnion("email-domain-claim", t, func(b []byte) ([]AppScope, error) { return ownerOf(b, false) })
124 if err != nil {
125 return c, err
126 }
127 // The account's domain record names every member the claim covers.
128 domain := strings.TrimSuffix(p[2], ".json")
129 for _, acct := range accountsOf(t) {
130 members, err := domainMembers(ctx, raw, acct, domain)
131 if err != nil {
132 return Classification{}, err
133 }
134 c.Scopes = append(c.Scopes, members...)
135 }
136 c.Scopes = dedupe(c.Scopes)
137 c.MailGate = true
138 return c, nil
139 case p[0] == "accounts" && len(p) == 5 && p[2] == "email" && p[3] == "domains" && controlname.ValidAccount(p[1]):
140 c, err := ownerUnion("email-domain-record", t, func(b []byte) ([]AppScope, error) { return domainRecordApps(p[1], b) })
141 c.MailGate = true
142 return c, err
143 case p[0] == "accounts" && len(p) == 3 && p[2] == "account.json" && controlname.ValidAccount(p[1]):
144 return Classification{Family: "account-manifest", NonApp: "account manifest; account deletion cascades are scoped by the SQL boundary"}, nil
145 case p[0] == "accounts" && len(p) == 3 && p[2] == "limits.json" && controlname.ValidAccount(p[1]):
146 // plans-caps-packs spec §7: the console publishes it; nothing deletes it.
147 if t.Method != "Put" {
148 return Classification{}, fmt.Errorf("%w: %s refuses deletes", ErrUnclassified, t.Key)
149 }
150 return Classification{Family: "account-limits", NonApp: "account limits record published from the plan catalog; no app state"}, nil
151 case t.Key == RelayAuthorityKey:
152 return classifyRelayAuthority(t)
153 case t.Key == RelayIssuerKey:
154 return classifyRelayIssuer(t)
155 case strings.HasPrefix(t.Key, "control/geoip/"):
156 return classifyGeoip(t)
157 case strings.HasPrefix(t.Key, "store/entitlements/") && len(p) == 3:
158 return Classification{Family: "store-entitlement", NonApp: "operator-set account entitlement; no app state"}, nil
159 case p[0] == "releases" && len(p) == 5 && p[3] == "promoted":
160 return classifyPromotionRecord(t, p)
161 case p[0] == "releases":
162 return classifyPointerDigestMarker(t, p)
163 }
164 if c, ok, err := classifyDeploymentFamily(w, t); ok {
165 return c, err
166 }
167 for _, f := range nonAppFamilies {
168 if (f.exact != "" && t.Key == f.exact) || (f.prefix != "" && strings.HasPrefix(t.Key, f.prefix)) {
169 if t.Method != "Put" && !f.deletes {
170 return Classification{}, fmt.Errorf("%w: %s refuses deletes", ErrUnclassified, t.Key)
171 }
172 return Classification{Family: "non-app", NonApp: f.reason}, nil
173 }
174 }
175 return Classification{}, fmt.Errorf("%w: %s", ErrUnclassified, t.Key)
176}
177
178// classifyPointerDigestMarker admits exactly one key under releases/: the
179// account's pointer digest marker, releases/<acct>/pointer-digests (#253,
180// spec §4.6), written by `carlos channels digests on` and deleted by `off`.
181// It is a root-signed switch that holds no app state. Anything else under
182// releases/ stays unreviewed.
183func classifyPointerDigestMarker(t MutationTarget, p []string) (Classification, error) {
184 if len(p) != 3 || !controlname.ValidAccount(p[1]) || t.Key != release.PointerDigestsKey(p[1]) ||
185 (t.Method != "Put" && t.Method != "Delete") {
186 return Classification{}, fmt.Errorf("%w: %s", ErrUnclassified, t.Key)
187 }
188 return Classification{Family: "pointer-digest-marker",
189 NonApp: "root-signed account switch for pointer digest emission; no app state"}, nil
190}
191
192// classifyPromotionRecord admits a build's signed promotion record,
193// releases/<acct>/<app>/promoted/<version> (settings plan 2, spec §4.1),
194// written when a version is promoted or authorized and deleted when the app
195// is purged. It is app state, scoped to exactly that app. Anything else
196// under releases/<acct>/<app>/ stays unreviewed.
197func classifyPromotionRecord(t MutationTarget, p []string) (Classification, error) {
198 if len(p) != 5 || p[3] != "promoted" || !controlname.ValidAccount(p[1]) || !controlname.ValidApp(p[2]) ||
199 !release.ValidVersion(p[4]) || p[4] == "dev" || t.Key != release.PromotionRecordKey(p[1], p[2], p[4]) ||
200 (t.Method != "Put" && t.Method != "Delete") {
201 return Classification{}, fmt.Errorf("%w: %s", ErrUnclassified, t.Key)
202 }
203 return appScoped("promotion-record", p[1], p[2])
204}
205
206func appScoped(family, account, app string) (Classification, error) {
207 if !controlname.ValidAccount(account) || !controlname.ValidApp(app) {
208 return Classification{}, ErrIdentity
209 }
210 return Classification{Family: family, Scopes: []AppScope{{account, app}}}, nil
211}
212
213// ownerUnion authorizes the union of the old and the proposed owners. A
214// record whose owner cannot be read refuses; a successor can never be
215// deleted after an old-owner check because the boundary deletes only the
216// exact ETag it classified.
217func ownerUnion(family string, t MutationTarget, owners func([]byte) ([]AppScope, error)) (Classification, error) {
218 var all []AppScope
219 for _, b := range [][]byte{t.Before, t.After} {
220 if len(b) == 0 {
221 continue
222 }
223 s, err := owners(b)
224 if err != nil {
225 return Classification{}, err
226 }
227 all = append(all, s...)
228 }
229 if len(all) == 0 && t.Method == "Put" {
230 return Classification{}, ErrIdentity
231 }
232 return Classification{Family: family, Scopes: dedupe(all)}, nil
233}
234
235func ownerOf(b []byte, appRequired bool) ([]AppScope, error) {
236 var o struct {
237 Account string `json:"account"`
238 App string `json:"app"`
239 }
240 if err := json.Unmarshal(b, &o); err != nil {
241 return nil, err
242 }
243 if !controlname.ValidAccount(o.Account) {
244 return nil, ErrIdentity
245 }
246 if o.App == "" {
247 if appRequired {
248 return nil, ErrIdentity
249 }
250 return nil, nil
251 }
252 if !controlname.ValidApp(o.App) {
253 return nil, ErrIdentity
254 }
255 return []AppScope{{o.Account, o.App}}, nil
256}
257
258func accountsOf(t MutationTarget) []string {
259 set := map[string]bool{}
260 for _, b := range [][]byte{t.Before, t.After} {
261 var o struct {
262 Account string `json:"account"`
263 }
264 if len(b) != 0 && json.Unmarshal(b, &o) == nil && controlname.ValidAccount(o.Account) {
265 set[o.Account] = true
266 }
267 }
268 var out []string
269 for a := range set {
270 out = append(out, a)
271 }
272 sort.Strings(out)
273 return out
274}
275
276func domainRecordApps(account string, b []byte) ([]AppScope, error) {
277 var rec struct {
278 Domain string `json:"domain"`
279 Apps []string `json:"apps"`
280 }
281 if err := json.Unmarshal(b, &rec); err != nil {
282 return nil, err
283 }
284 var out []AppScope
285 for _, app := range rec.Apps {
286 if !controlname.ValidApp(app) {
287 return nil, ErrIdentity
288 }
289 out = append(out, AppScope{account, app})
290 }
291 return out, nil
292}
293
294func domainMembers(ctx context.Context, raw release.Store, account, domain string) ([]AppScope, error) {
295 body, _, err := raw.Get(ctx, "accounts/"+account+"/email/domains/"+domain+".json")
296 if errors.Is(err, os.ErrNotExist) {
297 return nil, nil
298 }
299 if err != nil {
300 return nil, err
301 }
302 return domainRecordApps(account, body)
303}
304
305func dedupe(in []AppScope) []AppScope {
306 seen := map[AppScope]bool{}
307 var out []AppScope
308 for _, s := range in {
309 if !seen[s] {
310 seen[s] = true
311 out = append(out, s)
312 }
313 }
314 sort.Slice(out, func(i, j int) bool {
315 if out[i].Account != out[j].Account {
316 return out[i].Account < out[j].Account
317 }
318 return out[i].App < out[j].App
319 })
320 return out
321}
322
323// NewProductionMutationCheck is the one checker shared by the bucket,
324// versioned, SQL and backend boundaries.
325func NewProductionMutationCheck(raw release.Store, control *ControlWriter) (MutationCheck, error) {
326 if raw == nil || control == nil {
327 return nil, ErrContract
328 }
329 return func(ctx context.Context, t MutationTarget) error {
330 c, err := ClassifyMutation(ctx, raw, control, t)
331 if err != nil {
332 return err
333 }
334 return AuthorizeScopes(ctx, raw, control, c)
335 }, nil
336}
337
338// AuthorizeScopes requires a live permit for every scope (and the held mail
339// gate when flagged). A NonApp/lifecycle verdict with no scopes needs none.
340func AuthorizeScopes(ctx context.Context, raw release.Store, control *ControlWriter, c Classification) error {
341 if len(c.Scopes) == 0 && c.NonApp == "" && !c.MailGate {
342 return ErrIdentity
343 }
344 if c.MailGate {
345 lease, ok := MailGateLeaseFrom(ctx)
346 if !ok {
347 return ErrPending
348 }
349 if err := control.CheckMailGate(ctx, lease); err != nil {
350 return err
351 }
352 }
353 for _, s := range c.Scopes {
354 a, err := ReadCanonicalAdmission(ctx, raw, s.Account, s.App)
355 if err != nil {
356 // Absent admission is an unenrolled app: enabled mode refuses.
357 return err
358 }
359 if err = RequireAppMutation(ctx, raw, s.Account, s.App, a.Value.AppID); err != nil {
360 return err
361 }
362 }
363 return nil
364}
365
366// RelayAuthorityKey is relay.AuthorityKey: the published aggregate of every
367// enabled app's relay entry. It is per-app authority (a relay box serves an
368// app exactly when its entry is present), so it is classified per entry.
369const RelayAuthorityKey = "control/relay.json"
370
371// RelayIssuerKey is the operator's regional issuer pointer {region: kid}.
372const RelayIssuerKey = "control/relay-issuer.json"
373
374type relayAuthorityEntry struct {
375 Account string `json:"account"`
376 App string `json:"app"`
377 Incarnation string `json:"incarnation"`
378 Regions []string `json:"regions"`
379}
380
381type relayAuthorityDoc struct {
382 Apps []relayAuthorityEntry `json:"apps"`
383 WrittenAt json.RawMessage `json:"written_at"`
384}
385
386// strictJSON decodes exactly one JSON value with no unknown fields, so a
387// field the classifier does not compare can never move unpermitted.
388func strictJSON(b []byte, v any) error {
389 d := json.NewDecoder(bytes.NewReader(b))
390 d.DisallowUnknownFields()
391 if err := d.Decode(v); err != nil {
392 return fmt.Errorf("%w: %v", ErrIdentity, err)
393 }
394 if _, err := d.Token(); err != io.EOF {
395 return ErrIdentity
396 }
397 return nil
398}
399
400func relayAuthorityEntries(b []byte) (map[AppScope]relayAuthorityEntry, error) {
401 out := map[AppScope]relayAuthorityEntry{}
402 if len(b) == 0 {
403 return out, nil
404 }
405 var doc relayAuthorityDoc
406 if err := strictJSON(b, &doc); err != nil {
407 return nil, err
408 }
409 for _, e := range doc.Apps {
410 if !controlname.ValidAccount(e.Account) || !controlname.ValidApp(e.App) {
411 return nil, ErrIdentity
412 }
413 k := AppScope{e.Account, e.App}
414 if _, dup := out[k]; dup {
415 return nil, fmt.Errorf("%w: %s/%s appears twice in %s", ErrIdentity, e.Account, e.App, RelayAuthorityKey)
416 }
417 out[k] = e
418 }
419 return out, nil
420}
421
422// RelayAuthorityScopes is the exact app set a rewrite of control/relay.json
423// changes: every (account, app) entry added, removed or altered between
424// before and after, sorted and deduplicated. Entries identical on both sides
425// are preserved by the exact-ETag CAS and need no permit. A malformed
426// document or a duplicate entry on either side refuses. The relay publisher
427// calls this same function to choose its permits, so the publisher and the
428// boundary cannot disagree about the set.
429func RelayAuthorityScopes(before, after []byte) ([]AppScope, error) {
430 b, err := relayAuthorityEntries(before)
431 if err != nil {
432 return nil, err
433 }
434 a, err := relayAuthorityEntries(after)
435 if err != nil {
436 return nil, err
437 }
438 var changed []AppScope
439 for k, old := range b {
440 cur, ok := a[k]
441 if !ok || cur.Incarnation != old.Incarnation || !slices.Equal(cur.Regions, old.Regions) {
442 changed = append(changed, k)
443 }
444 }
445 for k := range a {
446 if _, ok := b[k]; !ok {
447 changed = append(changed, k)
448 }
449 }
450 return dedupe(changed), nil
451}
452
453func classifyRelayAuthority(t MutationTarget) (Classification, error) {
454 if t.Method != "Put" || len(t.After) == 0 {
455 return Classification{}, fmt.Errorf("%w: %s refuses deletes", ErrUnclassified, t.Key)
456 }
457 scopes, err := RelayAuthorityScopes(t.Before, t.After)
458 if err != nil {
459 return Classification{}, err
460 }
461 if len(scopes) == 0 {
462 return Classification{Family: "relay-authority", NonApp: "relay aggregate rewrite with every app entry identical; only written_at moves"}, nil
463 }
464 return Classification{Family: "relay-authority", Scopes: scopes}, nil
465}
466
467// classifyRelayIssuer admits the operator's regional issuance pointer. It
468// names which staged master each region issues from and holds no app
469// record; every app re-issued under a moved pointer is its own gated app
470// mutation.
471func classifyRelayIssuer(t MutationTarget) (Classification, error) {
472 if t.Method != "Put" || len(t.After) == 0 {
473 return Classification{}, fmt.Errorf("%w: %s refuses deletes", ErrUnclassified, t.Key)
474 }
475 var issuers map[string]string
476 if err := strictJSON(t.After, &issuers); err != nil {
477 return Classification{}, err
478 }
479 if issuers == nil {
480 return Classification{}, ErrIdentity
481 }
482 for region, kid := range issuers {
483 if !validRelayRegion(region) || !validRelayKid(kid) {
484 return Classification{}, ErrIdentity
485 }
486 }
487 return Classification{Family: "relay-issuer", NonApp: "operator regional relay issuer pointer; names a staged master kid per region and holds no app record"}, nil
488}
489
490func validRelayRegion(s string) bool {
491 if s == "" || len(s) > 64 {
492 return false
493 }
494 for _, c := range s {
495 if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
496 return false
497 }
498 }
499 return true
500}
501
502// validRelayKid mirrors relay.ValidKid (1-8 lowercase letters or digits);
503// hostops does not import the relay server package.
504func validRelayKid(s string) bool {
505 if s == "" || len(s) > 8 {
506 return false
507 }
508 for _, c := range s {
509 if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9') {
510 return false
511 }
512 }
513 return true
514}
515