carlos / platform Public

console: GET, PATCH and move status admit an instance's own credential (#155)

Self passes the three doors for its own host and incarnation only. PATCH
from Self takes addrs and probe and nothing else; move status checks the
incarnation before any refusal that reads the record.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7354517c201ff39497f4f9fe9aa9766cc3542772 parent e42e225
6 files changed, +296 −4
  • internal/console/apiinstancemove.go +7 −0
  • internal/console/apiinstances.go +20 −0
  • internal/console/authcli.go +7 −3
  • internal/console/instancetoken_doors_test.go +258 −0
  • internal/console/instancetoken_gate_test.go +3 −1
  • internal/console/instancetoken_words.go +1 −0
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index 9f59bab8d..54d3e1d2e 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -71,6 +71,13 @@ func (s *Server) moveGate(w http.ResponseWriter, r *http.Request) (acct *Account
jsonError(w, http.StatusBadGateway, "could not read the instance")
return nil, "", "", nil, false
}
+ // Before any refusal that reads the record: an instance's own credential
+ // must learn nothing about a replacement incarnation (spec
+ // 2026-10-16-instance-bound-tokens §4.3).
+ if p, _ := principalFrom(r.Context()); bindingOf(p).refuses(rec) {
+ http.NotFound(w, r)
+ return nil, "", "", nil, false
+ }
if k := rec.Kind; k != "" && k != "instance" {
jsonError(w, http.StatusBadRequest, c.kind(k))
return nil, "", "", nil, false
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index c43d6ca08..80649af71 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -891,6 +891,12 @@ func (s *Server) handleAPIInstanceGet(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadGateway, "could not read the instance")
return
}
+ // An instance's own credential reads only the incarnation it was
+ // minted for (spec 2026-10-16-instance-bound-tokens §4.3).
+ if p, _ := principalFrom(r.Context()); bindingOf(p).refuses(rec) {
+ http.NotFound(w, r)
+ return
+ }
writeJSON(w, http.StatusOK, instanceGetRecord{Record: *rec,
ServedBy: loadEdgesJoin(ctx, s.cfg.Releases)[host], Gated: rec.Gate != ""})
}
@@ -1963,6 +1969,14 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
if !readJSON(w, r, &req) {
return
}
+ if p.Instance != "" && (req.Environment != nil || req.Steering != nil || req.Gate != nil) {
+ // An instance's own credential may repoint its upstreams and probe,
+ // nothing else (spec 2026-10-16-instance-bound-tokens §4.4.1):
+ // environment alone would bind it to a sibling's bundle and its
+ // secrets. Refused before any read or write.
+ jsonError(w, http.StatusForbidden, selfPatchFieldsRefused)
+ return
+ }
if req.Addrs == nil && req.Probe == nil && req.Steering == nil && req.Environment == nil && req.Gate == nil {
jsonError(w, http.StatusBadRequest,
"nothing to update — send addrs, probe, steering, environment, gate, or a combination")
@@ -1988,6 +2002,12 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusBadGateway, "could not read the instance")
return
}
+ // Only the incarnation the token was minted for; the write below is
+ // conditional on this read's etag (spec §4.3).
+ if bindingOf(p).refuses(rec) {
+ http.NotFound(w, r)
+ return
+ }
// The record as it stands, kept whole before anything below mutates it:
// the shape rail past ValidateShape is a comparison BETWEEN the stored
// route and the patched one, not a property of either alone.
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index dc502e731..4e0d7c176 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -702,18 +702,22 @@ func (s *Server) registerCLIRoutes() {
// widen or narrow what its owner is shown.
s.cliRoute("PUT /api/cli/apps/{account}/{app}/telemetry", svcrole.Allow(svcrole.Operate), s.handleAPITelemetryPut)
s.cliRoute("POST /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceCreate)
- s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceGet)
+ // GET, PATCH and the move status read also admit the instance's own
+ // credential, for its own host and incarnation only (#155, spec
+ // 2026-10-16-instance-bound-tokens §4.2): cliRoute refuses any other
+ // {host}, and the handlers refuse another incarnation.
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}", svcrole.AllowSelf(svcrole.Allow(svcrole.Instance)), s.handleAPIInstanceGet)
// PATCH is the multi-upstream repoint (issue #152): the two fields of a
// record — its addr set and its probe — that change while the instance
// keeps serving. Same {host} wildcard, same gate, same byte-identical
// 404 as its GET and DELETE siblings.
- s.cliRoute("PATCH /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceUpdate)
+ s.cliRoute("PATCH /api/cli/apps/{account}/{app}/instances/{host}", svcrole.AllowSelf(svcrole.Allow(svcrole.Instance)), s.handleAPIInstanceUpdate)
// The residence-move doors (issue #331): requested by the tenant's
// ROUTER with the app-held instance token (titogo's residence design —
// never by an instance about itself... which the role system cannot yet
// express; the Instance role is the same authority the create and
// delete doors already grant this credential).
- s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveStatus)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.AllowSelf(svcrole.Allow(svcrole.Instance)), s.handleAPIInstanceMoveStatus)
s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveRequest)
s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveCut)
s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveAbort)
diff --git a/internal/console/instancetoken_doors_test.go b/internal/console/instancetoken_doors_test.go
new file mode 100644
index 000000000..62e24fa72
--- /dev/null
+++ b/internal/console/instancetoken_doors_test.go
@@ -0,0 +1,258 @@
+package console
+
+import (
+ "context"
+ "net/http"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// The six doors a Self principal may pass (spec
+// 2026-10-16-instance-bound-tokens §4.2), in that order: the record, its
+// PATCH and move status first, the three update settings doors after.
+var selfDoors = []string{
+ "GET /api/cli/apps/{account}/{app}/instances/{host}",
+ "PATCH /api/cli/apps/{account}/{app}/instances/{host}",
+ "GET /api/cli/apps/{account}/{app}/instances/{host}/move",
+ "GET /api/cli/apps/{account}/{app}/instances/{host}/updates",
+ "PUT /api/cli/apps/{account}/{app}/instances/{host}/updates",
+ "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 {
+ want[d] = true
+ }
+ for pattern, set := range fx.srv.cliRoles {
+ if set.Permits(svcrole.Self) != want[pattern] {
+ t.Errorf("%s: admits Self = %v, want %v", pattern, set.Permits(svcrole.Self), want[pattern])
+ }
+ }
+ for _, d := range selfDoorsOpen {
+ if _, ok := fx.srv.cliRoles[d]; !ok {
+ t.Errorf("%s is not registered", d)
+ }
+ }
+}
+
+// selfWorld is two sibling records of one app and a Self token bound to the
+// first. The records are upstream-backed services, so a PATCH of the probe
+// is a valid change a member could make.
+type selfWorld struct {
+ fx *consoleFixture
+ member string
+ tok string
+ a, b provision.Record
+}
+
+var otherWildcard = regexp.MustCompile(`\{[a-z_]+(\.\.\.)?\}`)
+
+const (
+ selfHostA = "a.example.com"
+ selfHostB = "b.example.com"
+)
+
+func newSelfWorld(t *testing.T) *selfWorld {
+ t.Helper()
+ fx, member := instanceFixture(t, "code-self-world")
+ w := &selfWorld{fx: fx, member: member}
+ w.a = w.create(t, selfHostA, "service")
+ w.b = w.create(t, selfHostB, "service")
+ w.tok = mintBoundToken(t, fx.st, OpsAccountID, "titogo", selfHostA, w.a.CreatedAt.UnixNano())
+ return w
+}
+
+func (w *selfWorld) create(t *testing.T, host, kind string) provision.Record {
+ t.Helper()
+ rec := provision.Record{Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], App: "titogo",
+ Account: release.OpsAccount, Pool: "tito", Region: "ie", Channel: "stable", Kind: kind,
+ CreatedAt: time.Now().UTC(), CreatedBy: "alice@keymail.dev"}
+ if kind == "service" {
+ rec.Addrs = []string{"10.0.0.1:8090", "10.0.0.2:8090"}
+ }
+ if err := provision.CreateRecord(context.Background(), w.fx.rel, rec); err != nil {
+ t.Fatalf("CreateRecord(%s): %v", host, err)
+ }
+ got, err := provision.GetRecord(context.Background(), w.fx.rel, release.OpsAccount, "titogo", host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return *got
+}
+
+func (w *selfWorld) etag(t *testing.T, host string) string {
+ t.Helper()
+ _, etag, err := provision.GetRecordETag(context.Background(), w.fx.rel, release.OpsAccount, "titogo", host)
+ if err != nil {
+ t.Fatalf("GetRecordETag(%s): %v", host, err)
+ }
+ return etag
+}
+
+// fill turns a registered pattern into a request URL, wildcards filled with
+// things that exist where they can (the closed-door walk's replacer).
+func (w *selfWorld) fill(pattern, host string) (method, url string) {
+ method, path, _ := strings.Cut(pattern, " ")
+ path = strings.NewReplacer(
+ "{account}", w.fx.sqid,
+ "{app}", "titogo",
+ "{fleet}", "edge",
+ "{label}", "box1",
+ "{name}", "hub",
+ "{host}", host,
+ "{id}", "deadbeefdeadbeef",
+ "{level}", "0",
+ "{span}", "1-1",
+ "{version}", "v1",
+ "{ledger}", "main",
+ "{key}", "k",
+ ).Replace(path)
+ // Any other wildcard gets a plain segment: a Self token is refused at
+ // the role gate before any handler reads one.
+ path = otherWildcard.ReplaceAllString(path, "x")
+ return method, w.fx.ts.URL + path
+}
+
+// TestSelfTokenContainment walks every CLI door. Outside the six, a Self
+// token is refused at the role gate, even for its own host. At the six, a
+// sibling or an unknown host is the stranger's 404, and neither record moves.
+func TestSelfTokenContainment(t *testing.T) {
+ w := newSelfWorld(t)
+ self := map[string]bool{}
+ for _, d := range selfDoorsOpen {
+ self[d] = true
+ }
+ patterns := make([]string, 0, len(w.fx.srv.cliRoles))
+ for p := range w.fx.srv.cliRoles {
+ patterns = append(patterns, p)
+ }
+ sort.Strings(patterns)
+ want := canonicalNotFound(t)
+ beforeA, beforeB := w.etag(t, selfHostA), w.etag(t, selfHostB)
+ for _, pattern := range patterns {
+ if self[pattern] {
+ for _, host := range []string{selfHostB, "c.example.com"} {
+ method, url := w.fill(pattern, host)
+ st, h, body := rawReq(t, method, url, w.tok, `{"probe":null}`)
+ assertNotFound(t, pattern+" on "+host, want, st, h, body)
+ }
+ continue
+ }
+ method, url := w.fill(pattern, selfHostA)
+ if strings.Contains(url, "{") {
+ t.Fatalf("%s has a wildcard this test does not fill: %s", pattern, url)
+ }
+ body := ""
+ if method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch {
+ body = `{}`
+ }
+ if st, _, out := rawReq(t, method, url, w.tok, body); st != http.StatusForbidden {
+ t.Errorf("%s with a Self token = %d %s, want the role 403", pattern, st, out)
+ }
+ }
+ if w.etag(t, selfHostA) != beforeA || w.etag(t, selfHostB) != beforeB {
+ t.Fatal("a refused request changed a record")
+ }
+}
+
+func TestSelfTokenReadsAndPatchesItself(t *testing.T) {
+ w := newSelfWorld(t)
+ base := instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + selfHostA
+ st, _, body := rawReq(t, http.MethodGet, base, w.tok, "")
+ if st != http.StatusOK || !strings.Contains(body, `"host":"`+selfHostA+`"`) {
+ t.Fatalf("GET own record = %d %s", st, body)
+ }
+ if st, _, body := rawReq(t, http.MethodPatch, base, w.tok, `{"probe":{"path":"/healthz","status":200}}`); st != http.StatusOK {
+ t.Fatalf("PATCH own probe = %d %s", st, body)
+ }
+ got, err := provision.GetRecord(context.Background(), w.fx.rel, release.OpsAccount, "titogo", selfHostA)
+ if err != nil || got.Health == nil || got.Health.Path != "/healthz" {
+ t.Fatalf("probe not written: %+v, %v", got, err)
+ }
+ // Its move status answers what a member's does.
+ mst, _, _ := rawReq(t, http.MethodGet, base+"/move", w.member, "")
+ if st, _, body := rawReq(t, http.MethodGet, base+"/move", w.tok, ""); st != mst {
+ t.Fatalf("move status = %d %s, a member gets %d", st, body, mst)
+ }
+}
+
+// PATCH from Self takes addrs and probe only (spec §4.4.1): environment
+// alone would bind the instance to a sibling's bundle and its secrets.
+func TestSelfPatchRefusesOtherFields(t *testing.T) {
+ w := newSelfWorld(t)
+ base := instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + selfHostA
+ before := w.etag(t, selfHostA)
+ for _, body := range []string{
+ `{"environment":"other"}`,
+ `{"environment":""}`,
+ `{"steering":"latency"}`,
+ `{"gate":"correct-horse-9"}`,
+ `{"probe":null,"environment":"other"}`,
+ } {
+ st, _, out := rawReq(t, http.MethodPatch, base, w.tok, body)
+ if st != http.StatusForbidden || !strings.Contains(out, selfPatchFieldsRefused) {
+ t.Errorf("PATCH %s = %d %s, want 403 with the Self refusal", body, st, out)
+ }
+ }
+ if w.etag(t, selfHostA) != before {
+ t.Fatal("a refused PATCH changed the record")
+ }
+ // A member may still send them: the refusal is the Self principal's.
+ if st, _, out := rawReq(t, http.MethodPatch, base, w.member, `{"steering":""}`); st != http.StatusOK {
+ t.Fatalf("member PATCH steering = %d %s", st, out)
+ }
+}
+
+// A token is bound to one incarnation (spec §3.4): delete the record and
+// create the host again, and the old token is the stranger's 404 on every
+// door, including move status for a re-created route of another kind,
+// whose kind refusal would otherwise describe the replacement.
+func TestSelfTokenDiesWithItsIncarnation(t *testing.T) {
+ w := newSelfWorld(t)
+ ctx := context.Background()
+ if err := provision.DeleteRecord(ctx, w.fx.rel, release.OpsAccount, "titogo", selfHostA); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(time.Millisecond)
+ w.create(t, selfHostA, "static")
+ want := canonicalNotFound(t)
+ base := instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + selfHostA
+ for _, c := range []struct{ method, url, body string }{
+ {http.MethodGet, base, ""},
+ {http.MethodPatch, base, `{"probe":null}`},
+ {http.MethodGet, base + "/move", ""},
+ } {
+ st, h, body := rawReq(t, c.method, c.url, w.tok, c.body)
+ assertNotFound(t, c.method+" "+c.url+" after re-creation", want, st, h, body)
+ }
+ // The replacement's own member access is untouched.
+ if st, _, body := rawReq(t, http.MethodGet, base, w.member, ""); st != http.StatusOK {
+ t.Fatalf("member GET of the replacement = %d %s", st, body)
+ }
+}
+
+func TestAppWideCredentialsStillRefusedAtUpdateDoors(t *testing.T) {
+ w := newSelfWorld(t)
+ for _, role := range []svcrole.Role{svcrole.Admin, svcrole.Instance} {
+ tok := serviceToken(t, w.fx.st, OpsAccountID, "c-"+string(role), "titogo", role)
+ for _, d := range selfDoors[3:] {
+ method, url := w.fill(d, selfHostA)
+ if st, _, body := rawReq(t, method, url, tok, `{}`); st != http.StatusForbidden {
+ t.Errorf("%s as %s = %d %s, want 403", d, role, st, body)
+ }
+ }
+ }
+}
diff --git a/internal/console/instancetoken_gate_test.go b/internal/console/instancetoken_gate_test.go
index d166176c2..79158a303 100644
--- a/internal/console/instancetoken_gate_test.go
+++ b/internal/console/instancetoken_gate_test.go
@@ -85,7 +85,9 @@ func TestInstanceLimiterIsPerIncarnation(t *testing.T) {
a2 := mintBoundToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn)
reborn := mintBoundToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn+1)
b := mintBoundToken(t, fx.st, OpsAccountID, "kass", "b.example.com", testBorn)
- url := func(host string) string { return fx.ts.URL + "/api/cli/test/" + fx.sqid + "/kass/instances/" + host + "/limit" }
+ url := func(host string) string {
+ return fx.ts.URL + "/api/cli/test/" + fx.sqid + "/kass/instances/" + host + "/limit"
+ }
for i, tok := range []string{a1, a1} {
if st, _, body := rawReq(t, http.MethodGet, url("a.example.com"), tok, ""); st != http.StatusNoContent {
t.Fatalf("request %d = %d %s", i, st, body)
diff --git a/internal/console/instancetoken_words.go b/internal/console/instancetoken_words.go
index f9e27fec4..a2896e31e 100644
--- a/internal/console/instancetoken_words.go
+++ b/internal/console/instancetoken_words.go
@@ -4,4 +4,5 @@ package console
// approved text replaces each value verbatim.
const (
instanceTokenDefaultLabel = "%s instance token"
+ selfPatchFieldsRefused = "an instance's own credential can change addrs and probe only. environment, steering and gate need a member of the app"
)