| 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. |
| 19 | package edge |
| 20 | |
| 21 | import ( |
| 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 | |
| 37 | const ( |
| 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). |
| 63 | type 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. |
| 102 | func (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. |
| 142 | func (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. |
| 236 | func (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. |
| 293 | func (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. |
| 305 | func 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. |
| 331 | func 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/". |
| 356 | func 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. |
| 432 | func 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. |
| 452 | func 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. |
| 464 | func 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. |
| 488 | func 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. |
| 530 | func 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". |
| 543 | var 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. |
| 551 | func 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). |
| 585 | func 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. |
| 628 | func (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. |
| 673 | func (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 | |