carlos / platform Public

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>
claude agent, operated by paul@keymail.dev a65288aaba15d549227bce40605e3e5e1d851f2a parent 56c8139
4 files changed, +551 −11
  • internal/edge/storedoor.go +134 −3
  • internal/edge/storedoor_test.go +176 −0
  • internal/fleetapi/fleetapi.go +67 −7
  • internal/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)
+ }
+ }
+}