carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The store door: release.Store spoken over HTTPS on the edge's reserved
2// tunnel hostname, so a fleet box needs no AWS credentials on the platform's
3// deployment bucket at all (spec 2026-08-11-fleet-api-surface-design.md,
4// F1-F4; issue #73).
5//
6// One mechanism, not N endpoints: the five Store operations, authenticated
7// per request by the credential the box already holds (its fleet tunnel
8// token, verified through TunnelHub.VerifyDynamic — the same hash table
9// detach/rotate already converge in seconds), and ACL'd server-side from the
10// fleet's registry row. The box's own loops — adoption, fast-converge,
11// config, status, metrics, bindings — run unchanged against the client half
12// (Task 3).
13//
14// Everything a caller is not entitled to is a byte-identical 404: wrong
15// token, static (non-fleet) name, another tenant's key, a key that does not
16// exist, a key shaped like a traversal. The keyspace must never be an
17// oracle, so no response distinguishes them, and no refusal ever costs more
18// than one constant-time verify.
19package edge
20
21import (
22 "encoding/json"
23 "errors"
24 "io"
25 "log"
26 "net/http"
27 "os"
28 "sort"
29 "strconv"
30 "strings"
31
32 "github.com/carlosframework/platform/internal/domains"
33 "github.com/carlosframework/platform/internal/plans"
34 "github.com/carlosframework/platform/internal/release"
35)
36
37const (
38 // storeDoorPrefix is what edge.go routes to this handler on the reserved
39 // tunnel hostname; /register (the tunnel handshake) stays with the hub.
40 storeDoorPrefix = "/store/"
41 storeObjectPrefix = "/store/o/"
42 storeListPath = "/store/list"
43 storeDirsPath = "/store/dirs"
44
45 // maxStoreKeyBytes is the pre-ACL key ceiling (spec: keys >1024 bytes are
46 // refused before the ACL runs). S3's own limit.
47 maxStoreKeyBytes = 1024
48 // maxStoreBodyBytes bounds one PUT. Everything a box may write lives
49 // under fleets/<own>/ — heartbeats, metrics, alerts, bindings, shipped
50 // logs — none of which approach this; the ceiling exists so a
51 // compromised box cannot make the edge buffer arbitrary memory.
52 maxStoreBodyBytes = 32 << 20
53)
54
55// storeDoor serves release.Store over HTTPS for fleet boxes (spec F1-F4).
56//
57// It holds the deployment-store handle the edge ALREADY has (EnableStatic's,
58// shared with the static and status serving classes) — never a second S3
59// client, and never anything the request can influence: no client-supplied
60// AWS header is read, and no bucket identity is ever exposed (F8: artifact
61// bytes stream through the edge rather than being redirected to a presigned
62// URL).
63type storeDoor struct {
64 hub *TunnelHub // VerifyDynamic: the one auth path, called once per request
65 store release.Store // the deployment bucket, edge's own handle
66 // fleetAccount maps an authenticated fleet name to its owning account's
67 // sqid — the whole basis of the read ACL. Backed by the edge's
68 // reload-time snapshot (Edge.fleetAccount), refreshed on the same pass
69 // that feeds the hub's FleetChannelAuth table, so a fleet's identity and
70 // its credentials can never come from two different reads of the
71 // registry.
72 fleetAccount func(fleet string) (sqid string, ok bool)
73}
74
75// ServeHTTP is the whole door. The order is load-bearing:
76//
77// 1. auth — exactly once, before anything else, for every path and method;
78// 2. fleet → account (the ACL's identity);
79// 3. key canonicalization (traversal, empty segments, oversize);
80// 4. the ACL;
81// 5. only then the request's own well-formedness (a PUT's precondition) and
82// the store.
83//
84// Steps 1-4 all answer http.NotFound, so a caller cannot learn from a
85// response which of them refused it — in particular a 400 for a missing
86// precondition is only ever reachable on a key this fleet may actually
87// write, never as a probe for whether such a key exists.
88//
89// The CAS mapping onto release.Store's contract (store.go), exactly:
90//
91// If-None-Match: * → Put(ifMatch: "") create-only
92// If-Match: <etag> → Put(ifMatch: <etag>) replace-only
93// neither → 400 (there is no unconditional Put in the contract)
94// ErrCASConflict → 412
95// Get not-found → 404 (os.ErrNotExist-wrapped, per the contract)
96// Delete → 204, always, for an in-scope key (idempotent)
97//
98// Etags are opaque strings on this wire, passed through in both directions
99// exactly as the backing store produced them (DirStore's bare sha256 hex,
100// S3's quoted tag): the door never parses or rewrites one, so the client's
101// If-Match always carries the same bytes its Get was handed.
102func (d *storeDoor) ServeHTTP(w http.ResponseWriter, r *http.Request) {
103 // Auth, once. Same two headers as the tunnel dial (F1), same table (F2):
104 // only fleet channels ("<fleet>/<label>") can pass — VerifyDynamic
105 // refuses slash-less static names outright, so a CARLOS_TUNNEL_CLIENTS
106 // credential can never open this door. The token is never logged, never
107 // echoed, and never compared in the clear.
108 name := r.Header.Get("Carlos-Tunnel-Name")
109 token, hasBearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
110 if name == "" || !hasBearer {
111 http.NotFound(w, r)
112 return
113 }
114 fleet, ok := d.hub.VerifyDynamic(name, token)
115 if !ok {
116 http.NotFound(w, r)
117 return
118 }
119 sqid, ok := d.fleetAccount(fleet)
120 if !ok || sqid == "" {
121 // Authenticated, but the fleet's registry row is not in the edge's
122 // current snapshot (a fleet deleted between reloads, or a hub whose
123 // auth table was set by hand). No identity, no ACL, no answer.
124 http.NotFound(w, r)
125 return
126 }
127 switch {
128 case strings.HasPrefix(r.URL.Path, storeObjectPrefix):
129 d.serveObject(w, r, sqid, fleet, strings.TrimPrefix(r.URL.Path, storeObjectPrefix))
130 case r.URL.Path == storeListPath:
131 d.serveListing(w, r, sqid, fleet, false)
132 case r.URL.Path == storeDirsPath:
133 d.serveListing(w, r, sqid, fleet, true)
134 default:
135 http.NotFound(w, r)
136 }
137}
138
139// serveObject is Get/Put/Delete on one key. rawKey is the path remainder,
140// already percent-decoded by net/http — which is exactly what must be
141// canonicalized: an encoded "%2e%2e" arrives here as a real ".." segment.
142func (d *storeDoor) serveObject(w http.ResponseWriter, r *http.Request, sqid, fleet, rawKey string) {
143 key, ok := canonicalKey(rawKey)
144 if !ok {
145 http.NotFound(w, r)
146 return
147 }
148 ctx := r.Context()
149 switch r.Method {
150 case http.MethodGet:
151 if strings.HasPrefix(key, domains.ClaimsPrefix) {
152 d.serveClaim(w, r, sqid, key)
153 return
154 }
155 if !aclRead(sqid, fleet, key) {
156 http.NotFound(w, r)
157 return
158 }
159 body, etag, err := d.store.Get(ctx, key)
160 if err != nil {
161 if errors.Is(err, os.ErrNotExist) {
162 http.NotFound(w, r) // byte-identical to every refusal above
163 return
164 }
165 d.storeFailed(w, r, "get", key, err)
166 return
167 }
168 if etag != "" {
169 w.Header().Set("ETag", etag)
170 }
171 w.Header().Set("Content-Type", "application/octet-stream")
172 w.Header().Set("Content-Length", strconv.Itoa(len(body)))
173 w.Write(body)
174 case http.MethodPut:
175 if !aclWrite(sqid, fleet, key) {
176 http.NotFound(w, r)
177 return
178 }
179 // Precondition parsing is AFTER the ACL on purpose: a 400 here must
180 // never be reachable for a key the caller may not write, or the
181 // difference between 400 and 404 would map the keyspace.
182 ifMatch, ok := putPrecondition(r)
183 if !ok {
184 http.Error(w, "want If-Match: <etag> or If-None-Match: *", http.StatusBadRequest)
185 return
186 }
187 body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxStoreBodyBytes))
188 if err != nil {
189 var tooLarge *http.MaxBytesError
190 if errors.As(err, &tooLarge) {
191 http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
192 return
193 }
194 http.Error(w, "read body", http.StatusBadRequest)
195 return
196 }
197 etag, err := d.store.Put(ctx, key, body, ifMatch)
198 if err != nil {
199 if errors.Is(err, release.ErrCASConflict) {
200 // The client maps this back to release.ErrCASConflict, so a
201 // heartbeat or binding takeover keeps its exact semantics
202 // through the proxy (F4).
203 http.Error(w, "precondition failed", http.StatusPreconditionFailed)
204 return
205 }
206 d.storeFailed(w, r, "put", key, err)
207 return
208 }
209 if etag != "" {
210 w.Header().Set("ETag", etag)
211 }
212 w.WriteHeader(http.StatusOK)
213 case http.MethodDelete:
214 if !aclWrite(sqid, fleet, key) {
215 http.NotFound(w, r)
216 return
217 }
218 if err := d.store.Delete(ctx, key); err != nil {
219 d.storeFailed(w, r, "delete", key, err)
220 return
221 }
222 w.WriteHeader(http.StatusNoContent) // idempotent, per the Store contract
223 default:
224 http.NotFound(w, r)
225 }
226}
227
228// serveListing is List (dirs=false) and ListDirs (dirs=true). The requested
229// prefix is scope-MAPPED rather than merely checked (aclListScope): a caller
230// asking for "apps/" gets its own account's subtree, which is what lets an
231// unmodified release.Store caller work through the door. Every returned entry
232// is then re-checked per tenant — aclRead for objects, aclDir for child
233// prefixes — so the answer is in scope even if a future mapping rule is ever
234// wrong, and so the one scope that IS shared (accounts/) can only ever answer
235// with this caller's own record.
236func (d *storeDoor) serveListing(w http.ResponseWriter, r *http.Request, sqid, fleet string, dirs bool) {
237 if r.Method != http.MethodGet {
238 http.NotFound(w, r)
239 return
240 }
241 prefix, ok := canonicalPrefix(r.URL.Query().Get("prefix"))
242 if !ok {
243 http.NotFound(w, r)
244 return
245 }
246 scope, ok := aclListScope(sqid, fleet, prefix)
247 if !ok {
248 http.NotFound(w, r)
249 return
250 }
251 if scope == domains.ClaimsPrefix {
252 d.serveClaimListing(w, r, dirs)
253 return
254 }
255 out := []string{} // never null on the wire: an empty listing is []
256 if dirs {
257 got, err := d.store.ListDirs(r.Context(), scope)
258 if err != nil {
259 d.storeFailed(w, r, "listdirs", scope, err)
260 return
261 }
262 for _, dir := range got {
263 // Judged per TENANT, never "is it under the prefix we listed":
264 // under the shared accounts/ scope that test is a tautology and
265 // would enumerate every account on the platform (aclDir).
266 if aclDir(sqid, fleet, dir) {
267 out = append(out, dir)
268 }
269 }
270 } else {
271 got, err := d.store.List(r.Context(), scope)
272 if err != nil {
273 d.storeFailed(w, r, "list", scope, err)
274 return
275 }
276 for _, key := range got {
277 if aclRead(sqid, fleet, key) {
278 out = append(out, key)
279 }
280 }
281 }
282 w.Header().Set("Content-Type", "application/json")
283 _ = json.NewEncoder(w).Encode(out)
284}
285
286// storeFailed answers a genuine backing-store failure. It is deliberately NOT
287// folded into the 404 shape: a bucket outage that looked like "the object is
288// gone" would make a box act on absence (a missing channel pointer, an empty
289// listing) instead of retrying. It is no keyspace oracle either — it is only
290// reachable once the key has already passed canonicalization and the ACL, so
291// the caller learns nothing it was not already entitled to. The log line
292// carries the key and the error; never the token, never the auth header.
293func (d *storeDoor) storeFailed(w http.ResponseWriter, r *http.Request, op, key string, err error) {
294 log.Printf("edge: store door: %s %s: %v", op, key, err)
295 http.Error(w, "store error", http.StatusBadGateway)
296}
297
298// putPrecondition maps the request's conditional headers onto release.Store's
299// ifMatch argument. The Store contract has no unconditional Put — "" is
300// create-only (If-None-Match: *) and non-empty is replace-only — so a PUT
301// carrying neither header is a client bug, refused rather than silently
302// turned into a create. Both headers together is equally ill-defined.
303// If-None-Match is honoured only as the literal "*": an etag list there would
304// mean something this wire does not implement.
305func putPrecondition(r *http.Request) (ifMatch string, ok bool) {
306 im := strings.TrimSpace(r.Header.Get("If-Match"))
307 inm := strings.TrimSpace(r.Header.Get("If-None-Match"))
308 switch {
309 case im != "" && inm != "":
310 return "", false
311 case inm == "*":
312 return "", true
313 case inm != "":
314 return "", false
315 case im != "":
316 return im, true
317 default:
318 return "", false
319 }
320}
321
322// canonicalKey is the pre-ACL gate: it refuses, rather than cleans, anything
323// that could re-point a prefix — "..", "." and empty segments (which covers a
324// leading "/", a trailing "/" and any "//"), an empty key, control bytes, and
325// anything over maxStoreKeyBytes. Refusing rather than normalizing is the
326// point: the ACL below is prefix-string exact, and a key that had to be
327// rewritten to be safe is a key whose author meant something else.
328//
329// It runs BEFORE the ACL, so "apps/<own>/../<other>/x" never reaches a prefix
330// comparison that would have approved its first segment.
331func canonicalKey(raw string) (string, bool) {
332 if raw == "" || len(raw) > maxStoreKeyBytes {
333 return "", false
334 }
335 if strings.HasPrefix(raw, "/") || strings.HasSuffix(raw, "/") {
336 return "", false
337 }
338 for i := 0; i < len(raw); i++ {
339 if raw[i] < 0x20 || raw[i] == 0x7f {
340 return "", false
341 }
342 }
343 for _, seg := range strings.Split(raw, "/") {
344 if seg == "" || seg == "." || seg == ".." {
345 return "", false
346 }
347 }
348 return raw, true
349}
350
351// canonicalPrefix is canonicalKey for a LISTING prefix: same refusals, except
352// that a prefix may end in "/" (and usually does — "apps/", "fleets/pi/").
353// The trailing slash is preserved, because aclListScope's mapping rules are
354// exact string matches and "apps" must not be allowed to stand in for a
355// prefix that would also match "appsomething/".
356func canonicalPrefix(raw string) (string, bool) {
357 if _, ok := canonicalKey(strings.TrimSuffix(raw, "/")); !ok {
358 return "", false
359 }
360 return raw, true
361}
362
363// aclRead is the read half of F3, prefix-string exact against the
364// authenticated fleet's own identity:
365//
366// control/serial the convergence poll
367// bootstrap/carlos the sanctioned self-update path (F7);
368// stays until update-platform-binary.sh
369// is retired (spec §10)
370// control/layout.json the fleet's layout intent (isolated-exec
371// design §8.2) — see release.LayoutIntentKey()
372// control/system/<c>/<ch>.json its platform channel pointer (system
373// update spec §3) — see systemReadKey
374// bootstrap/router/<v>/<asset> a mirrored platform artifact — see
375// systemReadKey
376// accounts/<sqid>/** its owning account's record — the manifest lives at
377// accounts/<sqid>/account.json (domains.AccountManifestKey),
378// NOT accounts/<sqid>.json: a box that cannot read it
379// sees an empty account set, and provision.Reconcile's
380// removal phase then retires every provisioned route on
381// the box (knownAccounts -> ParseRecordKey)
382// apps/<sqid>/** its owning account's whole app estate
383// fleets/<fleet>/** its own corner
384// releases/<sqid>/<app>/promoted/<version>
385// a promotion record of its owning account's
386// build — see promotionRecordRead
387//
388// domains/ is NOT here, and is served by its own two paths instead
389// (serveClaim, serveClaimListing): a box can list every claimed hostname,
390// but reads only its own account's verified claims, with the person who
391// made each one stripped out (#215).
392//
393// The fleets/<fleet>/** line is not in F3's enumeration and is not a
394// widening of it: the fleet's own prefix is the only place it may WRITE, and
395// every write on this wire is a CAS (read the etag, then Put with If-Match).
396// A write scope you cannot read is a write scope you cannot use — the
397// binding takeover guard and the heartbeat both read their own object first.
398// It grants access to nothing beyond what aclWrite already grants.
399//
400// control/layout.json is not in F3's enumeration either, and is granted for
401// the same reason control/serial is: every box needs the fleet's layout
402// intent to converge (isolated-exec design §8.2), and the object holds
403// nothing more than a mode and a box label, never tenant data. Write stays
404// console-only: aclWrite never answers true for it.
405//
406// domains/ was once readable here whole (#151), on the reasoning that a
407// claim says no more than DNS does. It says more: who made it (a person's
408// address), the account and app behind it, its path map, and names that do
409// not resolve yet or that nobody has proved they control. Anyone can create
410// an account and a fleet, so that was every tenant's claims for the asking.
411// What a box actually needs is narrower. ReconcileAliases only ever
412// materializes a claim whose route is a local route of the claim's own
413// account, and a fleet box's routes are all its own account's, so those are
414// the only bodies the door serves (serveClaim). It also needs to know which
415// NAMES are claimed at all, whoever by, so the listing stays whole
416// (serveClaimListing). Write stays console-only: aclWrite never answers true
417// for a domains/ key.
418//
419// A promotion record (settings spec §4.1, release.PromotionRecordKey) is
420// granted because a box needs it and it holds nothing secret: it is the
421// root-signed digest of one of the account's own builds, readable by anyone
422// holding the public key it verifies under. HeldAuthorized reads it before
423// staging a held release, and AuthorizedManifest before any install the
424// settings add. Without the grant every record reads as absent through the
425// door, so a build authorized by record alone could never stage on a fleet
426// box, and one a legacy pointer still names would fall back to that
427// pointer's weaker word. The grant is one object shape, not the prefix the
428// records sit under: releases/ also holds the account's pointer digest
429// marker, which a box has no use for. Listing is not granted (aclDir and
430// aclListScope are unchanged), and write stays console-only: aclWrite never
431// answers true for a releases/ key.
432func aclRead(sqid, fleet, key string) bool {
433 if sqid == "" || fleet == "" {
434 return false
435 }
436 switch key {
437 case "control/serial", "bootstrap/carlos", release.LayoutIntentKey(), plans.MarkerKey():
438 return true
439 }
440 if systemReadKey(key) || promotionRecordRead(sqid, key) {
441 return true
442 }
443 return strings.HasPrefix(key, "accounts/"+sqid+"/") ||
444 strings.HasPrefix(key, "apps/"+sqid+"/") ||
445 strings.HasPrefix(key, "fleets/"+fleet+"/")
446}
447
448// promotionRecordRead is the one releases/ shape aclRead grants: exactly
449// releases/<sqid>/<app>/promoted/<version>, five segments, the box's own
450// account, and a valid app and version name (release.PromotionRecordKey's
451// inputs). canonicalKey has already refused any "." or ".." segment.
452func promotionRecordRead(sqid, key string) bool {
453 seg := strings.Split(key, "/")
454 return len(seg) == 5 && seg[0] == "releases" && seg[1] == sqid &&
455 release.ValidApp(seg[2]) && seg[3] == "promoted" && release.ValidVersion(seg[4])
456}
457
458// systemReadKey is the two shapes the system-update spec (§3) grants every
459// fleet box: its platform channel pointer and a mirrored platform artifact.
460// Shapes, not prefixes — exactly three segments each, so nothing else that
461// ever lands under control/ or bootstrap/ is readable by a box. Listing is
462// not granted (aclDir is unchanged), and canonicalKey has already refused
463// any "." or ".." segment before this runs.
464func systemReadKey(key string) bool {
465 if rest, ok := strings.CutPrefix(key, "control/system/"); ok {
466 comp, file, ok := strings.Cut(rest, "/")
467 return ok && comp != "" && file != "" && !strings.Contains(file, "/") &&
468 strings.HasSuffix(file, ".json") && len(file) > len(".json")
469 }
470 if rest, ok := strings.CutPrefix(key, "bootstrap/router/"); ok {
471 ver, asset, ok := strings.Cut(rest, "/")
472 return ok && ver != "" && asset != "" && !strings.Contains(asset, "/")
473 }
474 return false
475}
476
477// aclDir is aclRead for a LISTING's child prefixes (ListDirs), which are
478// directories rather than objects and so cannot be judged by it: a returned
479// prefix is in scope exactly when it lies inside one of this tenant's own
480// subtrees, or IS its own account directory.
481//
482// It exists because "the prefix we listed under" is not a sufficient filter
483// for the one shared scope: accounts/ must be listable (it is how a box
484// discovers its own account record) but ListDirs("accounts/") returns
485// accounts/<sqid>/ for EVERY tenant on the platform, and a HasPrefix check
486// against the scope we just used is a tautology that would hand all of them
487// back.
488func aclDir(sqid, fleet, dir string) bool {
489 if sqid == "" || fleet == "" {
490 return false
491 }
492 if dir == "accounts/"+sqid+"/" {
493 return true
494 }
495 return strings.HasPrefix(dir, "apps/"+sqid+"/") ||
496 strings.HasPrefix(dir, "fleets/"+fleet+"/")
497}
498
499// aclWrite is the write half of F3 — and DELETE's half too. Two scopes, and
500// only two:
501//
502// fleets/<fleet>/** its own corner
503// apps/<sqid>/<app>/<family>/** its own account's app logs, errors, analytics (applog.Prefix)
504//
505// It can never write a release artifact, a channel pointer, or an instance
506// record, which is the tamper surface the whole spec exists to close (F6:
507// retention does not arm on a fleet box either). It can never write a
508// domains/ claim either (#151): the door only ever reads claims to a box
509// (serveClaim, serveClaimListing). A claim is a console-mediated hostname
510// reservation, and a box that could write one could mint or steal any
511// hostname on the platform, not just its own account's.
512//
513// The family scope is SEGMENT-EXACT on purpose, and the exactness is the
514// security property: the grant is apps/<sqid>/<ONE app segment>/<ONE family>/**, where
515// family is one of [logs, errors, analytics], never apps/<sqid>/** and never
516// a "family appears somewhere in the key" test. A blanket own-account write
517// would let a box pin its own channels/*.json (self-promoting any build it
518// likes) or mint instances/*.json records (self-authorizing routes) —
519// precisely the writes this door exists to prevent. Chunks and day files are
520// the one thing a box legitimately produces about its own tenant's apps
521// (applog.ChunkKey), and they are write-once objects under a per-box subtree.
522//
523// HONEST LIMIT: the <box> segment inside that subtree is an unverifiable
524// pseudonym (status.Label of a machine id the box chose), so one box of an
525// account can in principle delete a same-account sibling box's chunks. The
526// blast radius is inside a single account's own logs — no other tenant, no
527// release data — and pruning its own subtree is exactly what logship must do
528// every hour; verifying the segment would need a per-box identity the door
529// does not have today.
530func aclWrite(sqid, fleet, key string) bool {
531 if fleet == "" {
532 return false
533 }
534 if strings.HasPrefix(key, "fleets/"+fleet+"/") {
535 return true
536 }
537 return aclWriteAppFamily(sqid, key)
538}
539
540// chunkFamilies is the allow-list of app subtrees a box may write: the
541// chunk and day-file families it produces about its own tenant's apps
542// (applog.Family). Exact segment names — "logs" is not "logsx".
543var chunkFamilies = map[string]bool{"logs": true, "errors": true, "analytics": true}
544
545// aclWriteAppFamily implements aclWrite's second scope: exactly
546// apps/<sqid>/<app>/<family>/<something>, with <app> a single non-empty
547// segment, <family> one of chunkFamilies, and at least one key component
548// below the family. Anything else — a shorter key, a second path shape,
549// a family name as the app segment — is refused, so the grant can never be
550// widened by a key that merely contains the word.
551func aclWriteAppFamily(sqid, key string) bool {
552 if sqid == "" {
553 return false
554 }
555 rest, ok := strings.CutPrefix(key, "apps/"+sqid+"/")
556 if !ok {
557 return false
558 }
559 app, tail, ok := strings.Cut(rest, "/")
560 if !ok || app == "" {
561 return false
562 }
563 fam, below, ok := strings.Cut(tail, "/")
564 return ok && chunkFamilies[fam] && below != ""
565}
566
567// aclListScope maps a requested listing prefix onto the prefix the door will
568// actually list, or refuses. Mapping rather than refusing is what keeps
569// unmodified release.Store callers working: "apps/" is what platform code
570// asks for, and a fleet box asking it gets its own account's subtree back
571// (spec F3: "list of accounts/ and apps/ answers only the own-account
572// subset"). A bare "apps"/"fleets"/"accounts" (no trailing slash) maps the
573// same way; anything narrower must already be inside the caller's own
574// subtree, and a partial sqid or fleet name ("apps/ab1", "fleets/pix/") is
575// refused rather than widened, since S3 prefix matching would otherwise reach
576// a sibling tenant.
577//
578// accounts/ is the one scope that maps to a SHARED prefix — it has to be
579// listable, since that is how a box finds its own account manifest
580// (provision.knownAccounts lists accounts/ and parses
581// accounts/<sqid>/account.json). The caller may see only its own record
582// there, which serveListing enforces by re-checking every returned entry
583// (aclRead for objects, aclDir for the child prefixes ListDirs returns —
584// there is one per account on the platform).
585func aclListScope(sqid, fleet, prefix string) (string, bool) {
586 if sqid == "" || fleet == "" || prefix == "" {
587 return "", false
588 }
589 ownApps := "apps/" + sqid + "/"
590 ownFleet := "fleets/" + fleet + "/"
591 switch {
592 case prefix == "apps" || prefix == "apps/" || prefix == "apps/"+sqid:
593 return ownApps, true
594 case strings.HasPrefix(prefix, ownApps):
595 return prefix, true
596 case strings.HasPrefix(prefix, "apps/"):
597 return "", false
598 case prefix == "accounts" || prefix == "accounts/":
599 return "accounts/", true
600 case prefix == "domains" || prefix == "domains/":
601 // #151: the OTHER shared scope. Claims are keyed by hostname, not
602 // tenant, so there is no subtree to narrow into: serveClaimListing
603 // names every claimed hostname, and serveClaim withholds every body
604 // but this account's verified ones (#215). ReconcileAliases always
605 // lists the whole prefix; a narrower ask (e.g.
606 // "domains/x.example.json") is refused rather than widened, matching accounts/'s deliberate refusal of
607 // "accounts/<sqid>/" — see that case's comment.
608 return "domains/", true
609 case prefix == "fleets" || prefix == "fleets/" || prefix == "fleets/"+fleet:
610 return ownFleet, true
611 case strings.HasPrefix(prefix, ownFleet):
612 return prefix, true
613 default:
614 return "", false
615 }
616}
617
618// serveClaim answers a GET of one hostname claim. A box gets a claim only
619// when it is its own account's and verified — the only claims its
620// ReconcileAliases could ever act on — and never its created_by, which is a
621// person's address the box has no use for. Everything else is the door's
622// byte-identical 404, so a fleet credential cannot probe which hostnames
623// other tenants hold.
624//
625// The body is redacted as a generic JSON object rather than through
626// domains.Claim, so a field a newer console wrote survives the trip to a
627// box that understands it even if this edge does not.
628func (d *storeDoor) serveClaim(w http.ResponseWriter, r *http.Request, sqid, key string) {
629 body, etag, err := d.store.Get(r.Context(), key)
630 if err != nil {
631 if errors.Is(err, os.ErrNotExist) {
632 http.NotFound(w, r)
633 return
634 }
635 d.storeFailed(w, r, "get", key, err)
636 return
637 }
638 var obj map[string]json.RawMessage
639 if json.Unmarshal(body, &obj) != nil {
640 http.NotFound(w, r)
641 return
642 }
643 var account string
644 var pending bool
645 _ = json.Unmarshal(obj["account"], &account)
646 _ = json.Unmarshal(obj["pending"], &pending)
647 if account != sqid || pending {
648 http.NotFound(w, r)
649 return
650 }
651 delete(obj, "created_by")
652 out, err := json.Marshal(obj)
653 if err != nil {
654 d.storeFailed(w, r, "get", key, err)
655 return
656 }
657 if etag != "" {
658 w.Header().Set("ETag", etag)
659 }
660 w.Header().Set("Content-Type", "application/octet-stream")
661 w.Header().Set("Content-Length", strconv.Itoa(len(out)))
662 w.Write(out)
663}
664
665// serveClaimListing lists every claim KEY under domains/: hostnames only,
666// whoever holds them. The listing has to be whole, because ReconcileAliases
667// decides "a claim on this name wins, whoever holds it" from it (an instance
668// catch-all derives no <label>.<parent> alias over a name another account
669// claimed), and a listing narrowed to this account would let a box shadow
670// another tenant's claim. A hostname alone is what DNS publishes anyway; the
671// claim behind it (who, which app, its paths, whether it is proven) stays
672// behind serveClaim's 404. Child directories: none, claims are flat.
673func (d *storeDoor) serveClaimListing(w http.ResponseWriter, r *http.Request, dirs bool) {
674 out := []string{}
675 if !dirs {
676 keys, err := d.store.List(r.Context(), domains.ClaimsPrefix)
677 if err != nil {
678 d.storeFailed(w, r, "list", domains.ClaimsPrefix, err)
679 return
680 }
681 for _, key := range keys {
682 if _, ok := canonicalKey(key); ok && strings.HasSuffix(key, ".json") {
683 out = append(out, key)
684 }
685 }
686 sort.Strings(out)
687 }
688 w.Header().Set("Content-Type", "application/json")
689 _ = json.NewEncoder(w).Encode(out)
690}
691