console: an instance sets its own update settings within instance_control (#155)
The three update settings doors admit Self (AllowSelf(Set{})): still no
app-wide role, Admin included. The binding travels into readUpdatesTarget,
applyUpdates and every requestInstall attempt; Self may change only the
explicit settings instance_control names, checked in the fixed order
updates, major, pin; install needs updates named; fast track is never
Self's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+298
−45
internal/console/apiupdates.go+19 −6internal/console/apiupdates_test.go+3 −2internal/console/authcli.go+9 −7internal/console/browserupdates.go+4 −4internal/console/instancetoken_doors_test.go+3 −7internal/console/instancetoken_updates_test.go+198 −0internal/console/svcroutes_test.go+8 −13internal/console/updatescore.go+51 −4internal/instanceupdates/control.go+3 −2
diff --git a/internal/console/apiupdates.go b/internal/console/apiupdates.go| index d483067d0..eacc8f39f 100644 |
| --- a/internal/console/apiupdates.go |
| +++ b/internal/console/apiupdates.go |
| @@ -10,9 +10,11 @@ |
| // boundary (§2 decision 5), and the console never chooses a build: it writes |
| // the record, and root decides on its next pass. |
| // |
| -// Who: people only. Every service credential is refused at the role gate |
| -// (svcrole.Set{}): svcrole.Instance is app-wide, and the instance-bound |
| -// credential §3.3 limits to instance_control is #155 step 6. |
| +// Who: people, and the instance's own credential (#155, spec |
| +// 2026-10-16-instance-bound-tokens §4.2): svcrole.AllowSelf(svcrole.Set{}), |
| +// so every app-wide service credential, Admin included, is still refused at |
| +// the role gate. The instance's credential reaches only its own host and |
| +// incarnation, and changes only what its app's instance_control names. |
| // |
| // When: pin, manual and major are turned ON only while every box runs a |
| // binary that applies them (§4.4, updatesEnforced); turning them off is |
| @@ -84,7 +86,8 @@ func updatesViewFor(rec provision.Record, policy *provision.UpdatePolicy) update |
| // answering the family's 404 for a host with no record. ok=false means a |
| // response has been written. |
| func (s *Server) updatesTarget(w http.ResponseWriter, r *http.Request, ctx context.Context, acct *Account, app string) (*provision.Record, string, *provision.UpdatePolicy, bool) { |
| - rec, etag, policy, st, msg := s.readUpdatesTarget(ctx, acct, app, r.PathValue("host")) |
| + p, _ := principalFrom(r.Context()) |
| + rec, etag, policy, st, msg := s.readUpdatesTarget(ctx, acct, app, r.PathValue("host"), bindingOf(p)) |
| if st != 0 { |
| answerUpdates(w, r, st, msg) |
| return nil, "", nil, false |
| @@ -130,7 +133,8 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - view, st, msg := s.applyUpdates(ctx, acct, app, principal, rec, etag, policy, req) |
| + p, _ := principalFrom(r.Context()) |
| + view, st, msg := s.applyUpdates(ctx, acct, app, principal, bindingOf(p), rec, etag, policy, req) |
| if st != http.StatusOK { |
| answerUpdates(w, r, st, msg) |
| return |
| @@ -254,6 +258,15 @@ func (s *Server) handleAPIUpdatesInstall(w http.ResponseWriter, r *http.Request) |
| if !readJSON(w, r, &req) { |
| return |
| } |
| + p, _ := principalFrom(r.Context()) |
| + if req.FastTrack && p.Instance != "" { |
| + // Skipping the bake window is an account owner's alone, and an |
| + // instance's own credential is no one's role: refused before the |
| + // role lookup, which takes an actor string (spec |
| + // 2026-10-16-instance-bound-tokens §4.4.4). |
| + jsonError(w, http.StatusForbidden, updatewords.Text("settings.console.fast-track-owner")) |
| + return |
| + } |
| if req.FastTrack { |
| role, err := s.store.Role(acct.ID, principal) |
| if err != nil { |
| @@ -266,7 +279,7 @@ func (s *Server) handleAPIUpdatesInstall(w http.ResponseWriter, r *http.Request) |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| defer cancel() |
| - id, st, msg := s.requestInstall(ctx, acct, app, principal, r.PathValue("host"), req.FastTrack) |
| + id, st, msg := s.requestInstall(ctx, acct, app, principal, bindingOf(p), r.PathValue("host"), req.FastTrack) |
| if st != http.StatusOK { |
| answerUpdates(w, r, st, msg) |
| return |
diff --git a/internal/console/apiupdates_test.go b/internal/console/apiupdates_test.go| index 075aeb6ad..6621013d8 100644 |
| --- a/internal/console/apiupdates_test.go |
| +++ b/internal/console/apiupdates_test.go |
| @@ -453,8 +453,9 @@ func TestUpdatesDoorsUnknownHostIs404(t *testing.T) { |
| } |
| } |
| -// The instance-bound credential is #155 step 6: until then no service |
| -// credential reaches any of the four doors. |
| +// No app-wide service credential reaches any of the four doors. The |
| +// instance's own credential reaches the first three for its own host |
| +// (instancetoken_updates_test.go); the policy door stays people-only. |
| func TestUpdatesDoorsRefuseAppWideCredentials(t *testing.T) { |
| fx := updatesFixture(t) |
| for _, c := range []struct{ method, url string }{ |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 4e0d7c176..cbb002ef5 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -666,14 +666,16 @@ func (s *Server) registerCLIRoutes() { |
| // onto a channel whose rules it never met. Widening it later is easy; |
| // narrowing an authority already granted is not. |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/channel", svcrole.Set{}, s.handleAPIInstanceChannel) |
| - // Update settings (instance update settings spec §5.1). Person-only until |
| - // #155 step 6 adds an instance-bound credential: svcrole.Instance is |
| - // app-wide, so it would let one instance's code set a sibling's pin. |
| - s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.Set{}, s.handleAPIUpdatesGet) |
| - s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.Set{}, s.handleAPIUpdatesPut) |
| + // Update settings (instance update settings spec §5.1). People, and the |
| + // instance's own credential within its app's instance_control (#155, |
| + // spec 2026-10-16-instance-bound-tokens §4.2, §4.4). Still closed to |
| + // Instance and Admin: an app-wide credential would let one instance's |
| + // code set a sibling's pin. |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.AllowSelf(svcrole.Set{}), s.handleAPIUpdatesGet) |
| + s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.AllowSelf(svcrole.Set{}), s.handleAPIUpdatesPut) |
| // "Update now" asks root for the newest build the settings allow; the |
| - // same people-only gate, and fast_track only for an account owner. |
| - s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", svcrole.Set{}, s.handleAPIUpdatesInstall) |
| + // same gate, and fast_track only for an account owner. |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", svcrole.AllowSelf(svcrole.Set{}), s.handleAPIUpdatesInstall) |
| // The app's instance update policy (spec §3.2) lives on the instances |
| // manifest, so it is closed for the manifest door's reason: a credential |
| // the app holds must not rewrite the bounds its instances run inside. |
diff --git a/internal/console/browserupdates.go b/internal/console/browserupdates.go| index 49f3e686e..699571211 100644 |
| --- a/internal/console/browserupdates.go |
| +++ b/internal/console/browserupdates.go |
| @@ -75,7 +75,7 @@ type updatesPageView struct { |
| func (s *Server) updatesPage(ctx context.Context, acct *Account, app, host string, d *instanceDetail, report *updatesJoinView, owner bool) *updatesPageView { |
| ctx, cancel := context.WithTimeout(ctx, instanceStoreTimeout) |
| defer cancel() |
| - rec, _, policy, st, _ := s.readUpdatesTarget(ctx, acct, app, host) |
| + rec, _, policy, st, _ := s.readUpdatesTarget(ctx, acct, app, host, selfBinding{}) |
| if st != 0 { |
| return nil |
| } |
| @@ -241,7 +241,7 @@ func (s *Server) updatesFormGate(w http.ResponseWriter, r *http.Request, ctx con |
| } |
| t := updatesFormTarget{addr: addr, raw: raw, role: role, app: app, host: host, acct: acct, |
| back: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/instances/" + url.PathEscape(host)} |
| - rec, etag, policy, st, msg := s.readUpdatesTarget(ctx, acct, app, host) |
| + rec, etag, policy, st, msg := s.readUpdatesTarget(ctx, acct, app, host, selfBinding{}) |
| switch { |
| case st == http.StatusNotFound && msg == "": |
| http.NotFound(w, r) |
| @@ -278,7 +278,7 @@ func (s *Server) handleBrowserUpdates(w http.ResponseWriter, r *http.Request) { |
| } |
| // An empty request is a no-op save: applyUpdates writes nothing for it. |
| req := updatesFormRequest(r.PostForm) |
| - _, st, msg := s.applyUpdates(ctx, t.acct, t.app, t.addr, t.rec, t.etag, t.policy, req) |
| + _, st, msg := s.applyUpdates(ctx, t.acct, t.app, t.addr, selfBinding{}, t.rec, t.etag, t.policy, req) |
| if st != http.StatusOK { |
| s.updatesFlash(w, r, t.back, "err", msg) |
| return |
| @@ -302,7 +302,7 @@ func (s *Server) handleBrowserUpdateNow(w http.ResponseWriter, r *http.Request) |
| if fast && !s.requireFreshAuth(w, r, t.addr, t.raw) { |
| return |
| } |
| - _, st, msg := s.requestInstall(ctx, t.acct, t.app, t.addr, t.host, fast) |
| + _, st, msg := s.requestInstall(ctx, t.acct, t.app, t.addr, selfBinding{}, t.host, fast) |
| switch { |
| case st == http.StatusNotFound && msg == "": |
| http.NotFound(w, r) |
diff --git a/internal/console/instancetoken_doors_test.go b/internal/console/instancetoken_doors_test.go| index 62e24fa72..0e4412f66 100644 |
| --- a/internal/console/instancetoken_doors_test.go |
| +++ b/internal/console/instancetoken_doors_test.go |
| @@ -26,14 +26,10 @@ var selfDoors = []string{ |
| "POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", |
| } |
| -// selfDoorsOpen is the part of selfDoors registered so far: the update |
| -// settings doors open together with their binding checks (plan Task 5). |
| -var selfDoorsOpen = selfDoors[:3] |
| - |
| func TestSelfRoleDoors(t *testing.T) { |
| fx := newConsoleFixture(t) |
| want := map[string]bool{} |
| - for _, d := range selfDoorsOpen { |
| + for _, d := range selfDoors { |
| want[d] = true |
| } |
| for pattern, set := range fx.srv.cliRoles { |
| @@ -41,7 +37,7 @@ func TestSelfRoleDoors(t *testing.T) { |
| t.Errorf("%s: admits Self = %v, want %v", pattern, set.Permits(svcrole.Self), want[pattern]) |
| } |
| } |
| - for _, d := range selfDoorsOpen { |
| + for _, d := range selfDoors { |
| if _, ok := fx.srv.cliRoles[d]; !ok { |
| t.Errorf("%s is not registered", d) |
| } |
| @@ -132,7 +128,7 @@ func (w *selfWorld) fill(pattern, host string) (method, url string) { |
| func TestSelfTokenContainment(t *testing.T) { |
| w := newSelfWorld(t) |
| self := map[string]bool{} |
| - for _, d := range selfDoorsOpen { |
| + for _, d := range selfDoors { |
| self[d] = true |
| } |
| patterns := make([]string, 0, len(w.fx.srv.cliRoles)) |
diff --git a/internal/console/instancetoken_updates_test.go b/internal/console/instancetoken_updates_test.go| new file mode 100644 |
| index 000000000..407e3d81c |
| --- /dev/null |
| +++ b/internal/console/instancetoken_updates_test.go |
| @@ -0,0 +1,198 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +// An instance sets its own update settings within instance_control (spec |
| +// 2026-10-16-instance-bound-tokens §4.3, §4.4). updatesFixture's km app and |
| +// a.example.com record, with a Self token bound to that record. |
| + |
| +func selfUpdatesFixture(t *testing.T, policy *provision.UpdatePolicy) (*updatesFx, string) { |
| + t.Helper() |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(true) |
| + m := fx.manifest() |
| + m.Updates = policy |
| + fx.setManifest(m) |
| + rec := fx.record("a.example.com") |
| + return fx, mintBoundToken(t, fx.st, OpsAccountID, "km", "a.example.com", rec.CreatedAt.UnixNano()) |
| +} |
| + |
| +func appKeeps(key string) string { |
| + return fmt.Sprintf(updatewords.Text("settings.console.app-keeps"), key) |
| +} |
| + |
| +func recordETag(t *testing.T, fx *updatesFx, host string) string { |
| + t.Helper() |
| + _, etag, err := provision.GetRecordETag(context.Background(), fx.rel, release.OpsAccount, "km", host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return etag |
| +} |
| + |
| +func TestSelfPutIsLimitedToInstanceControl(t *testing.T) { |
| + t.Run("nothing named", func(t *testing.T) { |
| + fx, tok := selfUpdatesFixture(t, &provision.UpdatePolicy{}) |
| + before := recordETag(t, fx, "a.example.com") |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"updates": "manual"}, tok) |
| + if st != http.StatusForbidden || jsonErrorText(t, out) != appKeeps("updates") { |
| + t.Fatalf("PUT updates with nothing named: %d %s", st, out) |
| + } |
| + if recordETag(t, fx, "a.example.com") != before { |
| + t.Fatal("a refused PUT wrote the record") |
| + } |
| + }) |
| + t.Run("fixed order, not request order", func(t *testing.T) { |
| + fx, tok := selfUpdatesFixture(t, &provision.UpdatePolicy{InstanceControl: []string{"pin"}}) |
| + st, _, out := rawReq(t, http.MethodPut, fx.ts.URL+updURL, tok, `{"pin":"","major":2,"updates":"manual"}`) |
| + if st != http.StatusForbidden || jsonErrorText(t, out) != appKeeps("updates") { |
| + t.Fatalf("refusal named the wrong key: %d %s", st, out) |
| + } |
| + }) |
| + t.Run("named setting", func(t *testing.T) { |
| + fx, tok := selfUpdatesFixture(t, &provision.UpdatePolicy{InstanceControl: []string{"updates"}}) |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"updates": "manual"}, tok) |
| + if st != http.StatusOK || fx.record("a.example.com").Updates != release.UpdatesManual { |
| + t.Fatalf("PUT a named setting: %d %s", st, out) |
| + } |
| + var n int |
| + if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM audit WHERE action = 'instance-updates' AND address = ?`, |
| + "instance:bab/km/a.example.com").Scan(&n); err != nil || n != 1 { |
| + t.Fatalf("instance-updates activity under the instance actor: %d rows, %v", n, err) |
| + } |
| + }) |
| + t.Run("no manifest policy", func(t *testing.T) { |
| + fx, tok := selfUpdatesFixture(t, nil) |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"updates": "auto"}, tok) |
| + if st != http.StatusForbidden || jsonErrorText(t, out) != appKeeps("updates") { |
| + t.Fatalf("PUT with no policy: %d %s", st, out) |
| + } |
| + }) |
| +} |
| + |
| +// The round trip is GET's explicit object without channel (spec §4.4.3): |
| +// echoing it is no change, echoing an inherited effective value is. |
| +func TestSelfPutRoundTrip(t *testing.T) { |
| + fx, tok := selfUpdatesFixture(t, &provision.UpdatePolicy{DefaultUpdates: release.UpdatesManual}) |
| + st, out := sendJSON(t, http.MethodGet, fx.ts.URL+updURL, nil, tok) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET own settings: %d %s", st, out) |
| + } |
| + before := recordETag(t, fx, "a.example.com") |
| + st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"updates": "", "major": nil, "pin": ""}, tok) |
| + if st != http.StatusOK || recordETag(t, fx, "a.example.com") != before { |
| + t.Fatalf("echoing the explicit values: %d %s (record moved: %v)", st, out, recordETag(t, fx, "a.example.com") != before) |
| + } |
| + st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"updates": "manual"}, tok) |
| + if st != http.StatusForbidden || jsonErrorText(t, out) != appKeeps("updates") { |
| + t.Fatalf("echoing the inherited effective value: %d %s", st, out) |
| + } |
| + st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"channel": "stable"}, tok) |
| + if st != http.StatusBadRequest || jsonErrorText(t, out) != updatewords.Text("settings.console.channel-elsewhere") { |
| + t.Fatalf("an unchanged channel: %d %s", st, out) |
| + } |
| +} |
| + |
| +func TestSelfInstall(t *testing.T) { |
| + t.Run("updates not named", func(t *testing.T) { |
| + fx, tok := selfUpdatesFixture(t, &provision.UpdatePolicy{InstanceControl: []string{"pin"}}) |
| + st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, tok) |
| + if st != http.StatusForbidden || jsonErrorText(t, out) != appKeeps("updates") { |
| + t.Fatalf("install without updates named: %d %s", st, out) |
| + } |
| + if fx.record("a.example.com").InstallRequest != nil { |
| + t.Fatal("a refused install wrote the record") |
| + } |
| + }) |
| + t.Run("updates named", func(t *testing.T) { |
| + fx, tok := selfUpdatesFixture(t, &provision.UpdatePolicy{InstanceControl: []string{"updates"}}) |
| + st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, tok) |
| + if rec := fx.record("a.example.com"); st != http.StatusOK || rec.InstallRequest == nil || rec.InstallRequest.ID != 1 { |
| + t.Fatalf("install with updates named: %d %s, %+v", st, out, rec.InstallRequest) |
| + } |
| + }) |
| + t.Run("never fast track", func(t *testing.T) { |
| + fx, tok := selfUpdatesFixture(t, &provision.UpdatePolicy{InstanceControl: []string{"updates"}}) |
| + before := recordETag(t, fx, "a.example.com") |
| + st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{"fast_track": true}, tok) |
| + if st != http.StatusForbidden || jsonErrorText(t, out) != updatewords.Text("settings.console.fast-track-owner") { |
| + t.Fatalf("Self fast track: %d %s", st, out) |
| + } |
| + if recordETag(t, fx, "a.example.com") != before { |
| + t.Fatal("a refused fast track wrote the record") |
| + } |
| + // An owner's own fast track still works. |
| + if st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{"fast_track": true}, fx.owner); st != http.StatusOK { |
| + t.Fatalf("owner fast track: %d %s", st, out) |
| + } |
| + }) |
| +} |
| + |
| +// replaceOnFirstPutStore replaces the record with a new incarnation on the |
| +// first write to it and answers that write as a lost race, so the install |
| +// door's retry finds the replacement. |
| +type replaceOnFirstPutStore struct { |
| + release.Store |
| + key string |
| + once sync.Once |
| + t *testing.T |
| + rec provision.Record |
| +} |
| + |
| +func (s *replaceOnFirstPutStore) Put(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| + replaced := false |
| + if key == s.key { |
| + s.once.Do(func() { |
| + // A context of its own: the console's write carries its prepared |
| + // record on ctx, and this is another writer's create. |
| + ctx := context.Background() |
| + if err := s.Store.Delete(ctx, key); err != nil { |
| + s.t.Error(err) |
| + } |
| + next := s.rec |
| + next.CreatedAt = s.rec.CreatedAt.Add(time.Second) |
| + next.InstallRequest = nil |
| + next.RecordSeq = 0 |
| + if err := provision.CreateRecord(ctx, s.Store, next); err != nil { |
| + s.t.Error(err) |
| + } |
| + replaced = true |
| + }) |
| + } |
| + if replaced { |
| + return "", release.ErrCASConflict |
| + } |
| + return s.Store.Put(ctx, key, body, etag) |
| +} |
| + |
| +func TestSelfInstallRetryStopsAtAReplacement(t *testing.T) { |
| + fx, tok := selfUpdatesFixture(t, &provision.UpdatePolicy{InstanceControl: []string{"updates"}}) |
| + fx.srv.cfg.Releases = &replaceOnFirstPutStore{Store: fx.rel, t: t, |
| + key: provision.RecordKey(release.OpsAccount, "km", "a.example.com"), rec: fx.record("a.example.com")} |
| + st, h, body := rawReq(t, http.MethodPost, fx.ts.URL+updURL+"/install", tok, `{}`) |
| + assertNotFound(t, "install retry onto a replacement", canonicalNotFound(t), st, h, body) |
| + if rec := fx.record("a.example.com"); rec.InstallRequest != nil { |
| + t.Fatalf("the replacement got an install request: %+v", rec.InstallRequest) |
| + } |
| +} |
| + |
| +// The browser page shares these helpers with no binding at all: a member |
| +// there may set what the app keeps for itself, exactly as before. |
| +func TestMemberIsNotLimitedByInstanceControl(t *testing.T) { |
| + fx, _ := selfUpdatesFixture(t, &provision.UpdatePolicy{}) |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"updates": "manual"}, fx.member) |
| + if st != http.StatusOK { |
| + t.Fatalf("member PUT: %d %s", st, out) |
| + } |
| +} |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 89c5b7b93..e59f12074 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -131,8 +131,8 @@ var closedDoors = []string{ |
| // The app's instance update policy (settings spec §3.2) is written onto |
| // that same manifest: which settings an instance may change itself, and |
| // the defaults new and existing instances follow. Shut for the manifest |
| - // door's reason, and the instance-bound credential (#155 step 6) does not |
| - // open it either: an instance must not widen its own control. |
| + // door's reason, and the instance's own credential (#155) does not open |
| + // it either: an instance must not widen its own control. |
| "PUT /api/cli/apps/{account}/{app}/updates", |
| // The fleet-wide live tenancy read (discussion #11). Shut to every |
| // credential for a reason that is about the door's SUBJECT rather than |
| @@ -195,15 +195,10 @@ var closedDoors = []string{ |
| // whose rules it never met. Shut for the same reason the manifest door |
| // is — an app may not rewrite the bounds it runs inside. |
| "POST /api/cli/apps/{account}/{app}/instances/{host}/channel", |
| - // Instance update settings (settings spec §3.3): a member's preference |
| - // about which authorized build an instance runs. svcrole.Instance is |
| - // app-wide, so it would let one instance's code pin a sibling; the |
| - // instance-bound credential that may reach these is #155 step 6. |
| - "GET /api/cli/apps/{account}/{app}/instances/{host}/updates", |
| - "PUT /api/cli/apps/{account}/{app}/instances/{host}/updates", |
| - // "Update now" (settings plan 3 Task 7) asks root to move this instance: |
| - // the same app-wide reason, and fast_track is an account owner's alone. |
| - "POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", |
| + // The three instance update settings doors left this list with #155: |
| + // they admit the instance's own credential (AllowSelf) and still refuse |
| + // every app-wide role, which TestAppWideCredentialsStillRefusedAtUpdateDoors |
| + // pins. |
| // The `carlos db` replica door (apidb.go) serves the whole customer |
| // database, file by file. No credential the app holds needs it: the app |
| // reads its own database live, and CI has no business pulling |
| @@ -962,8 +957,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 60 { |
| - t.Errorf("walked %d closed doors, want all %d (and 60 today: instance-token, three door-key doors, the routes door, instances-manifest, the app update policy, sidecars-manifest, the route-channel move, three instance update settings doors, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the swap policy, the live tenancy read, the layout read, the activation-cancel door, nine system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, four store-entitlement doors, and the five run doors)", |
| + if seen != len(closedDoors) || seen != 57 { |
| + t.Errorf("walked %d closed doors, want all %d (and 57 today: instance-token, three door-key doors, the routes door, instances-manifest, the app update policy, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the swap policy, the live tenancy read, the layout read, the activation-cancel door, nine system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, four store-entitlement doors, and the five run doors)", |
| seen, len(closedDoors)) |
| } |
| } |
diff --git a/internal/console/updatescore.go b/internal/console/updatescore.go| index 79cba76ff..93bcd8414 100644 |
| --- a/internal/console/updatescore.go |
| +++ b/internal/console/updatescore.go |
| @@ -18,6 +18,7 @@ import ( |
| "log" |
| "net/http" |
| "os" |
| + "slices" |
| "strings" |
| "time" |
| @@ -38,7 +39,12 @@ func answerUpdates(w http.ResponseWriter, r *http.Request, status int, msg strin |
| // readUpdatesTarget reads the record and the app policy for a settings door. |
| // status is 0 on success; a host with no record is 404 with no message. |
| -func (s *Server) readUpdatesTarget(ctx context.Context, acct *Account, app, host string) (*provision.Record, string, *provision.UpdatePolicy, int, string) { |
| +// |
| +// b is the caller's binding: an instance's own credential is answered for |
| +// the incarnation it was minted for and no other, exactly as a host with no |
| +// record is (spec 2026-10-16-instance-bound-tokens §4.3). Every update path |
| +// reads through here, each install retry included. |
| +func (s *Server) readUpdatesTarget(ctx context.Context, acct *Account, app, host string, b selfBinding) (*provision.Record, string, *provision.UpdatePolicy, int, string) { |
| rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| switch { |
| case errors.Is(err, os.ErrNotExist): |
| @@ -47,6 +53,9 @@ func (s *Server) readUpdatesTarget(ctx context.Context, acct *Account, app, host |
| log.Printf("console: api updates %s/%s/%s: %v", acct.Sqid, app, host, err) |
| return nil, "", nil, http.StatusBadGateway, updatewords.Text("settings.console.read-failed") |
| } |
| + if b.refuses(rec) { |
| + return nil, "", nil, http.StatusNotFound, "" |
| + } |
| m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app) |
| if err != nil && !errors.Is(err, os.ErrNotExist) { |
| log.Printf("console: api updates %s/%s: manifest: %v", acct.Sqid, app, err) |
| @@ -93,7 +102,10 @@ func (s *Server) enableRefusal(ctx context.Context, what string) (int, string) { |
| // type is refused rather than read as a clear. It answers the view and 200, |
| // or a status and the reviewed refusal. An empty req writes nothing. It |
| // writes preferences only: the console never chooses a build (spec §4). |
| -func (s *Server) applyUpdates(ctx context.Context, acct *Account, app, principal string, rec *provision.Record, etag string, policy *provision.UpdatePolicy, req map[string]any) (updatesView, int, string) { |
| +// |
| +// b is the caller's binding. An instance's own credential may change only |
| +// the settings its app's instance_control names (spec §4.4.2). |
| +func (s *Server) applyUpdates(ctx context.Context, acct *Account, app, principal string, b selfBinding, rec *provision.Record, etag string, policy *provision.UpdatePolicy, req map[string]any) (updatesView, int, string) { |
| if st, msg := shapeRefusal(rec); st != 0 { |
| return updatesView{}, st, msg |
| } |
| @@ -136,6 +148,11 @@ func (s *Server) applyUpdates(ctx context.Context, acct *Account, app, principal |
| // major_ignored); it neither blocks an unrelated change, nor a clear sent |
| // beside it unchanged, nor is it silently cleared by one (R4). Validation |
| // below runs with it masked. |
| + if b.Host != "" { |
| + if key, refused := selfRefusedKey(*rec, next, policy); refused { |
| + return updatesView{}, http.StatusForbidden, fmt.Sprintf(updatewords.Text("settings.console.app-keeps"), key) |
| + } |
| + } |
| if majorSet && next.Channel != "stable" { |
| return updatesView{}, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.major-off-stable"), rec.Host, next.Channel) |
| } |
| @@ -197,15 +214,22 @@ func (s *Server) applyUpdates(ctx context.Context, acct *Account, app, principal |
| // the request id and 200, or a status and the reviewed refusal (404 with no |
| // message for a host with no record). Root resolves the request to a build; |
| // the console never names one. |
| -func (s *Server) requestInstall(ctx context.Context, acct *Account, app, principal, host string, fast bool) (uint64, int, string) { |
| +// |
| +// b is the caller's binding, checked on every attempt's read, with the |
| +// policy read on that same attempt (spec §4.3): a retry that finds a |
| +// replacement record stops there. |
| +func (s *Server) requestInstall(ctx context.Context, acct *Account, app, principal string, b selfBinding, host string, fast bool) (uint64, int, string) { |
| for attempt := 0; attempt < 5; attempt++ { |
| - rec, etag, _, st, msg := s.readUpdatesTarget(ctx, acct, app, host) |
| + rec, etag, policy, st, msg := s.readUpdatesTarget(ctx, acct, app, host, b) |
| if st != 0 { |
| return 0, st, msg |
| } |
| if st, msg := shapeRefusal(rec); st != 0 { |
| return 0, st, msg |
| } |
| + if b.Host != "" && !policyNames(policy, "updates") { |
| + return 0, http.StatusForbidden, fmt.Sprintf(updatewords.Text("settings.console.app-keeps"), "updates") |
| + } |
| if rec.Pin != "" { |
| return 0, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.pinned"), rec.Host, rec.Pin) |
| } |
| @@ -231,3 +255,26 @@ func (s *Server) requestInstall(ctx context.Context, acct *Account, app, princip |
| } |
| return 0, http.StatusConflict, updatewords.Text("settings.console.changed") |
| } |
| + |
| +// selfRefusedKey is spec 2026-10-16-instance-bound-tokens §4.4.2: the first |
| +// of updates, major and pin, in that fixed order, whose EXPLICIT value this |
| +// request changes and which the app's instance_control does not name. |
| +// Effective values are not compared: echoing an inherited value back would |
| +// write an override. |
| +func selfRefusedKey(before, after provision.Record, policy *provision.UpdatePolicy) (string, bool) { |
| + switch { |
| + case before.Updates != after.Updates && !policyNames(policy, "updates"): |
| + return "updates", true |
| + case !sameMajor(before.Major, after.Major) && !policyNames(policy, "major"): |
| + return "major", true |
| + case before.Pin != after.Pin && !policyNames(policy, "pin"): |
| + return "pin", true |
| + } |
| + return "", false |
| +} |
| + |
| +// policyNames reports whether the app's instance_control names key. No |
| +// policy names nothing. |
| +func policyNames(policy *provision.UpdatePolicy, key string) bool { |
| + return policy != nil && slices.Contains(policy.InstanceControl, key) |
| +} |
diff --git a/internal/instanceupdates/control.go b/internal/instanceupdates/control.go| index ef30fc4ba..7ff672e31 100644 |
| --- a/internal/instanceupdates/control.go |
| +++ b/internal/instanceupdates/control.go |
| @@ -1,8 +1,9 @@ |
| // Package instanceupdates is the control socket's read-only view of an |
| // instance's update settings (instance update settings spec §3.3, §5.1). It |
| // is mounted beside schedule.Mount's verbs with the same bearer and the same |
| -// fixed-host rule. Writes ship with #155 step 6's instance-bound credential, |
| -// through the console, never through this socket. |
| +// fixed-host rule. Writes go through the console with the instance's own |
| +// credential (#155, spec 2026-10-16-instance-bound-tokens), never through |
| +// this socket. |
| package instanceupdates |
| import ( |