carlos / platform Public

edge: store door — the account manifest key, and dirs listing per tenant

Two review Criticals on the door, fixed together.

C1: the account manifest is accounts/<sqid>/account.json
(domains.AccountManifestKey, mint.go:91 — parsed by AccountManifestSqid and
domains/reconcile.go), not accounts/<sqid>.json. The ACL allowed a key that
does not exist, so a fleet box's knownAccounts would have come back empty
with readFailed==false, ParseRecordKey would have rejected the box's own
records, and provision.Reconcile's removal phase would have retired EVERY
provisioned route on the box at its first API tick. aclRead now allows the
accounts/<own-sqid>/ prefix; the matrix and the seeds use the real shape,
with rows pinning that the old wrong key is NOT granted and that a longer
sqid never matches. The spec's F3 line carried the same wrong shape and is
corrected with it.

C2: ListDirs returns one child prefix per account under the shared
accounts/ scope, and filtering those by "is it under the prefix we listed"
is a tautology — GET /store/dirs?prefix=accounts/ would have enumerated
every account on the platform. Child prefixes now go through aclDir, a real
per-tenant predicate (under apps/<sqid>/ or fleets/<fleet>/, or exactly
accounts/<sqid>/), pinned by its own table test and by a handler regression
with a second account seeded.

Minor folded in: aclListScope's ownApps[:len(ownApps)-1] slicing reads as a
literal case now.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a22971efa503274a7563fc3e602e8b1787620c1f parent 99e4592
3 files changed, +98 −25
  • docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md +1 −1
  • internal/edge/storedoor.go +53 −18
  • internal/edge/storedoor_test.go +44 −6
