carlos / platform Public

store door: the app write grant is a family allow-list (logs, errors, analytics)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev ae19d846cac0b0027ea69c66c0c707d1a05d9563 parent 9aa34f1
2 files changed, +37 −19
  • internal/edge/storedoor.go +25 −19
  • internal/edge/storedoor_test.go +12 −0
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go
index b2a623761..115d39ed8 100644
--- a/internal/edge/storedoor.go
+++ b/internal/edge/storedoor.go
@@ -459,7 +459,7 @@ func aclDir(sqid, fleet, dir string) bool {
// only two:
//
// fleets/<fleet>/** its own corner
-// apps/<sqid>/<app>/logs/** its own account's app logs (applog.Prefix)
+// apps/<sqid>/<app>/<family>/** its own account's app logs, errors, analytics (applog.Prefix)
//
// 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:
@@ -469,15 +469,15 @@ func aclDir(sqid, fleet, dir string) bool {
// 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
-// apps/<sqid>/** and never a "logs/ appears somewhere in the key" test. A
-// blanket own-account write would let a box pin its own channels/*.json (self
-// -promoting any build it likes) or mint instances/*.json records
-// (self-authorizing routes) — precisely the writes this door exists to
-// prevent. Log chunks are the one thing a box legitimately produces about its
-// own tenant's apps (applog.ChunkKey), and they are write-once objects under a
-// per-box subtree.
+// The family scope is SEGMENT-EXACT on purpose, and the exactness is the
+// security property: the grant is apps/<sqid>/<ONE app segment>/<ONE family>/**, where
+// family is one of [logs, errors, analytics], never apps/<sqid>/** and never
+// a "family appears somewhere in the key" test. A blanket own-account write
+// would let a box pin its own channels/*.json (self-promoting any build it
+// likes) or mint instances/*.json records (self-authorizing routes) —
+// precisely the writes this door exists to prevent. Chunks and day files are
+// the one thing a box legitimately produces about its own tenant's apps
+// (applog.ChunkKey), and they are write-once objects under a per-box subtree.
//
// HONEST LIMIT: the <box> segment inside that subtree is an unverifiable
// pseudonym (status.Label of a machine id the box chose), so one box of an
@@ -493,15 +493,21 @@ func aclWrite(sqid, fleet, key string) bool {
if strings.HasPrefix(key, "fleets/"+fleet+"/") {
return true
}
- return aclWriteAppLogs(sqid, key)
+ return aclWriteAppFamily(sqid, key)
}
-// aclWriteAppLogs implements aclWrite's second scope: exactly
-// apps/<sqid>/<app>/logs/<something>, with <app> a single non-empty segment
-// and at least one key component below logs/. Anything else — a shorter key,
-// a second path shape, "logs" as the app segment — is refused, so the grant
-// can never be widened by a key that merely contains the word.
-func aclWriteAppLogs(sqid, key string) bool {
+// chunkFamilies is the allow-list of app subtrees a box may write: the
+// chunk and day-file families it produces about its own tenant's apps
+// (applog.Family). Exact segment names — "logs" is not "logsx".
+var chunkFamilies = map[string]bool{"logs": true, "errors": true, "analytics": true}
+
+// aclWriteAppFamily implements aclWrite's second scope: exactly
+// apps/<sqid>/<app>/<family>/<something>, with <app> a single non-empty
+// segment, <family> one of chunkFamilies, and at least one key component
+// below the family. Anything else — a shorter key, a second path shape,
+// a family name as the app segment — is refused, so the grant can never be
+// widened by a key that merely contains the word.
+func aclWriteAppFamily(sqid, key string) bool {
if sqid == "" {
return false
}
@@ -513,8 +519,8 @@ func aclWriteAppLogs(sqid, key string) bool {
if !ok || app == "" {
return false
}
- below, ok := strings.CutPrefix(tail, "logs/")
- return ok && below != ""
+ fam, below, ok := strings.Cut(tail, "/")
+ return ok && chunkFamilies[fam] && below != ""
}
// aclListScope maps a requested listing prefix onto the prefix the door will
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go
index 979c11fa6..a056e5fc1 100644
--- a/internal/edge/storedoor_test.go
+++ b/internal/edge/storedoor_test.go
@@ -137,6 +137,18 @@ func TestStoreDoorACLMatrix(t *testing.T) {
{"apps/ab12/web/logs/../../zz99/web/logs/x.jsonl.gz", opWrite, false, "traversal out of the logs subtree"},
{"apps/ab12/web/logs/../channels/stable.json", opWrite, false, "traversal out of logs into channels"},
+ // --- sibling chunk families (errors-and-analytics spec §3.2) ---
+ {"apps/ab12/web/errors/box-a/2026090710/0000000000000001-42.jsonl.gz", opWrite, true, "own app's error chunk"},
+ {"apps/ab12/web/errors/box-a/2026090710/0000000000000001-42.jsonl.gz", opDelete, true, "errship prunes its own subtree hourly"},
+ {"apps/ab12/web/analytics/box-a/days/2026-09-07.json", opWrite, true, "own app's analytics day file"},
+ {"apps/zz99/web/errors/box-a/x.jsonl.gz", opWrite, false, "another account's error chunks"},
+ {"apps/ab12/errors/box-a/x.jsonl.gz", opWrite, false, "errors must sit under an app segment"},
+ {"apps/ab12/web/errors", opWrite, false, "the bare family segment is not a chunk"},
+ {"apps/ab12/web/errors/", opWrite, false, "nor with a slash"},
+ {"apps/ab12/web/other/box-a/x.jsonl.gz", opWrite, false, "the family list is exact: logs, errors, analytics"},
+ {"apps/ab12/web/channels/errors/stable.json", opWrite, false, "\"errors\" deeper in the key grants nothing"},
+ {"apps/ab12/web/errorsx/box-a/x.jsonl.gz", opWrite, false, "the segment is errors/, not a prefix of a longer name"},
+
// --- other tenants ---
{"apps/zz99/web/channels/stable.json", opRead, false, "another account's app"},
{"apps/ab12x/web/channels/stable.json", opRead, false, "sqid prefix must not match a longer sqid"},