storedoor, fleetapi: own-account read and ETag listing of the app policy; refusal is not absence (#328)
The store door grants a fleet box read of exactly control/app-policy/<own sqid>/<valid app>.json (appPolicyRead), never write or delete, and marks a 404 for such a key the store does not have with Carlos-Store-Absent: 1. GET /store/etags lists the box's own account's policies with their ETags (spec §3a: one LIST per backstop pass, Astra plan R3-4), policy-only and read-only. Plain listing of control/ stays refused. fleetapi maps a policy-key 404 without the mark to release.ErrRefused, not os.ErrNotExist, so an older edge or a refusal never reads as "no policy", and implements release.ETagLister over /store/etags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+551
−11
internal/edge/storedoor.go+134 −3internal/edge/storedoor_test.go+176 −0internal/fleetapi/fleetapi.go+67 −7internal/fleetapi/fleetapi_test.go+174 −1
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go| index 2d014501e..81d4bfd86 100644 |
| --- a/internal/edge/storedoor.go |
| +++ b/internal/edge/storedoor.go |
| @@ -15,7 +15,9 @@ |
| // token, static (non-fleet) name, another tenant's key, a key that does not |
| // exist, a key shaped like a traversal. The keyspace must never be an |
| // oracle, so no response distinguishes them, and no refusal ever costs more |
| -// than one constant-time verify. |
| +// than one constant-time verify. The one exception is an absent app policy |
| +// the caller may read, whose 404 says so (aclRead, storeAbsentHeader): that |
| +// tells the caller nothing about a key it is not entitled to. |
| package edge |
| import ( |
| @@ -41,6 +43,13 @@ const ( |
| storeObjectPrefix = "/store/o/" |
| storeListPath = "/store/list" |
| storeDirsPath = "/store/dirs" |
| + // storeETagsPath is the policy ETag listing (serveETagListing). |
| + storeETagsPath = "/store/etags" |
| + |
| + // storeAbsentHeader marks the one 404 that means "absent" rather than |
| + // "refused": a GET of a key appPolicyRead grants that the store does not |
| + // have. internal/fleetapi reads it under the same name. |
| + storeAbsentHeader = "Carlos-Store-Absent" |
| // maxStoreKeyBytes is the pre-ACL key ceiling (spec: keys >1024 bytes are |
| // refused before the ACL runs). S3's own limit. |
| @@ -131,6 +140,8 @@ func (d *storeDoor) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| d.serveListing(w, r, sqid, fleet, false) |
| case r.URL.Path == storeDirsPath: |
| d.serveListing(w, r, sqid, fleet, true) |
| + case r.URL.Path == storeETagsPath: |
| + d.serveETagListing(w, r, sqid, fleet) |
| default: |
| http.NotFound(w, r) |
| } |
| @@ -159,7 +170,13 @@ func (d *storeDoor) serveObject(w http.ResponseWriter, r *http.Request, sqid, fl |
| body, etag, err := d.store.Get(ctx, key) |
| if err != nil { |
| if errors.Is(err, os.ErrNotExist) { |
| - http.NotFound(w, r) // byte-identical to every refusal above |
| + // Byte-identical to every refusal above, except for a policy |
| + // key the box may read: there the store's "absent" is marked, |
| + // so the box can tell "no policy" from a refusal (aclRead). |
| + if appPolicyRead(sqid, key) { |
| + w.Header().Set(storeAbsentHeader, "1") |
| + } |
| + http.NotFound(w, r) |
| return |
| } |
| d.storeFailed(w, r, "get", key, err) |
| @@ -283,6 +300,71 @@ func (d *storeDoor) serveListing(w http.ResponseWriter, r *http.Request, sqid, f |
| _ = json.NewEncoder(w).Encode(out) |
| } |
| +// keyETag is one /store/etags entry on the wire. internal/fleetapi decodes |
| +// the same two fields. |
| +type keyETag struct { |
| + Key string `json:"key"` |
| + ETag string `json:"etag"` |
| +} |
| + |
| +// serveETagListing is GET /store/etags?prefix=<p>: the box's own account's |
| +// operator policies with their ETags, sorted by key, [] when there are none. |
| +// |
| +// It exists for the request budget (instance sizes and egress spec §3a, |
| +// Astra plan R3-4): a fleet box's policy backstop is one LIST, and it GETs |
| +// only the policies whose ETag changed, so with nothing written a box costs |
| +// one listing per pass and no GETs. A plain /store/list cannot serve that: |
| +// it carries no ETags, and it refuses every control/ prefix. |
| +// |
| +// It is the one listing under control/, so it is narrow on purpose. It is |
| +// policy-only: aclETagListScope maps exactly the policy root or the box's |
| +// own account's policy directory, and every returned entry is re-checked per |
| +// key with appPolicyRead, as serveListing re-checks with aclRead, so the |
| +// answer is in scope even if the mapping were ever wrong. It is read-only: |
| +// anything but GET is the bare 404, and nothing here writes. ETags pass |
| +// through exactly as the backing store produced them, as Get's do, so a |
| +// listed ETag compares equal to one a GET returned. |
| +// |
| +// A backing store that cannot list ETags gets the bare 404, the answer an |
| +// older door gives, and a store failure is storeFailed's 502, never an empty |
| +// listing a box would read as "no policies". The listing is account-scoped, |
| +// so fleet plays no part in it. |
| +func (d *storeDoor) serveETagListing(w http.ResponseWriter, r *http.Request, sqid, fleet string) { |
| + if r.Method != http.MethodGet { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + prefix, ok := canonicalPrefix(r.URL.Query().Get("prefix")) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + scope, ok := aclETagListScope(sqid, prefix) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + lister, ok := d.store.(release.ETagLister) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + got, err := lister.ListETags(r.Context(), scope) |
| + if err != nil { |
| + d.storeFailed(w, r, "listetags", scope, err) |
| + return |
| + } |
| + out := []keyETag{} // never null on the wire: an empty listing is [] |
| + for _, ke := range got { |
| + if appPolicyRead(sqid, ke.Key) { |
| + out = append(out, keyETag{Key: ke.Key, ETag: ke.ETag}) |
| + } |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) |
| + w.Header().Set("Content-Type", "application/json") |
| + _ = json.NewEncoder(w).Encode(out) |
| +} |
| + |
| // storeFailed answers a genuine backing-store failure. It is deliberately NOT |
| // folded into the 404 shape: a bucket outage that looked like "the object is |
| // gone" would make a box act on absence (a missing channel pointer, an empty |
| @@ -384,6 +466,9 @@ func canonicalPrefix(raw string) (string, bool) { |
| // releases/<sqid>/<app>/promoted/<version> |
| // a promotion record of its owning account's |
| // build — see promotionRecordRead |
| +// control/app-policy/<sqid>/<app>.json |
| +// its owning account's operator policy for one |
| +// app — see appPolicyRead |
| // |
| // domains/ is NOT here, and is served by its own two paths instead |
| // (serveClaim, serveClaimListing): a box can list every claimed hostname, |
| @@ -429,6 +514,19 @@ func canonicalPrefix(raw string) (string, bool) { |
| // marker, which a box has no use for. Listing is not granted (aclDir and |
| // aclListScope are unchanged), and write stays console-only: aclWrite never |
| // answers true for a releases/ key. |
| +// |
| +// An app's operator policy (instance sizes and egress spec §4.3, |
| +// release.AppPolicyKey) is granted because the box converges from it: the |
| +// private addresses the app's instances may reach and the sizes they run at. |
| +// It is signed by the console and holds nothing about any other account. The |
| +// grant is one object shape, the box's own account only (appPolicyRead), |
| +// never the control/app-policy/ prefix. Plain listing is not granted (aclDir |
| +// and aclListScope are unchanged); the one listing is serveETagListing's, |
| +// policy-only. Write stays console-only: aclWrite never answers true for a |
| +// policy key. A GET of a granted policy key that the store does not have is |
| +// the one 404 carrying storeAbsentHeader: a box that read a refusal as |
| +// absence would drop the app's policy, so it must be able to tell the two |
| +// apart, and it may already read the key, so the mark is no oracle. |
| func aclRead(sqid, fleet, key string) bool { |
| if sqid == "" || fleet == "" { |
| return false |
| @@ -437,7 +535,7 @@ func aclRead(sqid, fleet, key string) bool { |
| case "control/serial", "bootstrap/carlos", release.LayoutIntentKey(), plans.MarkerKey(): |
| return true |
| } |
| - if systemReadKey(key) || promotionRecordRead(sqid, key) { |
| + if systemReadKey(key) || promotionRecordRead(sqid, key) || appPolicyRead(sqid, key) { |
| return true |
| } |
| return strings.HasPrefix(key, "accounts/"+sqid+"/") || |
| @@ -455,6 +553,22 @@ func promotionRecordRead(sqid, key string) bool { |
| release.ValidApp(seg[2]) && seg[3] == "promoted" && release.ValidVersion(seg[4]) |
| } |
| +// appPolicyRead is the one control/app-policy/ shape aclRead grants: |
| +// exactly release.AppPolicyKey(sqid, app), three segments under the prefix, |
| +// the box's own account, and a valid app name. canonicalKey has already |
| +// refused any "." or ".." segment. |
| +func appPolicyRead(sqid, key string) bool { |
| + if sqid == "" { |
| + return false |
| + } |
| + file, ok := strings.CutPrefix(key, release.AppPolicyPrefix+sqid+"/") |
| + if !ok { |
| + return false |
| + } |
| + app, ok := strings.CutSuffix(file, ".json") |
| + return ok && release.ValidApp(app) && key == release.AppPolicyKey(sqid, app) |
| +} |
| + |
| // systemReadKey is the two shapes the system-update spec (§3) grants every |
| // fleet box: its platform channel pointer and a mirrored platform artifact. |
| // Shapes, not prefixes — exactly three segments each, so nothing else that |
| @@ -615,6 +729,23 @@ func aclListScope(sqid, fleet, prefix string) (string, bool) { |
| } |
| } |
| +// aclETagListScope maps a requested ETag-listing prefix onto the box's own |
| +// account's policy directory, or refuses. It accepts exactly two prefixes: |
| +// the policy root (what apppolicy.ListETags asks for, unmodified, the way |
| +// "apps/" maps for List) and the box's own policy directory. Every other |
| +// prefix is refused rather than narrowed: another account's directory, a |
| +// sqid that only prefix-matches, control/, apps/, or empty. |
| +func aclETagListScope(sqid, prefix string) (string, bool) { |
| + if sqid == "" { |
| + return "", false |
| + } |
| + own := release.AppPolicyPrefix + sqid + "/" |
| + if prefix == release.AppPolicyPrefix || prefix == own { |
| + return own, true |
| + } |
| + return "", false |
| +} |
| + |
| // serveClaim answers a GET of one hostname claim. A box gets a claim only |
| // when it is its own account's and verified — the only claims its |
| // ReconcileAliases could ever act on — and never its created_by, which is a |
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go| index 9b7ae7e08..57cad7bb0 100644 |
| --- a/internal/edge/storedoor_test.go |
| +++ b/internal/edge/storedoor_test.go |
| @@ -197,6 +197,22 @@ func TestStoreDoorACLMatrix(t *testing.T) { |
| {"releases/ab12/web/promoted/v1.2.3", opDelete, false, "nor deletes one"}, |
| {"releases/ab12/web/promoted/../../zz99/web/promoted/v1", opRead, false, "traversal out of own account"}, |
| + // --- own account's operator policy (instance sizes and egress spec |
| + // §4.3): exactly control/app-policy/<own sqid>/<valid app>.json, read |
| + // only. Console-only write: aclWrite never answers true for it --- |
| + {"control/app-policy/" + testSqid + "/web.json", opRead, true, "the box's own app's operator policy (spec §4.3)"}, |
| + {"control/app-policy/" + testSqid + "/web.json", opWrite, false, "operator-only: a box never writes it"}, |
| + {"control/app-policy/" + testSqid + "/web.json", opDelete, false, "nor deletes it"}, |
| + {"control/app-policy/zzz/web.json", opRead, false, "another account's policy"}, |
| + {"control/app-policy/zzz/web.json", opWrite, false, "nor writes another account's"}, |
| + {"control/app-policy/" + testSqid + "/web.json/x", opRead, false, "exact shape only"}, |
| + {"control/app-policy/" + testSqid + "/Web!.json", opRead, false, "a valid app name only"}, |
| + {"control/app-policy/" + testSqid + "/web.txt", opRead, false, "a .json object only"}, |
| + {"control/app-policy/" + testSqid + "/.json", opRead, false, "an empty app name"}, |
| + {"control/app-policy/" + testSqid, opRead, false, "the account directory is not an object"}, |
| + {"control/app-policy/" + testSqid + "x/web.json", opRead, false, "sqid must not prefix-match"}, |
| + {"control/app-policy/" + testSqid + "/../zzz/web.json", opRead, false, "traversal out of own account"}, |
| + |
| // --- 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"}, |
| @@ -1023,3 +1039,163 @@ func TestFleetBoxCanReadThePlansMarker(t *testing.T) { |
| t.Error("the plans marker was readable without both identities") |
| } |
| } |
| + |
| +// doorShape is everything a caller can observe of one door response: the |
| +// status, every header and the body. Two refusals must be equal on all |
| +// three (TestStoreDoorRefusalsAreByteIdentical). |
| +type doorShape struct { |
| + code int |
| + header string |
| + body string |
| +} |
| + |
| +func shapeOf(w *httptest.ResponseRecorder) doorShape { |
| + return doorShape{w.Code, fmt.Sprintf("%v", w.Header()), w.Body.String()} |
| +} |
| + |
| +// missingInScopeShape is TestStoreDoorRefusalsAreByteIdentical's "missing |
| +// in-scope key" refusal, the shape every other refusal must equal. |
| +func missingInScopeShape(t *testing.T, d *storeDoor) doorShape { |
| + t.Helper() |
| + w := doorDo(d, "GET", "/store/o/apps/"+testSqid+"/web/nope.json", "", nil) |
| + if w.Code != http.StatusNotFound || w.Header().Get("Carlos-Store-Absent") != "" { |
| + t.Fatalf("missing in-scope key = %d %v, want a bare 404", w.Code, w.Header()) |
| + } |
| + return shapeOf(w) |
| +} |
| + |
| +// TestStoreDoorMarksOnlyAGrantedAbsentPolicy: the one 404 that says "absent" |
| +// is a policy key the door would have served. A box that read a refusal as |
| +// absence would drop its app's policy (spec §3a, Astra R3-3); a box that |
| +// could tell another account's absent key from a refused one would have a |
| +// keyspace oracle. Both are pinned here. |
| +func TestStoreDoorMarksOnlyAGrantedAbsentPolicy(t *testing.T) { |
| + d, store := newTestDoor(t) |
| + seedObject(t, store, release.AppPolicyKey("zzz", "web"), `{"v":1}`) |
| + |
| + w := doorDo(d, "GET", "/store/o/"+release.AppPolicyKey(testSqid, "web"), "", nil) |
| + if w.Code != http.StatusNotFound || w.Header().Get("Carlos-Store-Absent") != "1" { |
| + t.Fatalf("own absent policy = %d, Carlos-Store-Absent %q; want 404 with 1", w.Code, w.Header().Get("Carlos-Store-Absent")) |
| + } |
| + |
| + want := missingInScopeShape(t, d) |
| + for _, key := range []string{ |
| + release.AppPolicyKey("zzz", "web"), // seeded |
| + release.AppPolicyKey("zzz", "api"), // absent |
| + "control/app-policy/" + testSqid + "x/web.json", |
| + "control/app-policy/" + testSqid + "/web.txt", |
| + } { |
| + if got := shapeOf(doorDo(d, "GET", "/store/o/"+key, "", nil)); got != want { |
| + t.Errorf("GET %s = %+v, want the bare refusal %+v", key, got, want) |
| + } |
| + } |
| +} |
| + |
| +// etagCounter is a DirStore that counts ListETags calls and can fail them. |
| +type etagCounter struct { |
| + *release.DirStore |
| + listETags int |
| + fail error |
| +} |
| + |
| +func (c *etagCounter) ListETags(ctx context.Context, prefix string) ([]release.KeyETag, error) { |
| + c.listETags++ |
| + if c.fail != nil { |
| + return nil, c.fail |
| + } |
| + return c.DirStore.ListETags(ctx, prefix) |
| +} |
| + |
| +// TestStoreDoorListsOnlyItsOwnPolicyETags: /store/etags is the one listing a |
| +// box gets of control/, and it is its own account's policies only, read only. |
| +func TestStoreDoorListsOnlyItsOwnPolicyETags(t *testing.T) { |
| + d, dir := newTestDoor(t) |
| + store := &etagCounter{DirStore: dir} |
| + d.store = store |
| + ownWeb := release.AppPolicyKey(testSqid, "web") |
| + ownAPI := release.AppPolicyKey(testSqid, "api") |
| + seedObject(t, dir, ownWeb, `{"v":1,"app":"web"}`) |
| + seedObject(t, dir, ownAPI, `{"v":1,"app":"api"}`) |
| + seedObject(t, dir, release.AppPolicyKey("zzz", "web"), `{"v":1}`) |
| + seedObject(t, dir, "control/app-policy/"+testSqid+"/x.txt", "not a policy") |
| + |
| + etagOf := func(key string) string { |
| + t.Helper() |
| + w := doorDo(d, "GET", "/store/o/"+key, "", nil) |
| + if w.Code != http.StatusOK || w.Header().Get("ETag") == "" { |
| + t.Fatalf("GET %s = %d, ETag %q", key, w.Code, w.Header().Get("ETag")) |
| + } |
| + return w.Header().Get("ETag") |
| + } |
| + want := []map[string]string{ |
| + {"key": ownAPI, "etag": etagOf(ownAPI)}, |
| + {"key": ownWeb, "etag": etagOf(ownWeb)}, |
| + } |
| + for _, prefix := range []string{"control/app-policy/", "control/app-policy/" + testSqid + "/"} { |
| + store.listETags = 0 |
| + w := doorDo(d, "GET", "/store/etags?prefix="+prefix, "", nil) |
| + if w.Code != http.StatusOK { |
| + t.Fatalf("etags %q = %d %q, want 200", prefix, w.Code, w.Body.String()) |
| + } |
| + var got []map[string]string |
| + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { |
| + t.Fatalf("etags %q: decode %q: %v", prefix, w.Body.String(), err) |
| + } |
| + if !slices.EqualFunc(got, want, func(a, b map[string]string) bool { return fmt.Sprint(a) == fmt.Sprint(b) }) { |
| + t.Errorf("etags %q = %v, want %v", prefix, got, want) |
| + } |
| + if store.listETags != 1 { |
| + t.Errorf("etags %q: backing store saw %d ListETags, want 1", prefix, store.listETags) |
| + } |
| + } |
| + |
| + refusal := missingInScopeShape(t, d) |
| + store.listETags = 0 |
| + refused := map[string]*http.Request{ |
| + "another account": doorReq("GET", "/store/etags?prefix=control/app-policy/zzz/", testChannel, testToken, ""), |
| + "prefix-match sqid": doorReq("GET", "/store/etags?prefix=control/app-policy/"+testSqid+"x/", testChannel, testToken, ""), |
| + "own sqid no slash": doorReq("GET", "/store/etags?prefix=control/app-policy/"+testSqid, testChannel, testToken, ""), |
| + "control/": doorReq("GET", "/store/etags?prefix=control/", testChannel, testToken, ""), |
| + "own apps": doorReq("GET", "/store/etags?prefix=apps/"+testSqid+"/", testChannel, testToken, ""), |
| + "empty prefix": doorReq("GET", "/store/etags?prefix=", testChannel, testToken, ""), |
| + "no prefix": doorReq("GET", "/store/etags", testChannel, testToken, ""), |
| + "PUT": doorReq("PUT", "/store/etags?prefix=control/app-policy/", testChannel, testToken, "[]"), |
| + "DELETE": doorReq("DELETE", "/store/etags?prefix=control/app-policy/", testChannel, testToken, ""), |
| + "unauthenticated": doorReq("GET", "/store/etags?prefix=control/app-policy/", testChannel, "not-the-token", ""), |
| + "plain list of it": doorReq("GET", "/store/list?prefix=control/app-policy/", testChannel, testToken, ""), |
| + "plain list of own": doorReq("GET", "/store/list?prefix=control/app-policy/"+testSqid+"/", testChannel, testToken, ""), |
| + "dirs of the policy": doorReq("GET", "/store/dirs?prefix=control/app-policy/", testChannel, testToken, ""), |
| + } |
| + for name, r := range refused { |
| + w := httptest.NewRecorder() |
| + d.ServeHTTP(w, r) |
| + if got := shapeOf(w); got != refusal { |
| + t.Errorf("%s: %+v, want the bare refusal %+v", name, got, refusal) |
| + } |
| + } |
| + if store.listETags != 0 { |
| + t.Errorf("refused requests reached ListETags %d time(s), want 0", store.listETags) |
| + } |
| + |
| + // A backing store failure is a 502, never an empty listing a box would |
| + // read as "no policies". |
| + store.fail = errors.New("bucket unavailable") |
| + w := doorDo(d, "GET", "/store/etags?prefix=control/app-policy/", "", nil) |
| + if w.Code != http.StatusBadGateway || strings.Contains(w.Body.String(), "[]") { |
| + t.Errorf("failing ListETags = %d %q, want 502 and no listing", w.Code, w.Body.String()) |
| + } |
| + |
| + // A backing store without ETagLister answers as an older door does. |
| + d.store = &doorOpCounter{Store: dir} |
| + if got := shapeOf(doorDo(d, "GET", "/store/etags?prefix=control/app-policy/", "", nil)); got != refusal { |
| + t.Errorf("store without ETagLister: %+v, want the bare refusal %+v", got, refusal) |
| + } |
| + |
| + // Nothing to list is [], never null. |
| + empty, emptyStore := newTestDoor(t) |
| + seedObject(t, emptyStore, release.AppPolicyKey("zzz", "web"), `{"v":1}`) |
| + w = doorDo(empty, "GET", "/store/etags?prefix=control/app-policy/", "", nil) |
| + if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != "[]" { |
| + t.Errorf("empty etag listing = %d %q, want 200 []", w.Code, w.Body.String()) |
| + } |
| +} |
diff --git a/internal/fleetapi/fleetapi.go b/internal/fleetapi/fleetapi.go| index facc0d8dc..df0203718 100644 |
| --- a/internal/fleetapi/fleetapi.go |
| +++ b/internal/fleetapi/fleetapi.go |
| @@ -2,7 +2,8 @@ |
| // 2026-08-11-fleet-api-surface-design.md, F5; Task 2's server is |
| // internal/edge/storedoor.go). It implements release.Store over the five |
| // HTTPS operations the door serves on the edge's reserved tunnel hostname — |
| -// GET/PUT/DELETE /store/o/<key>, GET /store/list, GET /store/dirs — using the |
| +// GET/PUT/DELETE /store/o/<key>, GET /store/list, GET /store/dirs — and |
| +// release.ETagLister over GET /store/etags, using the |
| // same two headers a tunnel dial already carries: "Authorization: Bearer |
| // <token>" and "Carlos-Tunnel-Name: <fleet>/<label>". A box that builds one |
| // of these needs no AWS credentials on the platform's deployment bucket at |
| @@ -127,9 +128,9 @@ func (s *store) newRequest(ctx context.Context, method, rawURL string, body io.R |
| // Get fetches key and its etag. A 404 — which the door hands back |
| // byte-identically whether the key never existed, belongs to another |
| // tenant, or the caller's credential is bad (spec: no response distinguishes |
| -// them) — is mapped uniformly onto release.Store's os.ErrNotExist contract: |
| -// this client cannot tell those apart either, by design, and must not |
| -// pretend otherwise. |
| +// them) — is mapped onto release.Store's os.ErrNotExist contract: this |
| +// client cannot tell those apart either, by design, and must not pretend |
| +// otherwise. The one exception is an app policy key (notFound). |
| func (s *store) Get(ctx context.Context, key string) ([]byte, string, error) { |
| req, err := s.newRequest(ctx, http.MethodGet, s.objectURL(key), nil) |
| if err != nil { |
| @@ -148,7 +149,7 @@ func (s *store) Get(ctx context.Context, key string) ([]byte, string, error) { |
| case http.StatusOK: |
| return body, resp.Header.Get("ETag"), nil |
| case http.StatusNotFound: |
| - return nil, "", fmt.Errorf("fleetapi: %s: %w", key, os.ErrNotExist) |
| + return nil, "", notFound(key, resp.Header) |
| default: |
| return nil, "", errorf("get %s: unexpected status %d", key, resp.StatusCode) |
| } |
| @@ -156,7 +157,7 @@ func (s *store) Get(ctx context.Context, key string) ([]byte, string, error) { |
| // Open streams one object. Unlike Get it does not buffer the body, which |
| // is what a 25 MiB platform binary on a Pi needs; the 404 mapping is the |
| -// same as Get's so callers can errors.Is(err, os.ErrNotExist). |
| +// same as Get's (notFound) so callers can errors.Is(err, os.ErrNotExist). |
| func (s *store) Open(ctx context.Context, key string) (io.ReadCloser, string, error) { |
| req, err := s.newRequest(ctx, http.MethodGet, s.objectURL(key), nil) |
| if err != nil { |
| @@ -171,7 +172,7 @@ func (s *store) Open(ctx context.Context, key string) (io.ReadCloser, string, er |
| return resp.Body, resp.Header.Get("ETag"), nil |
| case http.StatusNotFound: |
| resp.Body.Close() |
| - return nil, "", fmt.Errorf("fleetapi: %s: %w", key, os.ErrNotExist) |
| + return nil, "", notFound(key, resp.Header) |
| default: |
| resp.Body.Close() |
| return nil, "", errorf("open %s: unexpected status %d", key, resp.StatusCode) |
| @@ -180,6 +181,25 @@ func (s *store) Open(ctx context.Context, key string) (io.ReadCloser, string, er |
| var _ release.Opener = (*store)(nil) |
| +// absentHeader is the door's mark on a 404 that means "absent" rather than |
| +// "refused" (internal/edge/storedoor.go, storeAbsentHeader). The door sets it |
| +// only on a GET of an app policy key it would have served. |
| +const absentHeader = "Carlos-Store-Absent" |
| + |
| +// notFound maps a 404 for key onto the Store contract. Every key reads it as |
| +// os.ErrNotExist, as it always has, except a key under |
| +// release.AppPolicyPrefix: there a 404 is absent only when the door marked |
| +// it so, and otherwise wraps release.ErrRefused. A refusal read as absence |
| +// is "this app has no policy", which drops the operator's egress allow and |
| +// sizes (Astra R3-3), and the bare 404 is exactly what an older edge |
| +// without the grant, or a bad token, answers. Unknown is not absent. |
| +func notFound(key string, h http.Header) error { |
| + if strings.HasPrefix(key, release.AppPolicyPrefix) && h.Get(absentHeader) != "1" { |
| + return fmt.Errorf("fleetapi: %s: %w", key, release.ErrRefused) |
| + } |
| + return fmt.Errorf("fleetapi: %s: %w", key, os.ErrNotExist) |
| +} |
| + |
| // No release.PutStreamer here, deliberately: the console never writes |
| // through a fleet's store door, and streaming would need a server side in |
| // internal/edge/storedoor.go. release.PutObjectFrom falls back to Put for |
| @@ -255,6 +275,46 @@ func (s *store) List(ctx context.Context, prefix string) ([]string, error) { |
| return s.listDecode(ctx, "/store/list", prefix) |
| } |
| +var _ release.ETagLister = (*store)(nil) |
| + |
| +// ListETags is the door's /store/etags: the box's own account's app policies |
| +// under prefix, each with its ETag, sorted by key. A listing has no |
| +// "absent", so a 404 wraps release.ErrRefused: the door refused the prefix, |
| +// the credential is bad, or the edge is older and has no such path. Callers |
| +// treat it as unknown, never as "no policies". |
| +func (s *store) ListETags(ctx context.Context, prefix string) ([]release.KeyETag, error) { |
| + req, err := s.newRequest(ctx, http.MethodGet, s.listURL("/store/etags", prefix), nil) |
| + if err != nil { |
| + return nil, err |
| + } |
| + resp, err := s.client.Do(req) |
| + if err != nil { |
| + return nil, errorf("list etags %s: %w", prefix, err) |
| + } |
| + defer resp.Body.Close() |
| + switch resp.StatusCode { |
| + case http.StatusOK: |
| + case http.StatusNotFound: |
| + return nil, fmt.Errorf("fleetapi: list etags %s: %w", prefix, release.ErrRefused) |
| + default: |
| + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) |
| + return nil, errorf("list etags %s: unexpected status %d: %s", prefix, resp.StatusCode, strings.TrimSpace(string(respBody))) |
| + } |
| + // The door's wire shape (storedoor.go, keyETag). |
| + var wire []struct { |
| + Key string `json:"key"` |
| + ETag string `json:"etag"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil { |
| + return nil, errorf("list etags %s: decode response: %w", prefix, err) |
| + } |
| + out := make([]release.KeyETag, 0, len(wire)) |
| + for _, e := range wire { |
| + out = append(out, release.KeyETag{Key: e.Key, ETag: e.ETag}) |
| + } |
| + return out, nil |
| +} |
| + |
| // ListDirs is the door's /store/dirs (the delimiter-aware listing). |
| func (s *store) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| return s.listDecode(ctx, "/store/dirs", prefix) |
diff --git a/internal/fleetapi/fleetapi_test.go b/internal/fleetapi/fleetapi_test.go| index b372a666f..f7e5bd483 100644 |
| --- a/internal/fleetapi/fleetapi_test.go |
| +++ b/internal/fleetapi/fleetapi_test.go |
| @@ -13,17 +13,22 @@ package fleetapi_test |
| import ( |
| "bytes" |
| "context" |
| + "crypto/ed25519" |
| "crypto/sha256" |
| "encoding/hex" |
| "errors" |
| "io" |
| + "maps" |
| "net" |
| "net/http" |
| + "net/http/httptest" |
| "os" |
| "path/filepath" |
| "strings" |
| "testing" |
| + "time" |
| + "github.com/carlosframework/platform/internal/apppolicy" |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/fleetapi" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -31,7 +36,8 @@ import ( |
| ) |
| const ( |
| - testSqid = "ab12" |
| + // A valid account (release.ValidAccount): the policy tests sign for it. |
| + testSqid = "abc" |
| testFleet = "pi" |
| testLabel = "box-a" |
| testToken = "box-a-bearer-token-abc123" |
| @@ -391,3 +397,170 @@ func TestOpenStreamsThroughTheDoor(t *testing.T) { |
| t.Fatalf("missing: %v", err) |
| } |
| } |
| + |
| +// ---- the app policy: refusal is not absence (#328) ------------------------- |
| + |
| +// seedPolicy signs a policy for sqid/app and writes it straight into the |
| +// backing store, as the console would. It returns the stored ETag. |
| +func seedPolicy(t *testing.T, s *release.DirStore, priv ed25519.PrivateKey, sqid, app string, now time.Time) string { |
| + t.Helper() |
| + etag, err := apppolicy.PutIfMatchETag(context.Background(), s, priv, apppolicy.Policy{ |
| + V: 1, Account: sqid, App: app, Revision: 1, SignedAt: now, |
| + EgressAllow: []string{"10.0.5.20"}, DefaultSize: "large", |
| + }, "") |
| + if err != nil { |
| + t.Fatalf("seed policy %s/%s: %v", sqid, app, err) |
| + } |
| + return etag |
| +} |
| + |
| +func TestPolicyThroughTheDoor(t *testing.T) { |
| + ctx := context.Background() |
| + d := newTestDoor(t) |
| + box := d.box(t) |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now() |
| + |
| + if p, _, err := apppolicy.Get(ctx, box, pub, testSqid, "web", now); p != nil || !errors.Is(err, os.ErrNotExist) || errors.Is(err, release.ErrRefused) { |
| + t.Fatalf("own absent policy: %v, %v; want os.ErrNotExist", p, err) |
| + } |
| + |
| + etag := seedPolicy(t, d.store, priv, testSqid, "web", now) |
| + p, gotETag, err := apppolicy.Get(ctx, box, pub, testSqid, "web", now) |
| + if err != nil || p == nil || gotETag != etag { |
| + t.Fatalf("own policy: %v, %q, %v; want the policy at etag %q", p, gotETag, err, etag) |
| + } |
| + if p.Account != testSqid || p.App != "web" || p.DefaultSize != "large" || len(p.EgressAllow) != 1 || p.EgressAllow[0] != "10.0.5.20" { |
| + t.Errorf("own policy = %+v", *p) |
| + } |
| + |
| + seedPolicy(t, d.store, priv, "zzz", "web", now) |
| + for _, sqid := range []string{"zzz", "bab"} { // seeded, and absent |
| + if _, _, err := apppolicy.Get(ctx, box, pub, sqid, "web", now); !errors.Is(err, release.ErrRefused) || errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("another account's policy (%s): %v; want ErrRefused, not os.ErrNotExist", sqid, err) |
| + } |
| + } |
| + |
| + key := release.AppPolicyKey(testSqid, "web") |
| + before, _, err := d.store.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := box.Put(ctx, key, []byte("{}"), etag); err == nil { |
| + t.Error("Put of the own policy through the door succeeded") |
| + } |
| + if _, err := box.Put(ctx, release.AppPolicyKey(testSqid, "api"), []byte("{}"), ""); err == nil { |
| + t.Error("create of an own policy through the door succeeded") |
| + } |
| + if err := box.Delete(ctx, key); err == nil { |
| + t.Error("Delete of the own policy through the door succeeded") |
| + } |
| + after, afterETag, err := d.store.Get(ctx, key) |
| + if err != nil || !bytes.Equal(after, before) || afterETag != etag { |
| + t.Errorf("the seeded policy changed: %v, etag %q (want %q)", err, afterETag, etag) |
| + } |
| + if _, _, err := d.store.Get(ctx, release.AppPolicyKey(testSqid, "api")); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("a policy was created through the door: %v", err) |
| + } |
| +} |
| + |
| +// TestPolicyRefusalFromAnOldDoorIsNotAbsence: an edge that predates the |
| +// grant answers a policy read with its bare 404. That is a refusal, and a |
| +// box that read it as "no policy" would drop the operator's allow and sizes. |
| +func TestPolicyRefusalFromAnOldDoorIsNotAbsence(t *testing.T) { |
| + ctx := context.Background() |
| + old := httptest.NewServer(http.HandlerFunc(http.NotFound)) |
| + t.Cleanup(old.Close) |
| + s, err := fleetapi.New(old.URL, testFleet+"/"+testLabel, testToken) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + policyKey := release.AppPolicyKey(testSqid, "web") |
| + |
| + if _, _, err := s.Get(ctx, policyKey); !errors.Is(err, release.ErrRefused) || errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("Get %s: %v; want ErrRefused, not os.ErrNotExist", policyKey, err) |
| + } |
| + if _, _, err := s.Get(ctx, "apps/"+testSqid+"/web/x.json"); !errors.Is(err, os.ErrNotExist) || errors.Is(err, release.ErrRefused) { |
| + t.Errorf("Get of an app key: %v; want os.ErrNotExist as before", err) |
| + } |
| + op := s.(release.Opener) |
| + if _, _, err := op.Open(ctx, policyKey); !errors.Is(err, release.ErrRefused) || errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("Open %s: %v; want ErrRefused, not os.ErrNotExist", policyKey, err) |
| + } |
| + if _, _, err := op.Open(ctx, "apps/"+testSqid+"/web/x.json"); !errors.Is(err, os.ErrNotExist) || errors.Is(err, release.ErrRefused) { |
| + t.Errorf("Open of an app key: %v; want os.ErrNotExist as before", err) |
| + } |
| + l, ok := s.(release.ETagLister) |
| + if !ok { |
| + t.Fatal("fleetapi store is not an ETagLister") |
| + } |
| + if got, err := l.ListETags(ctx, apppolicy.Prefix); !errors.Is(err, release.ErrRefused) || got != nil { |
| + t.Errorf("ListETags from an old door: %v, %v; want ErrRefused", got, err) |
| + } |
| + if _, err := apppolicy.ListETags(ctx, s); !errors.Is(err, release.ErrRefused) || errors.Is(err, errors.ErrUnsupported) { |
| + t.Errorf("apppolicy.ListETags from an old door: %v; want ErrRefused", err) |
| + } |
| +} |
| + |
| +// TestPolicyOpenThroughTheDoor: Open maps the door's answers as Get does. |
| +func TestPolicyOpenThroughTheDoor(t *testing.T) { |
| + ctx := context.Background() |
| + d := newTestDoor(t) |
| + op := d.box(t).(release.Opener) |
| + if _, _, err := op.Open(ctx, release.AppPolicyKey(testSqid, "web")); !errors.Is(err, os.ErrNotExist) || errors.Is(err, release.ErrRefused) { |
| + t.Errorf("Open own absent policy: %v; want os.ErrNotExist", err) |
| + } |
| + if _, _, err := op.Open(ctx, release.AppPolicyKey("zzz", "web")); !errors.Is(err, release.ErrRefused) || errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("Open another account's policy: %v; want ErrRefused", err) |
| + } |
| +} |
| + |
| +func TestPolicyETagsThroughTheDoor(t *testing.T) { |
| + ctx := context.Background() |
| + d := newTestDoor(t) |
| + box := d.box(t) |
| + if _, ok := box.(release.ETagLister); !ok { |
| + t.Fatal("fleetapi store is not an ETagLister: apppolicy.ListETags would take no listing at all") |
| + } |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now() |
| + |
| + empty, err := apppolicy.ListETags(ctx, box) |
| + if err != nil || len(empty) != 0 { |
| + t.Fatalf("ListETags with nothing written = %v, %v; want empty", empty, err) |
| + } |
| + |
| + seedPolicy(t, d.store, priv, testSqid, "web", now) |
| + seedPolicy(t, d.store, priv, testSqid, "api", now) |
| + seedPolicy(t, d.store, priv, "zzz", "web", now) |
| + |
| + got, err := apppolicy.ListETags(ctx, box) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := map[string]string{} |
| + for _, app := range []string{"web", "api"} { |
| + _, etag, err := apppolicy.Get(ctx, box, pub, testSqid, app, now) |
| + if err != nil || etag == "" { |
| + t.Fatalf("Get %s: %q, %v", app, etag, err) |
| + } |
| + want[testSqid+"/"+app] = etag |
| + } |
| + if !maps.Equal(got, want) { |
| + t.Errorf("ListETags through the door = %v, want %v", got, want) |
| + } |
| + |
| + // A prefix outside the own account's policies is refused, not empty. |
| + l := box.(release.ETagLister) |
| + for _, prefix := range []string{"control/app-policy/zzz/", "apps/" + testSqid + "/", "control/"} { |
| + if got, err := l.ListETags(ctx, prefix); !errors.Is(err, release.ErrRefused) { |
| + t.Errorf("ListETags(%q) = %v, %v; want ErrRefused", prefix, got, err) |
| + } |
| + } |
| +} |