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>
9 files changed,
+1065
−3
internal/apppolicy/apppolicy.go+240 −0internal/apppolicy/apppolicy_test.go+339 −0internal/apppolicy/changeindex_test.go+32 −0internal/apppolicy/signed.go+187 −0internal/apppolicy/signed_test.go+234 −0internal/release/changeindex.go+11 −1internal/release/changeindex_test.go+3 −0internal/release/release.go+13 −2internal/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 |