| 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): |