carlos / platform Public

feat: store door reads domains/ claims (#151)

Task 7: fleet boxes reach the deployment bucket only through the
store door, which is default-deny per tenant. ReconcileAliases (the
alias pass every box runs) lists and reads domains/<host>.json claim
records; without an ACL case boxes silently saw nothing there.

Extends aclRead, aclDir, and aclListScope with a domains/ case, all
three cross-tenant (unscoped by sqid/fleet): a claim is a
hostname->account/app/route binding, the same information DNS and
the serving edge already make public. Write stays console-only —
aclWrite is untouched and keeps answering false for domains/ keys.

TDD: extended the existing acl table tests (aclRead/aclWrite matrix,
aclListScope, aclDir) with the domains/ rows first (RED), then
implemented (GREEN). go test ./... -count=1 clean across all 24
packages; GOOS=darwin build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell fb545d337d825c7c3689527bcdf5d17ef8f626b3 parent 08b2b66
2 files changed, +63 −7
  • internal/edge/storedoor.go +43 −7
  • internal/edge/storedoor_test.go +20 −0
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go
index f96443f79..ad3a0d460 100644
--- a/internal/edge/storedoor.go
+++ b/internal/edge/storedoor.go
@@ -362,13 +362,24 @@ func canonicalPrefix(raw string) (string, bool) {
// the box (knownAccounts -> ParseRecordKey)
// apps/<sqid>/** its owning account's whole app estate
// fleets/<fleet>/** its own corner
+// domains/** every tenant's hostname claims (see below)
//
-// 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
-// wire is a CAS (read the etag, then Put with If-Match). A write scope you
-// cannot read is a write scope you cannot use — the binding takeover guard
-// and the heartbeat both read their own object first. It grants access to
-// nothing beyond what aclWrite already grants.
+// The fleets/<fleet>/** 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 wire is a CAS (read the etag, then Put with If-Match).
+// A write scope you cannot read is a write scope you cannot use — the
+// binding takeover guard and the heartbeat both read their own object first.
+// It grants access to nothing beyond what aclWrite already grants.
+//
+// domains/ is different in kind, not degree (#151): a claim record
+// (domains.Claim) is a hostname→account/app/route binding, and that binding
+// is cross-tenant-readable BY DESIGN — it is exactly the information DNS and
+// the serving edge already make public to anyone who resolves the hostname,
+// so scoping it to sqid or fleet would hide nothing and would only break
+// ReconcileAliases, the alias pass every box runs to reconcile its own
+// routes' extra hostnames against the global claim set. Write stays
+// console-only: aclWrite never answers true for a domains/ key, so this read
+// grant cannot be used to mint or steal a claim.
func aclRead(sqid, fleet, key string) bool {
if sqid == "" || fleet == "" {
return false
@@ -377,6 +388,9 @@ func aclRead(sqid, fleet, key string) bool {
case "control/serial", "bootstrap/carlos":
return true
}
+ if strings.HasPrefix(key, "domains/") {
+ return true
+ }
return strings.HasPrefix(key, "accounts/"+sqid+"/") ||
strings.HasPrefix(key, "apps/"+sqid+"/") ||
strings.HasPrefix(key, "fleets/"+fleet+"/")
@@ -400,6 +414,15 @@ func aclDir(sqid, fleet, dir string) bool {
if dir == "accounts/"+sqid+"/" {
return true
}
+ // domains/ (#151): cross-tenant-readable by the same reasoning as
+ // aclRead, so every child prefix under it is in scope regardless of
+ // tenant. ListClaims lists domains/ flat today (List, not ListDirs), so
+ // this branch is not exercised in production yet — it exists so a future
+ // nested claim layout does not silently lose read access the way
+ // accounts/ almost did.
+ if strings.HasPrefix(dir, "domains/") {
+ return true
+ }
return strings.HasPrefix(dir, "apps/"+sqid+"/") ||
strings.HasPrefix(dir, "fleets/"+fleet+"/")
}
@@ -412,7 +435,11 @@ func aclDir(sqid, fleet, dir string) bool {
//
// It can never write a release artifact, a channel pointer, or an instance
// record, which is the tamper surface the whole spec exists to close (F6:
-// retention does not arm on a fleet box either).
+// retention does not arm on a fleet box either). It can never write a
+// domains/ claim either (#151): aclRead's cross-tenant grant there is
+// read-only by design — a claim is a console-mediated hostname reservation,
+// and a box that could write one could mint or steal any hostname on the
+// platform, not just its own account's.
//
// The logs scope is SEGMENT-EXACT on purpose, and the exactness is the
// security property: the grant is apps/<sqid>/<ONE app segment>/logs/**, never
@@ -495,6 +522,15 @@ func aclListScope(sqid, fleet, prefix string) (string, bool) {
return "", false
case prefix == "accounts" || prefix == "accounts/":
return "accounts/", true
+ case prefix == "domains" || prefix == "domains/":
+ // #151: the OTHER shared scope, and shared for the same reason as
+ // accounts/ above but simpler — every claim is cross-tenant-readable
+ // (aclRead), so unlike apps/ and fleets/ there is no per-tenant
+ // subtree to narrow into. ReconcileAliases always lists the whole
+ // prefix; a narrower ask (e.g. "domains/x.example.json") is refused
+ // rather than widened, matching accounts/'s deliberate refusal of
+ // "accounts/<sqid>/" — see that case's comment.
+ return "domains/", true
case prefix == "fleets" || prefix == "fleets/" || prefix == "fleets/"+fleet:
return ownFleet, true
case strings.HasPrefix(prefix, ownFleet):
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go
index a2eb8011d..3a0df9c7a 100644
--- a/internal/edge/storedoor_test.go
+++ b/internal/edge/storedoor_test.go
@@ -142,6 +142,14 @@ func TestStoreDoorACLMatrix(t *testing.T) {
{"fleets/pix/status/boxes/box-a.json", opWrite, false, "fleet prefix must not match a longer fleet name"},
{"fleets/pi", opWrite, false, "the fleet prefix itself is not an object"},
+ // --- domains/ claims (#151): cross-tenant-readable by design, the
+ // alias pass (ReconcileAliases) reads them on every box; console-only
+ // write ---
+ {"domains/app.example.com.json", opRead, true, "hostname claim, own or any other tenant's"},
+ {"domains/app.example.com.json", opWrite, false, "boxes read claims, only the console writes them"},
+ {"domains/app.example.com.json", opDelete, false, "nor delete them"},
+ {"domains", opRead, false, "an object AT the bare name, not the claims prefix"},
+
// --- shapes canonicalKey must kill before the ACL is consulted ---
{"apps/ab12/../zz99/web/channels/stable.json", opRead, false, "traversal out of own account"},
{"fleets/pi/../other/status/boxes/box-a.json", opWrite, false, "traversal out of own fleet"},
@@ -231,6 +239,12 @@ func TestACLListScope(t *testing.T) {
// this pin makes visible.
{"accounts/" + testSqid + "/", "", false},
{"accounts/" + testSqid, "", false},
+ // domains/ (#151): the OTHER shared scope — every claim is
+ // cross-tenant-readable, so (unlike apps/ and fleets/) there is no
+ // per-tenant subtree to narrow into; only the bare prefix maps.
+ {"domains/", "domains/", true},
+ {"domains", "domains/", true},
+ {"domains/app.example.com.json", "", false},
{"fleets/", "fleets/pi/", true},
{"fleets/pi/status/boxes/", "fleets/pi/status/boxes/", true},
{"fleets/other/", "", false},
@@ -264,6 +278,12 @@ func TestACLDirIsPerTenant(t *testing.T) {
"fleets/other/status/": false,
"fleets/" + testFleet + "x/y/": false,
"control/": false,
+ // domains/ (#151): cross-tenant by design, so no tenant scoping to
+ // pin here — every child prefix under it is in scope. ListClaims
+ // lists domains/ flat (List, not ListDirs) today, so this path isn't
+ // exercised in production; the row exists so a future nested claim
+ // layout doesn't silently lose read access.
+ "domains/anything/": true,
}
for dir, want := range cases {
if got := aclDir(testSqid, testFleet, dir); got != want {