carlos / platform Public

apppolicy: the signed operator policy record (#328, #329)

internal/apppolicy: one signed operator policy per app at
control/app-policy/<sqid>/<app>.json, on the door-key precedent: ed25519
over its own domain line, a monotonic revision, a 72-hour lifetime, and
the account and app inside the signature so a record copied to another
key is refused. Get/GetForUpdate/PutIfMatch(ETag)/PutWire/Delete,
Present and the one-listing ListETags; Validate checks shape against
egressaddr and cgroup sizes.

internal/release: ErrRefused, AppPolicyPrefix/AppPolicyKey, and the
ChangeScope row mapping a policy key to its app's scope (spec §3a).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev d3757ae744b593c57ae176bf314c7a05daa24ff3 parent ce6975b
9 files changed, +1065 −3
  • internal/apppolicy/apppolicy.go +240 −0
  • internal/apppolicy/apppolicy_test.go +339 −0
  • internal/apppolicy/changeindex_test.go +32 −0
  • internal/apppolicy/signed.go +187 −0
  • internal/apppolicy/signed_test.go +234 −0
  • internal/release/changeindex.go +11 −1
  • internal/release/changeindex_test.go +3 −0
  • internal/release/release.go +13 −2
  • internal/release/store.go +6 −0
diff --git a/internal/apppolicy/apppolicy.go b/internal/apppolicy/apppolicy.go
new file mode 100644
index 000000000..73013cd92
--- /dev/null
+++ b/internal/apppolicy/apppolicy.go
@@ -0,0 +1,240 @@
+// Package apppolicy is the operator's policy for one app (spec §3): the
+// private addresses its instances may reach (#328) and the sizes they run
+// at (#329). There is one object per app, at Key(sqid, app), separate from
+// instances.json and the instance records so that a member's door or an
+// older console's read-modify-write can never erase it.
+//
+// Only the console writes it, through its policy door and its re-sign loop,
+// under the policy writer lease, compare-and-swap on the object's ETag. The
+// agent verifies it (signed.go) before it converges anything from it. Root
+// reads none of it in this build (ruling 9).
+package apppolicy
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "fmt"
+ "net/netip"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/controlname"
+ "github.com/carlosframework/platform/internal/egressaddr"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Prefix is where every app's policy lives in the deployment bucket.
+const Prefix = release.AppPolicyPrefix
+
+// Key is one app's policy object.
+func Key(sqid, app string) string { return release.AppPolicyKey(sqid, app) }
+
+// Policy is one app's signed operator policy. Account and App bind the
+// record to its key; Revision only increases; SignedAt starts its Lifetime.
+type Policy struct {
+ V int `json:"v"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Revision uint64 `json:"revision"`
+ SignedAt time.Time `json:"signed_at"`
+ AppID string `json:"app_id,omitempty"`
+ EgressAllow []string `json:"egress_allow,omitempty"`
+ DefaultSize string `json:"default_size,omitempty"`
+ Sizes map[string]SizeOverride `json:"sizes,omitempty"`
+}
+
+// SizeOverride is one host's size, bound to the instance it was set on by
+// that instance record's CreatedAt, so a host name reused after a delete
+// does not inherit it.
+type SizeOverride struct {
+ Size string `json:"size"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// Validate checks the policy's shape: the version, known size names, host
+// keys, and an egress allow already in its stored form (canonical text,
+// strictly ascending, at most egressaddr.MaxAllow). It does not decide
+// whether an address may be granted (the door does, with
+// egressaddr.Normalize) and does not look at the signing fields.
+func (p Policy) Validate() error {
+ if p.V != 1 {
+ return fmt.Errorf("%w: version %d", ErrInvalid, p.V)
+ }
+ if p.DefaultSize != "" {
+ if _, ok := cgroup.SizeByName(p.DefaultSize); !ok {
+ return fmt.Errorf("%w: default size %q", ErrInvalid, p.DefaultSize)
+ }
+ }
+ for host, o := range p.Sizes {
+ if !controlname.ValidHostShape(host) || strings.ToLower(host) != host {
+ return fmt.Errorf("%w: size host %q", ErrInvalid, host)
+ }
+ if _, ok := cgroup.SizeByName(o.Size); !ok {
+ return fmt.Errorf("%w: size %q for %s", ErrInvalid, o.Size, host)
+ }
+ if o.CreatedAt.IsZero() {
+ return fmt.Errorf("%w: size for %s has no created_at", ErrInvalid, host)
+ }
+ }
+ if len(p.EgressAllow) > egressaddr.MaxAllow {
+ return fmt.Errorf("%w: %d egress allows, at most %d", ErrInvalid, len(p.EgressAllow), egressaddr.MaxAllow)
+ }
+ var prev netip.Addr
+ for i, e := range p.EgressAllow {
+ a, err := egressaddr.Parse(e)
+ if err != nil || a.String() != e {
+ return fmt.Errorf("%w: egress allow %q", ErrInvalid, e)
+ }
+ if i > 0 && prev.Compare(a) >= 0 {
+ return fmt.Errorf("%w: egress allow %q out of order", ErrInvalid, e)
+ }
+ prev = a
+ }
+ return nil
+}
+
+// SizeFor is the size host runs at: its override if that was set on the
+// instance created at createdAt, else the app's default, else "" (the
+// build's tier). Nil-safe: no policy is no size.
+func (p *Policy) SizeFor(host string, createdAt time.Time) string {
+ if p == nil {
+ return ""
+ }
+ if o, ok := p.Sizes[host]; ok && o.CreatedAt.Equal(createdAt) {
+ return o.Size
+ }
+ return p.DefaultSize
+}
+
+// EgressColumn is the allow as the registry stores it, space-separated.
+// Nil-safe: no policy is no allow.
+func (p *Policy) EgressColumn() string {
+ if p == nil {
+ return ""
+ }
+ return strings.Join(p.EgressAllow, " ")
+}
+
+// Get is every box-side reader's read of an app's policy. An absent object
+// is an error matching os.ErrNotExist with no ETag; any other store error
+// passes through wrapped (release.ErrRefused stays matchable). An object
+// that does not verify for this key, or has expired, is refused with its
+// ETag, so the caller can tell when it changes.
+func Get(ctx context.Context, s release.Store, pub ed25519.PublicKey, sqid, app string, now time.Time) (*Policy, string, error) {
+ body, etag, err := s.Get(ctx, Key(sqid, app))
+ if err != nil {
+ return nil, "", fmt.Errorf("apppolicy: get %s/%s: %w", sqid, app, err)
+ }
+ p, err := VerifyKey(pub, body, now, sqid, app)
+ if err != nil {
+ return nil, etag, err
+ }
+ return &p, etag, nil
+}
+
+// GetForUpdate is the console's read for its policy door and re-sign loop:
+// Get without the expiry, so an operator's policy outlives a console that
+// was down past Lifetime and is re-signed when it returns. The stored bytes
+// and ETag come back beside the policy, and also with ErrUnverifiable.
+func GetForUpdate(ctx context.Context, s release.Store, pub ed25519.PublicKey, sqid, app string) (*Policy, []byte, string, error) {
+ body, etag, err := s.Get(ctx, Key(sqid, app))
+ if err != nil {
+ return nil, nil, "", fmt.Errorf("apppolicy: get %s/%s: %w", sqid, app, err)
+ }
+ p, err := OpenKey(pub, body, sqid, app)
+ if err != nil {
+ return nil, body, etag, err
+ }
+ return &p, body, etag, nil
+}
+
+// PutIfMatch signs p and writes it at its key, compare-and-swap on etag.
+func PutIfMatch(ctx context.Context, s release.Store, priv ed25519.PrivateKey, p Policy, etag string) error {
+ _, err := PutIfMatchETag(ctx, s, priv, p, etag)
+ return err
+}
+
+// PutIfMatchETag signs p and writes it at Key(p.Account, p.App), returning
+// the new ETag. etag "" is create-only; a lost race is
+// release.ErrCASConflict. The caller chooses Revision and SignedAt. A
+// policy Sign refuses is ErrInvalid and nothing is written.
+func PutIfMatchETag(ctx context.Context, s release.Store, priv ed25519.PrivateKey, p Policy, etag string) (string, error) {
+ wire, err := Sign(priv, p)
+ if err != nil {
+ return "", err
+ }
+ return PutWire(ctx, s, p.Account, p.App, wire, etag)
+}
+
+// PutWire writes bytes the console has already signed and recorded,
+// compare-and-swap on etag.
+func PutWire(ctx context.Context, s release.Store, sqid, app string, wire []byte, etag string) (string, error) {
+ newETag, err := s.Put(ctx, Key(sqid, app), wire, etag)
+ if err != nil {
+ return "", fmt.Errorf("apppolicy: put %s/%s: %w", sqid, app, err)
+ }
+ return newETag, nil
+}
+
+// Delete removes an app's policy. Deleting an absent policy is not an error.
+func Delete(ctx context.Context, s release.Store, sqid, app string) error {
+ if err := s.Delete(ctx, Key(sqid, app)); err != nil {
+ return fmt.Errorf("apppolicy: delete %s/%s: %w", sqid, app, err)
+ }
+ return nil
+}
+
+// Present is every key listed under Prefix, as listed: one LIST. Callers
+// check each key's shape.
+func Present(ctx context.Context, s release.Store) (map[string]bool, error) {
+ keys, err := s.List(ctx, Prefix)
+ if err != nil {
+ return nil, fmt.Errorf("apppolicy: list: %w", err)
+ }
+ out := make(map[string]bool, len(keys))
+ for _, k := range keys {
+ out[k] = true
+ }
+ return out, nil
+}
+
+// ListETags is every policy's ETag in one listing, keyed "<sqid>/<app>".
+// A key under Prefix that is not exactly Key(sqid, app) for a valid account
+// and app is skipped. A store that cannot list ETags returns an error
+// wrapping errors.ErrUnsupported.
+func ListETags(ctx context.Context, s release.Store) (map[string]string, error) {
+ l, ok := s.(release.ETagLister)
+ if !ok {
+ return nil, fmt.Errorf("apppolicy: list etags: %w", errors.ErrUnsupported)
+ }
+ got, err := l.ListETags(ctx, Prefix)
+ if err != nil {
+ return nil, fmt.Errorf("apppolicy: list etags: %w", err)
+ }
+ out := make(map[string]string, len(got))
+ for _, ke := range got {
+ if sqid, app, ok := parseKey(ke.Key); ok {
+ out[sqid+"/"+app] = ke.ETag
+ }
+ }
+ return out, nil
+}
+
+// parseKey is the inverse of Key for a valid account and app.
+func parseKey(key string) (sqid, app string, ok bool) {
+ rest, ok := strings.CutPrefix(key, Prefix)
+ if !ok {
+ return "", "", false
+ }
+ sqid, file, ok := strings.Cut(rest, "/")
+ if !ok {
+ return "", "", false
+ }
+ app, ok = strings.CutSuffix(file, ".json")
+ if !ok || !release.ValidAccount(sqid) || !release.ValidApp(app) {
+ return "", "", false
+ }
+ return sqid, app, true
+}
diff --git a/internal/apppolicy/apppolicy_test.go b/internal/apppolicy/apppolicy_test.go
new file mode 100644
index 000000000..eb4e734ad
--- /dev/null
+++ b/internal/apppolicy/apppolicy_test.go
@@ -0,0 +1,339 @@
+package apppolicy
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestKeyShape(t *testing.T) {
+ if got := Key("abc", "shop"); got != "control/app-policy/abc/shop.json" || got != release.AppPolicyKey("abc", "shop") {
+ t.Fatalf("Key = %q, release.AppPolicyKey = %q", got, release.AppPolicyKey("abc", "shop"))
+ }
+ if Prefix != "control/app-policy/" {
+ t.Fatalf("Prefix = %q", Prefix)
+ }
+}
+
+func TestRoundTripAndCAS(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ pub, priv := newKey(t)
+
+ if p, etag, err := Get(ctx, s, pub, "abc", "shop", testNow); p != nil || etag != "" || !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Get on an empty store = %v, %q, %v; want nil, \"\", os.ErrNotExist", p, etag, err)
+ }
+
+ p := Policy{V: 1, Account: "abc", App: "shop", Revision: 1, SignedAt: testNow,
+ EgressAllow: []string{"10.0.5.20"}, DefaultSize: "large"}
+ etag1, err := PutIfMatchETag(ctx, s, priv, p, "")
+ if err != nil || etag1 == "" {
+ t.Fatalf("create: %q, %v", etag1, err)
+ }
+ if err := PutIfMatch(ctx, s, priv, p, ""); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("a second create: %v, want ErrCASConflict", err)
+ }
+
+ got, etag, err := Get(ctx, s, pub, "abc", "shop", testNow)
+ if err != nil || etag != etag1 {
+ t.Fatalf("Get = %v, %q, %v; want etag %q", got, etag, err, etag1)
+ }
+ samePolicy(t, *got, p)
+
+ p2 := p
+ p2.Revision, p2.DefaultSize = 2, "xlarge"
+ if err := PutIfMatch(ctx, s, priv, p2, etag1); err != nil {
+ t.Fatalf("replace at etag1: %v", err)
+ }
+ p3 := p2
+ p3.Revision = 3
+ if err := PutIfMatch(ctx, s, priv, p3, etag1); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("replace at a stale etag: %v, want ErrCASConflict", err)
+ }
+
+ if err := Delete(ctx, s, "abc", "shop"); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := Get(ctx, s, pub, "abc", "shop", testNow); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Get after Delete: %v, want os.ErrNotExist", err)
+ }
+ if err := Delete(ctx, s, "abc", "shop"); err != nil {
+ t.Fatalf("a second Delete: %v", err)
+ }
+}
+
+func TestValidate(t *testing.T) {
+ t0 := testNow
+ for _, p := range []Policy{
+ {V: 1},
+ {V: 1, AppID: "x", EgressAllow: []string{"10.0.5.20", "fd00:5::20"}, DefaultSize: "small",
+ Sizes: map[string]SizeOverride{"shop-a.example.com": {"xlarge", t0}}},
+ } {
+ if err := p.Validate(); err != nil {
+ t.Errorf("Validate(%+v) = %v", p, err)
+ }
+ }
+
+ nine := make([]string, 9)
+ for i := range nine {
+ nine[i] = fmt.Sprintf("10.0.5.%d", 20+i)
+ }
+ for name, p := range map[string]Policy{
+ "v 0": {V: 0},
+ "v 2": {V: 2},
+ "default medium": {V: 1, DefaultSize: "medium"},
+ "size default": {V: 1, Sizes: map[string]SizeOverride{"shop-a.example.com": {"default", t0}}},
+ "size zero created_at": {V: 1, Sizes: map[string]SizeOverride{"shop-a.example.com": {"xlarge", time.Time{}}}},
+ "size host upper case": {V: 1, Sizes: map[string]SizeOverride{"Shop-A.example.com": {"xlarge", t0}}},
+ "size host path": {V: 1, Sizes: map[string]SizeOverride{"../x": {"xlarge", t0}}},
+ "nine allows": {V: 1, EgressAllow: nine},
+ "unsorted allows": {V: 1, EgressAllow: []string{"fd00:5::20", "10.0.5.20"}},
+ "duplicate allows": {V: 1, EgressAllow: []string{"10.0.5.20", "10.0.5.20"}},
+ "allow with a port": {V: 1, EgressAllow: []string{"10.0.5.20:80"}},
+ "allow not in canon form": {V: 1, EgressAllow: []string{"::ffff:10.0.5.20"}},
+ } {
+ if err := p.Validate(); !errors.Is(err, ErrInvalid) {
+ t.Errorf("%s: Validate = %v, want ErrInvalid", name, err)
+ }
+ }
+}
+
+func TestGetRefusesWhatItCannotVerifyButReturnsItsETag(t *testing.T) {
+ ctx := context.Background()
+ pub, priv := newKey(t)
+ key := Key("abc", "shop")
+
+ seed := func(t *testing.T, body []byte) (release.Store, string) {
+ t.Helper()
+ s := release.NewDirStore(t.TempDir())
+ etag, err := s.Put(ctx, key, body, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s, etag
+ }
+
+ for name, body := range map[string][]byte{
+ "malformed": []byte(`{`),
+ "unsigned": []byte(`{"v":1,"account":"abc","app":"shop","egress_allow":["10.0.5.20"]}`),
+ } {
+ s, want := seed(t, body)
+ p, etag, err := Get(ctx, s, pub, "abc", "shop", testNow)
+ if p != nil || etag == "" || etag != want || !errors.Is(err, ErrUnverifiable) {
+ t.Fatalf("%s: Get = %v, %q, %v; want nil, %q, ErrUnverifiable", name, p, etag, err, want)
+ }
+ }
+
+ old := p1(testNow.Add(-73 * time.Hour))
+ oldWire := mustSign(t, priv, old)
+ s, want := seed(t, oldWire)
+ if p, etag, err := Get(ctx, s, pub, "abc", "shop", testNow); p != nil || etag != want || !errors.Is(err, ErrExpired) {
+ t.Fatalf("expired: Get = %v, %q, %v; want nil, %q, ErrExpired", p, etag, err, want)
+ }
+ p, body, etag, err := GetForUpdate(ctx, s, pub, "abc", "shop")
+ if err != nil || etag != want || string(body) != string(oldWire) {
+ t.Fatalf("expired: GetForUpdate = %v, %q, %q, %v; want the stored bytes at %q", p, body, etag, err, want)
+ }
+ samePolicy(t, *p, old)
+
+ other := p1(testNow)
+ other.App = "other"
+ otherWire := mustSign(t, priv, other)
+ s, want = seed(t, otherWire)
+ if p, etag, err := Get(ctx, s, pub, "abc", "shop", testNow); p != nil || etag != want || !errors.Is(err, ErrUnverifiable) {
+ t.Fatalf("another app's record: Get = %v, %q, %v; want nil, %q, ErrUnverifiable", p, etag, err, want)
+ }
+ p, body, etag, err = GetForUpdate(ctx, s, pub, "abc", "shop")
+ if p != nil || etag != want || string(body) != string(otherWire) || !errors.Is(err, ErrUnverifiable) {
+ t.Fatalf("another app's record: GetForUpdate = %v, %q, %q, %v; want nil, the bytes, %q, ErrUnverifiable", p, body, etag, err, want)
+ }
+}
+
+func TestGetPassesStoreErrorsThrough(t *testing.T) {
+ ctx := context.Background()
+ pub, _ := newKey(t)
+ s := failingStore{Store: release.NewDirStore(t.TempDir()), err: fmt.Errorf("door: %w", release.ErrRefused)}
+ if p, etag, err := Get(ctx, s, pub, "abc", "shop", testNow); p != nil || etag != "" || !errors.Is(err, release.ErrRefused) || errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Get = %v, %q, %v; want nil, \"\", ErrRefused", p, etag, err)
+ }
+ if p, body, etag, err := GetForUpdate(ctx, s, pub, "abc", "shop"); p != nil || body != nil || etag != "" || !errors.Is(err, release.ErrRefused) {
+ t.Fatalf("GetForUpdate = %v, %q, %q, %v; want ErrRefused", p, body, etag, err)
+ }
+}
+
+func TestSizeForHonoursTheBinding(t *testing.T) {
+ t0 := time.Now()
+ t1 := t0.Add(time.Hour)
+ p := &Policy{V: 1, DefaultSize: "large", Sizes: map[string]SizeOverride{"shop-a.example.com": {"xlarge", t0}}}
+ if got := p.SizeFor("shop-a.example.com", t0); got != "xlarge" {
+ t.Fatalf("bound host: %q, want xlarge", got)
+ }
+ if got := p.SizeFor("shop-a.example.com", t0.Add(time.Second)); got != "large" {
+ t.Fatalf("recreated host: %q, want large", got)
+ }
+ if got := p.SizeFor("shop-b.example.com", t1); got != "large" {
+ t.Fatalf("other host: %q, want large", got)
+ }
+ if got := (&Policy{V: 1}).SizeFor("shop-a.example.com", t0); got != "" {
+ t.Fatalf("no sizes: %q, want \"\"", got)
+ }
+ var nilp *Policy
+ if got := nilp.SizeFor("shop-a.example.com", t0); got != "" {
+ t.Fatalf("nil policy SizeFor: %q", got)
+ }
+ if got := nilp.EgressColumn(); got != "" {
+ t.Fatalf("nil policy EgressColumn: %q", got)
+ }
+ if got := (&Policy{EgressAllow: []string{"10.0.5.20", "fd00:5::20"}}).EgressColumn(); got != "10.0.5.20 fd00:5::20" {
+ t.Fatalf("EgressColumn = %q", got)
+ }
+
+ // The record's time arrives through JSON (the registry, the policy):
+ // the monotonic reading and the location are gone, the instant is not.
+ b, err := json.Marshal(t0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var parsed time.Time
+ if err := json.Unmarshal(b, &parsed); err != nil {
+ t.Fatal(err)
+ }
+ if got := p.SizeFor("shop-a.example.com", parsed); got != "xlarge" {
+ t.Fatalf("after a JSON round trip: %q, want xlarge", got)
+ }
+ wire, err := json.Marshal(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var back Policy
+ if err := json.Unmarshal(wire, &back); err != nil {
+ t.Fatal(err)
+ }
+ if got := back.SizeFor("shop-a.example.com", t0); got != "xlarge" {
+ t.Fatalf("a policy after a JSON round trip: %q, want xlarge", got)
+ }
+}
+
+func TestPresentListsPolicies(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, priv := newKey(t)
+ for _, app := range []string{"shop", "web"} {
+ p := p1(testNow)
+ p.App = app
+ if err := PutIfMatch(ctx, s, priv, p, ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := s.Put(ctx, "control/door-keys/abc/shop.json", []byte("{}"), ""); err != nil {
+ t.Fatal(err)
+ }
+ got, err := Present(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := map[string]bool{Key("abc", "shop"): true, Key("abc", "web"): true}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("Present = %v, want %v", got, want)
+ }
+}
+
+// countingStore counts Get and ListETags and forwards ETagLister.
+type countingStore struct {
+ release.Store
+ gets, listETags int
+}
+
+func (c *countingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ c.gets++
+ return c.Store.Get(ctx, key)
+}
+
+func (c *countingStore) ListETags(ctx context.Context, prefix string) ([]release.KeyETag, error) {
+ c.listETags++
+ return c.Store.(release.ETagLister).ListETags(ctx, prefix)
+}
+
+// plainStore hides every optional interface of the store it wraps.
+type plainStore struct{ release.Store }
+
+// failingStore answers every Get with err.
+type failingStore struct {
+ release.Store
+ err error
+}
+
+func (f failingStore) Get(context.Context, string) ([]byte, string, error) { return nil, "", f.err }
+
+func TestListETagsIsOneListing(t *testing.T) {
+ ctx := context.Background()
+ dir := release.NewDirStore(t.TempDir())
+ pub, priv := newKey(t)
+ for _, app := range []string{"shop", "web"} {
+ p := p1(testNow)
+ p.App = app
+ if err := PutIfMatch(ctx, dir, priv, p, ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, stray := range []string{"control/app-policy/abc/x.txt", "control/app-policy/abc/shop.json.bak",
+ "control/app-policy/AB/shop.json", "control/app-policy/abc/sub/shop.json", "control/app-policy-lease/lock.json"} {
+ if _, err := dir.Put(ctx, stray, []byte("{}"), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ want := map[string]string{}
+ for _, app := range []string{"shop", "web"} {
+ _, etag, err := Get(ctx, dir, pub, "abc", app, testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want["abc/"+app] = etag
+ }
+
+ c := &countingStore{Store: dir}
+ got, err := ListETags(ctx, c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("ListETags = %v, want %v", got, want)
+ }
+ if c.listETags != 1 || c.gets != 0 {
+ t.Fatalf("ListETags made %d listings and %d gets, want 1 and 0", c.listETags, c.gets)
+ }
+
+ if got, err := ListETags(ctx, plainStore{dir}); !errors.Is(err, errors.ErrUnsupported) {
+ t.Fatalf("a store without ETagLister: %v, %v; want ErrUnsupported", got, err)
+ }
+}
+
+func TestPolicyJSONTags(t *testing.T) {
+ // The record's wire keys are the pinned JSON tags, empty fields omitted.
+ b, err := json.Marshal(Policy{V: 1, Account: "abc", App: "shop", Revision: 1, SignedAt: testNow, AppID: "x",
+ EgressAllow: []string{"10.0.5.20"}, DefaultSize: "large",
+ Sizes: map[string]SizeOverride{"shop-a.example.com": {"xlarge", testNow}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, k := range []string{`"v":`, `"account":`, `"app":`, `"revision":`, `"signed_at":`, `"app_id":`,
+ `"egress_allow":`, `"default_size":`, `"sizes":`, `"size":`, `"created_at":`} {
+ if !strings.Contains(string(b), k) {
+ t.Errorf("%s lacks %s", b, k)
+ }
+ }
+ b, _ = json.Marshal(Policy{V: 1})
+ for _, k := range []string{"app_id", "egress_allow", "default_size", "sizes"} {
+ if strings.Contains(string(b), k) {
+ t.Errorf("an empty %s is written: %s", k, b)
+ }
+ }
+}
diff --git a/internal/apppolicy/changeindex_test.go b/internal/apppolicy/changeindex_test.go
new file mode 100644
index 000000000..396b43da2
--- /dev/null
+++ b/internal/apppolicy/changeindex_test.go
@@ -0,0 +1,32 @@
+package apppolicy
+
+import (
+ "context"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// TestAPolicyWriteBumpsTheAppScope writes through this package, not the
+// store, so it lives here: release cannot import apppolicy (Astra plan R7-1).
+func TestAPolicyWriteBumpsTheAppScope(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ s.EnableChangeIndex()
+ _, priv := newKey(t)
+ p := p1(testNow)
+ p.Account = "bab"
+ if err := PutIfMatch(ctx, s, priv, p, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := Delete(ctx, s, "bab", "shop"); err != nil {
+ t.Fatal(err)
+ }
+ idx, _, err := release.ReadChangeIndex(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if idx.Entries["apps/bab/shop"] != 2 || len(idx.Entries) != 1 {
+ t.Fatalf("entries = %v, want apps/bab/shop at 2 and nothing else", idx.Entries)
+ }
+}
diff --git a/internal/apppolicy/signed.go b/internal/apppolicy/signed.go
new file mode 100644
index 000000000..4bfb45c4b
--- /dev/null
+++ b/internal/apppolicy/signed.go
@@ -0,0 +1,187 @@
+// Signing follows the door-key precedent (internal/doorkey, spec §3). Under
+// claims the agent is treated as compromised, yet it holds the box's cloud
+// role and can write control/app-policy/, so an unsigned policy would let it
+// forge an egress allow. The console signs each policy with the release key,
+// with a monotonic revision and a 72-hour lifetime, and re-signs it well
+// inside that lifetime; every reader verifies it before acting on it.
+//
+// The release key signs other records (release pointers, pipelines, system
+// pointers, grants) over the bare base64 payload, and door-key records over
+// "carlos door-keys v1\n" plus the payload. A policy is signed over its own
+// domain line plus the payload, so no signature the key has made for any of
+// those records verifies as a policy, and a policy's signature verifies as
+// none of them.
+//
+// Account and App are inside the signed payload, so a record names the key
+// it was signed for: a record copied to another app's key is refused
+// (VerifyKey, OpenKey), as the edge refuses a door-key record filed under
+// another app (internal/edge/doorkeys.go).
+package apppolicy
+
+import (
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const (
+ // Lifetime is how long a signed policy is honoured after signing. The
+ // console re-signs well inside it; a console down for longer closes the
+ // granted addresses rather than leaving a revoked allow working, the
+ // door keys' trade.
+ Lifetime = 72 * time.Hour
+
+ // MaxClockSkew is how far ahead of a verifier's clock a policy's
+ // SignedAt may be before Verify refuses it.
+ MaxClockSkew = 5 * time.Minute
+
+ // domain separates a policy signature from every other record the
+ // release key signs.
+ domain = "carlos app-policy v1\n"
+)
+
+var (
+ // ErrInvalid is a policy that may not be signed or stored: its shape
+ // (Validate) or its signing fields are wrong.
+ ErrInvalid = errors.New("apppolicy: invalid policy")
+ // ErrUnverifiable is a stored object that is not a policy validly
+ // signed for its key: a bad or missing signature, a malformed body, a
+ // record for another app, or one signed in the future.
+ ErrUnverifiable = errors.New("apppolicy: policy unverifiable")
+ // ErrExpired is a validly signed policy past its Lifetime.
+ ErrExpired = errors.New("apppolicy: policy expired")
+)
+
+type signedWrapper struct {
+ Payload string `json:"payload"`
+ Sig string `json:"sig"`
+}
+
+// signingFields checks what Validate does not: the fields that bind a
+// record to its key and order it.
+func (p Policy) signingFields() error {
+ switch {
+ case !release.ValidAccount(p.Account):
+ return fmt.Errorf("account %q", p.Account)
+ case !release.ValidApp(p.App):
+ return fmt.Errorf("app %q", p.App)
+ case p.Revision == 0:
+ return errors.New("revision 0")
+ case p.SignedAt.IsZero():
+ return errors.New("no signed_at")
+ }
+ return nil
+}
+
+// Expired reports whether now is at or past the end of p's lifetime.
+func (p Policy) Expired(now time.Time) bool { return !now.Before(p.SignedAt.Add(Lifetime)) }
+
+// Sign produces the bucket object for p.
+func Sign(priv ed25519.PrivateKey, p Policy) ([]byte, error) {
+ if len(priv) != ed25519.PrivateKeySize {
+ return nil, fmt.Errorf("%w: no signing key", ErrInvalid)
+ }
+ if err := p.Validate(); err != nil {
+ return nil, err
+ }
+ if err := p.signingFields(); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalid, err)
+ }
+ payload, err := json.Marshal(p)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalid, err)
+ }
+ b64 := base64.StdEncoding.EncodeToString(payload)
+ return json.MarshalIndent(signedWrapper{Payload: b64,
+ Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(domain+b64)))}, "", " ")
+}
+
+// open checks the signature and the shape, not the expiry or the key.
+func open(pub ed25519.PublicKey, wire []byte) (Policy, error) {
+ if len(pub) != ed25519.PublicKeySize {
+ return Policy{}, fmt.Errorf("%w: no public key", ErrUnverifiable)
+ }
+ var w signedWrapper
+ if err := json.Unmarshal(wire, &w); err != nil {
+ return Policy{}, fmt.Errorf("%w: %v", ErrUnverifiable, err)
+ }
+ sig, err := base64.StdEncoding.DecodeString(w.Sig)
+ if err != nil || len(sig) != ed25519.SignatureSize || !ed25519.Verify(pub, []byte(domain+w.Payload), sig) {
+ return Policy{}, fmt.Errorf("%w: bad signature", ErrUnverifiable)
+ }
+ payload, err := base64.StdEncoding.DecodeString(w.Payload)
+ if err != nil {
+ return Policy{}, fmt.Errorf("%w: %v", ErrUnverifiable, err)
+ }
+ var p Policy
+ if err := json.Unmarshal(payload, &p); err != nil {
+ return Policy{}, fmt.Errorf("%w: %v", ErrUnverifiable, err)
+ }
+ if err := p.Validate(); err != nil {
+ return Policy{}, fmt.Errorf("%w: %v", ErrUnverifiable, err)
+ }
+ if err := p.signingFields(); err != nil {
+ return Policy{}, fmt.Errorf("%w: %v", ErrUnverifiable, err)
+ }
+ return p, nil
+}
+
+// Verify is a reader's check: signature, shape, unexpired, and not signed
+// in the future.
+func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Policy, error) {
+ p, err := open(pub, wire)
+ if err != nil {
+ return Policy{}, err
+ }
+ if p.Expired(now) {
+ return Policy{}, fmt.Errorf("%w: signed %s", ErrExpired, p.SignedAt.UTC().Format(time.RFC3339))
+ }
+ // A record dated in the future would otherwise stay valid for Lifetime
+ // from that date.
+ if p.SignedAt.After(now.Add(MaxClockSkew)) {
+ return Policy{}, fmt.Errorf("%w: signed in the future", ErrUnverifiable)
+ }
+ return p, nil
+}
+
+// VerifyRevision is the publisher's read: the revision a validly signed
+// record carries, expired or not, so the next write stays above it.
+func VerifyRevision(pub ed25519.PublicKey, wire []byte) (uint64, error) {
+ p, err := open(pub, wire)
+ if err != nil {
+ return 0, err
+ }
+ return p.Revision, nil
+}
+
+// VerifyKey is Verify for the record stored at Key(sqid, app): the record
+// must name that account and app.
+func VerifyKey(pub ed25519.PublicKey, wire []byte, now time.Time, sqid, app string) (Policy, error) {
+ p, err := Verify(pub, wire, now)
+ if err != nil {
+ return Policy{}, err
+ }
+ return bound(p, sqid, app)
+}
+
+// OpenKey is VerifyKey without the expiry: the console's read of a record it
+// will re-sign.
+func OpenKey(pub ed25519.PublicKey, wire []byte, sqid, app string) (Policy, error) {
+ p, err := open(pub, wire)
+ if err != nil {
+ return Policy{}, err
+ }
+ return bound(p, sqid, app)
+}
+
+func bound(p Policy, sqid, app string) (Policy, error) {
+ if p.Account != sqid || p.App != app {
+ return Policy{}, fmt.Errorf("%w: the record names %s/%s", ErrUnverifiable, p.Account, p.App)
+ }
+ return p, nil
+}
diff --git a/internal/apppolicy/signed_test.go b/internal/apppolicy/signed_test.go
new file mode 100644
index 000000000..cd76c5f4a
--- /dev/null
+++ b/internal/apppolicy/signed_test.go
@@ -0,0 +1,234 @@
+package apppolicy
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "reflect"
+ "testing"
+ "time"
+)
+
+// testNow is a fixed clock for the signing tests.
+var testNow = time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
+
+// The brief's fixture account is "ab12", which release.ValidAccount refuses
+// (sqids are 3-16 lower-case letters), so every test uses "abc".
+func p1(now time.Time) Policy {
+ return Policy{V: 1, Account: "abc", App: "shop", Revision: 1, SignedAt: now, EgressAllow: []string{"10.0.5.20"}}
+}
+
+func newKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
+ t.Helper()
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return pub, priv
+}
+
+func mustSign(t *testing.T, priv ed25519.PrivateKey, p Policy) []byte {
+ t.Helper()
+ wire, err := Sign(priv, p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return wire
+}
+
+// wireWith builds a wire object whose sig is priv's signature over msg(b64),
+// so a test can sign the payload in a form Sign never uses.
+func wireWith(t *testing.T, priv ed25519.PrivateKey, p Policy, msg func(b64 string) string) []byte {
+ t.Helper()
+ payload, err := json.Marshal(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b64 := base64.StdEncoding.EncodeToString(payload)
+ wire, err := json.Marshal(signedWrapper{Payload: b64,
+ Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(msg(b64))))})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return wire
+}
+
+func samePolicy(t *testing.T, got, want Policy) {
+ t.Helper()
+ if !got.SignedAt.Equal(want.SignedAt) {
+ t.Fatalf("SignedAt %v, want %v", got.SignedAt, want.SignedAt)
+ }
+ got.SignedAt, want.SignedAt = time.Time{}, time.Time{}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("policy %+v, want %+v", got, want)
+ }
+}
+
+func TestSignVerifyRoundTrip(t *testing.T) {
+ pub, priv := newKey(t)
+ p := p1(testNow)
+ wire := mustSign(t, priv, p)
+
+ got, err := Verify(pub, wire, testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ samePolicy(t, got, p)
+
+ var w map[string]string
+ if err := json.Unmarshal(wire, &w); err != nil {
+ t.Fatal(err)
+ }
+ if len(w) != 2 || w["payload"] == "" || w["sig"] == "" {
+ t.Fatalf("wire fields %v, want payload and sig", w)
+ }
+ payload, err := base64.StdEncoding.DecodeString(w["payload"])
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fields map[string]any
+ if err := json.Unmarshal(payload, &fields); err != nil {
+ t.Fatal(err)
+ }
+ if fields["revision"] != float64(1) {
+ t.Fatalf("payload revision %v, want 1", fields["revision"])
+ }
+ if _, ok := fields["signed_at"]; !ok {
+ t.Fatalf("payload %s carries no signed_at", payload)
+ }
+
+ rev, err := VerifyRevision(pub, wire)
+ if err != nil || rev != 1 {
+ t.Fatalf("VerifyRevision = %d, %v; want 1", rev, err)
+ }
+}
+
+func TestVerifyRefuses(t *testing.T) {
+ pub, priv := newKey(t)
+ p := p1(testNow)
+ wire := mustSign(t, priv, p)
+
+ otherPub, _ := newKey(t)
+ if _, err := Verify(otherPub, wire, testNow); !errors.Is(err, ErrUnverifiable) {
+ t.Fatalf("another key: %v, want ErrUnverifiable", err)
+ }
+
+ // The payload changed under the old signature.
+ var w signedWrapper
+ if err := json.Unmarshal(wire, &w); err != nil {
+ t.Fatal(err)
+ }
+ tampered := p
+ tampered.EgressAllow = []string{"10.0.5.21"}
+ payload, _ := json.Marshal(tampered)
+ w.Payload = base64.StdEncoding.EncodeToString(payload)
+ tamperedWire, _ := json.Marshal(w)
+ if _, err := Verify(pub, tamperedWire, testNow); !errors.Is(err, ErrUnverifiable) {
+ t.Fatalf("tampered payload: %v, want ErrUnverifiable", err)
+ }
+
+ // Signed under door-keys' domain, and over the bare payload as the
+ // release key signs pointers: the same key, another record.
+ doorKeyWire := wireWith(t, priv, p, func(b64 string) string { return "carlos door-keys v1\n" + b64 })
+ if _, err := Verify(pub, doorKeyWire, testNow); !errors.Is(err, ErrUnverifiable) {
+ t.Fatalf("door-key domain: %v, want ErrUnverifiable", err)
+ }
+ bareWire := wireWith(t, priv, p, func(b64 string) string { return b64 })
+ if _, err := Verify(pub, bareWire, testNow); !errors.Is(err, ErrUnverifiable) {
+ t.Fatalf("bare payload signature: %v, want ErrUnverifiable", err)
+ }
+
+ for _, raw := range []string{
+ `{"v":1,"account":"abc","app":"shop","egress_allow":["10.0.5.20"]}`,
+ `{`,
+ } {
+ if _, err := Verify(pub, []byte(raw), testNow); !errors.Is(err, ErrUnverifiable) {
+ t.Fatalf("Verify(%s): %v, want ErrUnverifiable", raw, err)
+ }
+ }
+
+ if _, err := Verify(pub, wire, testNow.Add(Lifetime)); !errors.Is(err, ErrExpired) {
+ t.Fatalf("at SignedAt+Lifetime: %v, want ErrExpired", err)
+ }
+ if _, err := Verify(pub, wire, testNow.Add(Lifetime-time.Nanosecond)); err != nil {
+ t.Fatalf("just inside Lifetime: %v", err)
+ }
+
+ future := mustSign(t, priv, p1(testNow.Add(6*time.Minute)))
+ if _, err := Verify(pub, future, testNow); !errors.Is(err, ErrUnverifiable) {
+ t.Fatalf("signed 6 minutes ahead: %v, want ErrUnverifiable", err)
+ }
+ nearFuture := mustSign(t, priv, p1(testNow.Add(4*time.Minute)))
+ if _, err := Verify(pub, nearFuture, testNow); err != nil {
+ t.Fatalf("signed 4 minutes ahead: %v", err)
+ }
+}
+
+func TestSignRefusesAnUnsignableRecord(t *testing.T) {
+ _, priv := newKey(t)
+ cases := map[string]func(*Policy){
+ "revision 0": func(p *Policy) { p.Revision = 0 },
+ "zero signed_at": func(p *Policy) { p.SignedAt = time.Time{} },
+ "bad account": func(p *Policy) { p.Account = "AB!" },
+ "empty app": func(p *Policy) { p.App = "" },
+ "unknown default": func(p *Policy) { p.DefaultSize = "huge" },
+ }
+ for name, mutate := range cases {
+ p := p1(testNow)
+ mutate(&p)
+ if wire, err := Sign(priv, p); !errors.Is(err, ErrInvalid) {
+ t.Errorf("%s: Sign = %q, %v; want ErrInvalid", name, wire, err)
+ }
+ }
+ if wire, err := Sign(priv[:10], p1(testNow)); !errors.Is(err, ErrInvalid) {
+ t.Errorf("short key: Sign = %q, %v; want ErrInvalid", wire, err)
+ }
+}
+
+func TestVerifyRevisionIgnoresExpiry(t *testing.T) {
+ pub, priv := newKey(t)
+ p := p1(testNow.Add(-73 * time.Hour))
+ p.Revision = 7
+ wire := mustSign(t, priv, p)
+ if _, err := Verify(pub, wire, testNow); !errors.Is(err, ErrExpired) {
+ t.Fatalf("Verify: %v, want ErrExpired", err)
+ }
+ rev, err := VerifyRevision(pub, wire)
+ if err != nil || rev != 7 {
+ t.Fatalf("VerifyRevision = %d, %v; want 7", rev, err)
+ }
+
+ tampered := append([]byte(nil), wire...)
+ tampered[len(tampered)/2] ^= 1
+ if rev, err := VerifyRevision(pub, tampered); err == nil {
+ t.Fatalf("VerifyRevision of a tampered wire = %d, want an error", rev)
+ }
+}
+
+func TestVerifyKeyBindsTheRecordToItsKey(t *testing.T) {
+ pub, priv := newKey(t)
+ wire := mustSign(t, priv, p1(testNow))
+ if _, err := VerifyKey(pub, wire, testNow, "abc", "shop"); err != nil {
+ t.Fatalf("its own key: %v", err)
+ }
+ for _, k := range [][2]string{{"abc", "other"}, {"zzz", "shop"}} {
+ if _, err := VerifyKey(pub, wire, testNow, k[0], k[1]); !errors.Is(err, ErrUnverifiable) {
+ t.Fatalf("VerifyKey for %s/%s: %v, want ErrUnverifiable", k[0], k[1], err)
+ }
+ }
+
+ old := mustSign(t, priv, p1(testNow.Add(-73*time.Hour)))
+ if _, err := OpenKey(pub, old, "abc", "shop"); err != nil {
+ t.Fatalf("OpenKey of an expired record: %v", err)
+ }
+ if _, err := OpenKey(pub, old, "abc", "other"); !errors.Is(err, ErrUnverifiable) {
+ t.Fatalf("OpenKey for abc/other: %v, want ErrUnverifiable", err)
+ }
+ tampered := append([]byte(nil), old...)
+ tampered[len(tampered)/2] ^= 1
+ if _, err := OpenKey(pub, tampered, "abc", "shop"); !errors.Is(err, ErrUnverifiable) {
+ t.Fatalf("OpenKey of a tampered wire: %v, want ErrUnverifiable", err)
+ }
+}
diff --git a/internal/release/changeindex.go b/internal/release/changeindex.go
index 99e309713..3088ee007 100644
--- a/internal/release/changeindex.go
+++ b/internal/release/changeindex.go
@@ -48,7 +48,8 @@ var dataPrefixes = map[string]bool{
}
// ChangeScope maps a bucket key to the scope whose generation a write to it
-// bumps. The scopes are apps/<a>/<b> (except the data prefixes), accounts/<a>,
+// bumps. The scopes are apps/<a>/<b> (except the data prefixes, and also
+// bumped by that app's policy, control/app-policy/<a>/<b>.json), accounts/<a>,
// control/certs and control/edges; every other key reports ok=false.
func ChangeScope(key string) (scope string, ok bool) {
parts := strings.Split(key, "/")
@@ -71,6 +72,15 @@ func ChangeScope(key string) (scope string, ok bool) {
if parts[1] == "certs" || parts[1] == "edges" {
return "control/" + parts[1], true
}
+ // control/app-policy/<a>/<b>.json is that app's operator policy
+ // (spec §3a): a write moves the app's own generation, so the agent
+ // re-reads only that app's policy. Shape only, like the rows above;
+ // the readers validate the names.
+ if parts[1] == "app-policy" && len(parts) == 4 && parts[2] != "" {
+ if b, ok := strings.CutSuffix(parts[3], ".json"); ok && b != "" {
+ return "apps/" + parts[2] + "/" + b, true
+ }
+ }
}
return "", false
}
diff --git a/internal/release/changeindex_test.go b/internal/release/changeindex_test.go
index c03a31b7d..4d389abb3 100644
--- a/internal/release/changeindex_test.go
+++ b/internal/release/changeindex_test.go
@@ -18,6 +18,7 @@ func TestChangeScope(t *testing.T) {
"accounts/bab/telemetry.json": "accounts/bab",
"control/certs/example.com": "control/certs",
"control/edges/box-a.json": "control/edges",
+ "control/app-policy/bab/shop.json": "apps/bab/shop",
}
for k, want := range cases {
got, ok := ChangeScope(k)
@@ -33,6 +34,8 @@ func TestChangeScope(t *testing.T) {
ChangeIndexKey, "control/serial", "control/edge-mesh-key",
"control/edge-mesh-key.next", "releases/x", "status/boxes/a.json",
"fleets/f/apps/bab/shop/config.json",
+ "control/app-policy/bab/shop.json/x", "control/app-policy/bab", "control/app-policy/bab/.json",
+ "control/app-policy/bab/shop.txt", "control/app-policy//shop.json", "control/app-policy-lease/lock.json",
} {
if s, ok := ChangeScope(k); ok {
t.Errorf("ChangeScope(%q) = %q, want none", k, s)
diff --git a/internal/release/release.go b/internal/release/release.go
index d77987961..23f50e4c2 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -254,8 +254,9 @@ func ChannelsPrefix(acct, app string) string { return appPrefix(acct, app) + "ch
func ReleasesPrefix(acct, app string) string { return appPrefix(acct, app) + "releases/" }
// AppPrefix is everything an app owns in the bucket, under its account,
-// except its open ledgers (LedgerAppPrefix) and its door-key record
-// (internal/doorkey), which live outside it.
+// except its open ledgers (LedgerAppPrefix), its door-key record
+// (internal/doorkey) and its operator policy (AppPolicyKey), which live
+// outside it.
func AppPrefix(acct, app string) string { return appPrefix(acct, app) }
// LedgerAppPrefix is where an app's open ledgers live. internal/ledger builds
@@ -264,6 +265,16 @@ func AppPrefix(acct, app string) string { return appPrefix(acct, app) }
// because PurgeApp sweeps it and ledger imports release.
func LedgerAppPrefix(acct, app string) string { return "ledgers/" + acct + "/" + app + "/" }
+// AppPolicyPrefix is where the signed operator policies live, one object per
+// app (internal/apppolicy). It sits under control/, outside AppPrefix, so no
+// app-scoped writer (a member's door, an older console) can rewrite it.
+// It is defined here, not in internal/apppolicy, because ChangeScope maps it
+// and apppolicy imports release.
+const AppPolicyPrefix = "control/app-policy/"
+
+// AppPolicyKey is one app's operator policy.
+func AppPolicyKey(acct, app string) string { return AppPolicyPrefix + acct + "/" + app + ".json" }
+
func appPrefix(acct, app string) string { return "apps/" + acct + "/" + app + "/" }
// The legacy, account-less key shapes. Since the read-fallback was retired
diff --git a/internal/release/store.go b/internal/release/store.go
index 5f7d76e09..6d5f58fde 100644
--- a/internal/release/store.go
+++ b/internal/release/store.go
@@ -36,6 +36,12 @@ import (
// re-read and retry; they never overwrite blind.
var ErrCASConflict = errors.New("store: compare-and-swap conflict")
+// ErrRefused is a store that would not answer for a key, told apart from
+// the key being absent: a door that refuses the read, or an older edge that
+// has no such path. A store that cannot tell the two apart never returns it.
+// Callers treat it as unknown, never as "no object".
+var ErrRefused = errors.New("store: refused")
+
// Store is the deployment bucket. Get's not-found is os.ErrNotExist-wrapped
// so callers use errors.Is; Put with ifMatch "" is create-only
// (If-None-Match: *), non-empty is replace-only (If-Match: <etag>), and a