edge: store door grants a read of the fleet's layout intent
aclRead had no allowance for control/layout.json (release.LayoutIntentKey), so a box on the fleet door read a denied 404 for it forever and never converged its storage layout. Grant a read of exactly that key to every fleet box, the same way control/serial is granted: it is read-only, and the object carries only a mode and a box label, never tenant data. aclWrite still refuses it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2 files changed,
+34
−1
internal/edge/storedoor.go+9 −1internal/edge/storedoor_test.go+25 −0
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go| index 115d39ed8..ae4e1e9cb 100644 |
| --- a/internal/edge/storedoor.go |
| +++ b/internal/edge/storedoor.go |
| @@ -356,6 +356,8 @@ func canonicalPrefix(raw string) (string, bool) { |
| // bootstrap/carlos the sanctioned self-update path (F7); |
| // stays until update-platform-binary.sh |
| // is retired (spec §10) |
| +// control/layout.json the fleet's layout intent (isolated-exec |
| +// design §8.2) — see release.LayoutIntentKey() |
| // control/system/<c>/<ch>.json its platform channel pointer (system |
| // update spec §3) — see systemReadKey |
| // bootstrap/router/<v>/<asset> a mirrored platform artifact — see |
| @@ -377,6 +379,12 @@ func canonicalPrefix(raw string) (string, bool) { |
| // binding takeover guard and the heartbeat both read their own object first. |
| // It grants access to nothing beyond what aclWrite already grants. |
| // |
| +// control/layout.json is not in F3's enumeration either, and is granted for |
| +// the same reason control/serial is: every box needs the fleet's layout |
| +// intent to converge (isolated-exec design §8.2), and the object holds |
| +// nothing more than a mode and a box label, never tenant data. Write stays |
| +// console-only: aclWrite never answers true for it. |
| +// |
| // 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 |
| @@ -391,7 +399,7 @@ func aclRead(sqid, fleet, key string) bool { |
| return false |
| } |
| switch key { |
| - case "control/serial", "bootstrap/carlos": |
| + case "control/serial", "bootstrap/carlos", release.LayoutIntentKey(): |
| return true |
| } |
| if systemReadKey(key) { |
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go| index 8210ea4ef..fab601a69 100644 |
| --- a/internal/edge/storedoor_test.go |
| +++ b/internal/edge/storedoor_test.go |
| @@ -118,6 +118,10 @@ func TestStoreDoorACLMatrix(t *testing.T) { |
| {"bootstrap/router.sig", opRead, false, "nothing else under bootstrap/"}, |
| {"bootstrap/router/../../x", opRead, false, "traversal is refused by canonicalKey before the ACL"}, |
| {"control/system/../serial", opRead, false, "…on both prefixes"}, |
| + {release.LayoutIntentKey(), opRead, true, "every box needs the fleet's layout intent to converge"}, |
| + {release.LayoutIntentKey(), opWrite, false, "a box must never author the fleet's layout intent"}, |
| + {release.LayoutIntentKey(), opDelete, false, "nor delete it"}, |
| + {"control/layout.json2", opRead, false, "the allowlist is exact, not a prefix"}, |
| // --- own account estate --- |
| // The account manifest is accounts/<sqid>/account.json |
| @@ -228,6 +232,27 @@ func TestStoreDoorACLMatrix(t *testing.T) { |
| } |
| } |
| +// TestAclReadLayoutIntentNeedsBothIdentities pins the same empty-sqid, |
| +// empty-fleet guard aclRead already applies to every other key: an |
| +// authenticated identity is required before the allowlist is even |
| +// consulted, so a request that never resolved a fleet row cannot read the |
| +// layout intent either. |
| +func TestAclReadLayoutIntentNeedsBothIdentities(t *testing.T) { |
| + key := release.LayoutIntentKey() |
| + if !aclRead(testSqid, testFleet, key) { |
| + t.Errorf("aclRead(%q, %q, %q) = false, want true", testSqid, testFleet, key) |
| + } |
| + if aclRead("", testFleet, key) { |
| + t.Errorf("aclRead(\"\", %q, %q) = true, want false", testFleet, key) |
| + } |
| + if aclRead(testSqid, "", key) { |
| + t.Errorf("aclRead(%q, \"\", %q) = true, want false", testSqid, key) |
| + } |
| + if aclWrite(testSqid, testFleet, key) { |
| + t.Errorf("aclWrite(%q, %q, %q) = true, want false", testSqid, testFleet, key) |
| + } |
| +} |
| + |
| // TestCanonicalKeyRefusesShapes pins the pre-ACL gate on its own: the ACL is |
| // prefix-string exact, which is only safe because nothing that could re-point |
| // a prefix ever reaches it. |