diff --git a/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md b/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md
index 270e39971..ce95f9c00 100644
--- a/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md
+++ b/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md
@@ -49,7 +49,7 @@ reconcile, config, status, metrics, bindings — runs unchanged.
|---|---|---|
| F1 | **Transport is the edge's reserved tunnel hostname**, new paths beside `/register`: `GET/PUT/DELETE /store/o/<key>`, `GET /store/list?prefix=`, `GET /store/dirs?prefix=`. Same headers as the tunnel dial: `Authorization: Bearer <token>` + `Carlos-Tunnel-Name: <fleet>/<label>`. | The reserved host is already TLS'd, already routed, already carries the only box-shaped credential the platform can verify. No new hostname, cert, or credential mint. |
| F2 | **Auth = the dynamic-auth table**, via a new `TunnelHub.VerifyDynamic(name, token) (fleet string, ok bool)` refactored from ServeHTTP's dynamic branch (constant-time hash compare, single-401 shape preserved; static `CARLOS_TUNNEL_CLIENTS` names never authenticate here — this door is for fleet boxes only). | One credential, one hash table, one revocation path: detach/rotate already converge to the edge in seconds and now revoke API access with the same motion. |
-| F3 | **ACL is account-scoped + own-prefix**, derived server-side from the authenticated fleet's registry row (`fleets.account` → sqid): READ allows `control/serial`, `bootstrap/carlos`, `accounts/<own-sqid>.json`, and `apps/<own-sqid>/**`; list of `accounts/` and `apps/` answers only the own-account subset. WRITE/DELETE allow `fleets/<own-fleet>/**` only. Everything else: byte-identical 404 (never 403 — the keyspace must not be an oracle). | This is the issue's "account-scoped surface" literally: a fleet reads only its owning account's estate and writes only its own corner. A compromised customer box can no longer read other tenants or touch platform releases. |
+| F3 | **ACL is account-scoped + own-prefix**, derived server-side from the authenticated fleet's registry row (`fleets.account` → sqid): READ allows `control/serial`, `bootstrap/carlos`, `accounts/<own-sqid>/**` (the account manifest is `accounts/<sqid>/account.json` — `domains.AccountManifestKey`, one path level deep — NOT `accounts/<sqid>.json`; a box that cannot read it sees zero accounts and `provision.Reconcile` retires every provisioned route on it), and `apps/<own-sqid>/**`; list of `accounts/` and `apps/` answers only the own-account subset, INCLUDING the delimiter-aware listing — `ListDirs("accounts/")` returns one child prefix per account on the platform, so it is filtered per tenant, not against the prefix it was listed under. WRITE/DELETE allow `fleets/<own-fleet>/**` only. Everything else: byte-identical 404 (never 403 — the keyspace must not be an oracle). | This is the issue's "account-scoped surface" literally: a fleet reads only its owning account's estate and writes only its own corner. A compromised customer box can no longer read other tenants or touch platform releases. |
| F4 | **CAS passes through**: Get returns the backing etag in `ETag`; Put requires `If-Match` (or `If-None-Match: *` for create), maps conflict to 412; the client maps 412 back to `release.ErrCASConflict`. Deletes are idempotent 204. | The lease-adjacent writers (heartbeat CAS, binding takeover guard) keep their exact semantics through the proxy. |
| F5 | **Client = `internal/fleetapi.Store`**, implementing `release.Store` over the F1 wire with the box's existing `CARLOS_TUNNEL_URL`/`_NAME`/`_TOKEN` env. Selection: `openDeployStore` (and the status/metrics bucket resolution) returns the API store **when fleet identity resolves AND `CARLOS_DEPLOYMENT_BUCKET`/`CARLOS_STATUS_BUCKET` is unset**; a bucket still set wins (the issue's migration fallback — platform boxes and grandfathered fleet boxes keep direct S3, byte-for-byte unchanged). | Env-driven cutover, box by box, no flag day: drop the bucket var + AWS creds from a box's env and it is on the API; put them back and it is off. |
| F6 | **retentionLoop does not arm on a fleet box** (fleet identity present → skip with one log line). | A customer box scheduling deletes of platform-bucket releases was wrong under direct S3 too; the ACL would refuse it anyway — skipping avoids an hourly error loop. Platform boxes keep retention. |
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go
index 308e632fc..e9a8dd7ab 100644
--- a/internal/edge/storedoor.go
+++ b/internal/edge/storedoor.go
@@ -221,9 +221,11 @@ func (d *storeDoor) serveObject(w http.ResponseWriter, r *http.Request, sqid, fl
// serveListing is List (dirs=false) and ListDirs (dirs=true). The requested
// prefix is scope-MAPPED rather than merely checked (aclListScope): a caller
// asking for "apps/" gets its own account's subtree, which is what lets an
-// unmodified release.Store caller work through the door. Every returned key
-// is then re-checked against the read ACL, so the answer is in scope even if
-// a future mapping rule is ever wrong.
+// unmodified release.Store caller work through the door. Every returned entry
+// is then re-checked per tenant — aclRead for objects, aclDir for child
+// prefixes — so the answer is in scope even if a future mapping rule is ever
+// wrong, and so the one scope that IS shared (accounts/) can only ever answer
+// with this caller's own record.
func (d *storeDoor) serveListing(w http.ResponseWriter, r *http.Request, sqid, fleet string, dirs bool) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
@@ -247,9 +249,10 @@ func (d *storeDoor) serveListing(w http.ResponseWriter, r *http.Request, sqid, f
return
}
for _, dir := range got {
- // A child prefix is in scope exactly when it is under the scope
- // the ACL granted; aclRead judges objects, not directories.
- if strings.HasPrefix(dir, scope) {
+ // Judged per TENANT, never "is it under the prefix we listed":
+ // under the shared accounts/ scope that test is a tautology and
+ // would enumerate every account on the platform (aclDir).
+ if aclDir(sqid, fleet, dir) {
out = append(out, dir)
}
}
@@ -349,11 +352,16 @@ func canonicalPrefix(raw string) (string, bool) {
// aclRead is the read half of F3, prefix-string exact against the
// authenticated fleet's own identity:
//
-// control/serial the convergence poll
-// bootstrap/carlos the sanctioned self-update path (F7)
-// accounts/<sqid>.json its owning account's record
-// apps/<sqid>/** its owning account's whole app estate
-// fleets/<fleet>/** its own corner
+// control/serial the convergence poll
+// bootstrap/carlos the sanctioned self-update path (F7)
+// accounts/<sqid>/** its owning account's record — the manifest lives at
+// accounts/<sqid>/account.json (domains.AccountManifestKey),
+// NOT accounts/<sqid>.json: a box that cannot read it
+// sees an empty account set, and provision.Reconcile's
+// removal phase then retires every provisioned route on
+// the box (knownAccounts -> ParseRecordKey)
+// apps/<sqid>/** its owning account's whole app estate
+// fleets/<fleet>/** its own corner
//
// The last line is not in F3's enumeration and is not a widening of it: the
// fleet's own prefix is the only place it may WRITE, and every write on this
@@ -366,13 +374,36 @@ func aclRead(sqid, fleet, key string) bool {
return false
}
switch key {
- case "control/serial", "bootstrap/carlos", "accounts/" + sqid + ".json":
+ case "control/serial", "bootstrap/carlos":
return true
}
- return strings.HasPrefix(key, "apps/"+sqid+"/") ||
+ return strings.HasPrefix(key, "accounts/"+sqid+"/") ||
+ strings.HasPrefix(key, "apps/"+sqid+"/") ||
strings.HasPrefix(key, "fleets/"+fleet+"/")
}
+// aclDir is aclRead for a LISTING's child prefixes (ListDirs), which are
+// directories rather than objects and so cannot be judged by it: a returned
+// prefix is in scope exactly when it lies inside one of this tenant's own
+// subtrees, or IS its own account directory.
+//
+// It exists because "the prefix we listed under" is not a sufficient filter
+// for the one shared scope: accounts/ must be listable (it is how a box
+// discovers its own account record) but ListDirs("accounts/") returns
+// accounts/<sqid>/ for EVERY tenant on the platform, and a HasPrefix check
+// against the scope we just used is a tautology that would hand all of them
+// back.
+func aclDir(sqid, fleet, dir string) bool {
+ if sqid == "" || fleet == "" {
+ return false
+ }
+ if dir == "accounts/"+sqid+"/" {
+ return true
+ }
+ return strings.HasPrefix(dir, "apps/"+sqid+"/") ||
+ strings.HasPrefix(dir, "fleets/"+fleet+"/")
+}
+
// aclWrite is the write half of F3 — and DELETE's half too, which is why it
// takes no sqid: a box may touch its own fleet corner and nothing else. In
// particular it can never delete a release artifact, which is the tamper
@@ -396,9 +427,13 @@ func aclWrite(fleet, key string) bool {
// refused rather than widened, since S3 prefix matching would otherwise reach
// a sibling tenant.
//
-// accounts/ is the one scope that maps to a SHARED prefix: the caller may see
-// only its own record there, which serveListing enforces by re-checking every
-// returned key against aclRead.
+// accounts/ is the one scope that maps to a SHARED prefix — it has to be
+// listable, since that is how a box finds its own account manifest
+// (provision.knownAccounts lists accounts/ and parses
+// accounts/<sqid>/account.json). The caller may see only its own record
+// there, which serveListing enforces by re-checking every returned entry
+// (aclRead for objects, aclDir for the child prefixes ListDirs returns —
+// there is one per account on the platform).
func aclListScope(sqid, fleet, prefix string) (string, bool) {
if sqid == "" || fleet == "" || prefix == "" {
return "", false
@@ -406,7 +441,7 @@ func aclListScope(sqid, fleet, prefix string) (string, bool) {
ownApps := "apps/" + sqid + "/"
ownFleet := "fleets/" + fleet + "/"
switch {
- case prefix == "apps" || prefix == "apps/" || prefix == ownApps[:len(ownApps)-1]:
+ case prefix == "apps" || prefix == "apps/" || prefix == "apps/"+sqid:
return ownApps, true
case strings.HasPrefix(prefix, ownApps):
return prefix, true
@@ -414,7 +449,7 @@ func aclListScope(sqid, fleet, prefix string) (string, bool) {
return "", false
case prefix == "accounts" || prefix == "accounts/":
return "accounts/", true
- case prefix == "fleets" || prefix == "fleets/" || prefix == ownFleet[:len(ownFleet)-1]:
+ case prefix == "fleets" || prefix == "fleets/" || prefix == "fleets/"+fleet:
return ownFleet, true
case strings.HasPrefix(prefix, ownFleet):
return prefix, true
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go
index b13c238d3..65419d7c0 100644
--- a/internal/edge/storedoor_test.go
+++ b/internal/edge/storedoor_test.go
@@ -84,9 +84,16 @@ func TestStoreDoorACLMatrix(t *testing.T) {
{"bootstrap/carlos.sig", opRead, false, "exact key only"},
// --- own account estate ---
- {"accounts/ab12.json", opRead, true, "own account record"},
- {"accounts/zz99.json", opRead, false, "another tenant's account record"},
- {"accounts/ab12.json", opWrite, false, "reads only, even of its own account"},
+ // The account manifest is accounts/<sqid>/account.json
+ // (domains.AccountManifestKey), NOT accounts/<sqid>.json: a box that
+ // cannot read it sees zero accounts, and provision.Reconcile then
+ // retires every provisioned route on the box.
+ {"accounts/ab12/account.json", opRead, true, "own account manifest"},
+ {"accounts/zz99/account.json", opRead, false, "another tenant's account manifest"},
+ {"accounts/ab12/account.json", opWrite, false, "reads only, even of its own account"},
+ {"accounts/ab12.json", opRead, false, "the manifest is a directory deeper; this key is not the account"},
+ {"accounts/ab12x/account.json", opRead, false, "sqid prefix must not match a longer sqid"},
+ {"accounts/ab12", opRead, false, "the account prefix itself is not an object"},
{"apps/ab12/web/channels/stable.json", opRead, true, "own app channel pointer"},
{"apps/ab12/web/releases/v1.2.3/manifest.json", opRead, true, "own release manifest"},
{"apps/ab12/web/releases/v1.2.3/app.tar.gz", opRead, true, "own artifact (F8: streamed, not presigned)"},
@@ -205,6 +212,31 @@ func TestACLListScope(t *testing.T) {
}
}
+// TestACLDirIsPerTenant pins the ListDirs predicate on its own. A child
+// prefix cannot be judged by aclRead (it is a directory, not an object), and
+// judging it by "is it under the prefix we listed" would enumerate every
+// account on the platform the moment accounts/ is listed.
+func TestACLDirIsPerTenant(t *testing.T) {
+ cases := map[string]bool{
+ "accounts/" + testSqid + "/": true,
+ "accounts/zz99/": false,
+ "accounts/" + testSqid + "x/": false,
+ "accounts/": false,
+ "apps/" + testSqid + "/web/": true,
+ "apps/zz99/web/": false,
+ "apps/" + testSqid + "x/web/": false,
+ "fleets/" + testFleet + "/status/": true,
+ "fleets/other/status/": false,
+ "fleets/" + testFleet + "x/y/": false,
+ "control/": false,
+ }
+ for dir, want := range cases {
+ if got := aclDir(testSqid, testFleet, dir); got != want {
+ t.Errorf("aclDir(%q) = %v, want %v", dir, got, want)
+ }
+ }
+}
+
// ---- handler tests ------------------------------------------------------
// The credentials every handler test runs with: a fleet channel
@@ -420,8 +452,8 @@ func TestStoreDoorListScopeStripsOtherTenants(t *testing.T) {
seedObject(t, store, ownKey, "{}")
seedObject(t, store, "apps/"+testSqid+"/web/releases/v1/manifest.json", "{}")
seedObject(t, store, otherAccountKey, "{}")
- seedObject(t, store, "accounts/"+testSqid+".json", "{}")
- seedObject(t, store, "accounts/zz99.json", "{}")
+ seedObject(t, store, "accounts/"+testSqid+"/account.json", "{}")
+ seedObject(t, store, "accounts/zz99/account.json", "{}")
seedObject(t, store, ownFleetKey, "{}")
seedObject(t, store, "fleets/other/status/boxes/box-a.json", "{}")
@@ -442,11 +474,17 @@ func TestStoreDoorListScopeStripsOtherTenants(t *testing.T) {
want []string
}{
{"/store/list?prefix=apps/", []string{ownKey, "apps/" + testSqid + "/web/releases/v1/manifest.json"}},
- {"/store/list?prefix=accounts/", []string{"accounts/" + testSqid + ".json"}},
+ {"/store/list?prefix=accounts/", []string{"accounts/" + testSqid + "/account.json"}},
{"/store/list?prefix=fleets/", []string{ownFleetKey}},
{"/store/list?prefix=apps/" + testSqid + "/web/releases/", []string{"apps/" + testSqid + "/web/releases/v1/manifest.json"}},
{"/store/dirs?prefix=apps/", []string{"apps/" + testSqid + "/web/"}},
{"/store/dirs?prefix=fleets/", []string{"fleets/" + testFleet + "/status/"}},
+ // The regression that matters most on the dirs route: accounts/ is a
+ // SHARED prefix, and ListDirs returns one child per account on the
+ // platform. Filtering by "is it under the prefix we listed" is a
+ // tautology there — the answer must be this tenant's own directory
+ // and nothing else, with zz99 seeded above to prove it.
+ {"/store/dirs?prefix=accounts/", []string{"accounts/" + testSqid + "/"}},
} {
got := list(c.target)
if !slices.Equal(got, c.want) {