console: mint an instance token; delete revokes it; credential surfaces name the host (#155)
POST …/instances/{host}/token, people only, binds the token to the
record's CreatedAt. Delete revokes the host's tokens after the record is
gone, best effort (spec §3.4 rev 4 amended: revoke-first refused a
claim-mode delete retry; the incarnation check is the guarantee).
Instance rows take the app-token branch of the personal revoke, so a
former member cannot revoke them. The services API, the account pane,
the personal settings page and the activity feed name the host. The
PATCH Self refusal moves into instancetoken.go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 files changed,
+354
−17
docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md+7 −5internal/console/apiinstances.go+3 −6internal/console/apiservices.go+7 −0internal/console/authcli.go+10 −2internal/console/claim_invariant_fixtures_test.go+10 −0internal/console/instancetoken.go+108 −0internal/console/instancetoken_mint_test.go+194 −0internal/console/instancetoken_words.go+4 −0internal/console/server.go+4 −0internal/console/svcroutes_test.go+4 −2internal/console/templates/account_settings.html+1 −1internal/console/templates/settings.html+1 −1internal/console/testdata/claim-routes.txt+1 −0
diff --git a/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md b/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md| index c2ab4cc6e..1931abedd 100644 |
| --- a/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md |
| +++ b/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md |
| @@ -137,10 +137,12 @@ A token is bound to one incarnation of its host: |
| `provision.CreateRecord` and of `hostops.PrepareInstance`; a new caller |
| fails the test until it is reviewed against this rule and added. |
| - `handleAPIInstanceDelete` revokes every live `kind='instance'` row for |
| - (account, app, host) **before** deleting the record. If the revoke |
| - fails, the delete answers 500 and deletes nothing. A delete that fails |
| - after the revoke leaves a live instance with no token, which a member |
| - fixes by minting again. |
| + (account, app, host) **after** the record is gone, best effort: a failed |
| + revoke is logged and the delete still answers 200. (Rev 4 said "before, |
| + and a failed revoke deletes nothing". Built that way, it refused a claim-mode |
| + delete retry whose app is under the delete's own exclusive operation, |
| + `TestClaimDeleteRetryWhileParkingIsNotDone`. The incarnation check above is |
| + the guarantee, so the revoke cannot be a precondition.) |
| - Purge (`claim_app_identity.go:182`, `store.go:1736`) deletes instance |
| rows with the app's other credentials: its `kind IN (…)` list gains |
| `cliKindInstance`. Trash does not touch them, as it does not touch app |
| @@ -408,7 +410,7 @@ docs page section are copy review. |
| later rename or move by the new console is not refused. |
| - Every test that writes `cli_tokens` runs against `OpenGated` with |
| `HostClaim` on as well as the plain store. |
| -- Delete revokes; a revoke failure aborts the delete. |
| +- Delete revokes; a revoke failure still deletes, and the surviving token answers 404 once the host is created again. |
| - Purge, rename and account-move delete or carry instance rows the way |
| they do app rows. |
| - Audit keys are per host and never collapse onto the `app:` key. |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 80649af71..b3b780068 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -1969,12 +1969,7 @@ 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) |
| + if refuseSelfPatchFields(w, p, req.Environment != nil || req.Steering != nil || req.Gate != nil) { |
| return |
| } |
| if req.Addrs == nil && req.Probe == nil && req.Steering == nil && req.Environment == nil && req.Gate == nil { |
| @@ -2225,6 +2220,7 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request) |
| } |
| if answered, finished := s.refuseImportDelete(w, r, acct, app, host); answered { |
| if finished { |
| + s.revokeDeletedInstanceTokens(ctx, acct, app, host) |
| s.auditActivity(acct, app, principal, Activity{ |
| Action: "instance-delete", TargetType: "instance", Target: host, Message: message, |
| Detail: "deleted instance " + host + " (cli)", |
| @@ -2251,6 +2247,7 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusBadGateway, "could not delete the instance — try again") |
| return |
| } |
| + s.revokeDeletedInstanceTokens(ctx, acct, app, host) |
| // DNS seam (spec §10): a multi-region deployment retires the instance's |
| // CNAME here, after the record is gone. |
| // |
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go| index 7f23b4a6a..9ff2860e9 100644 |
| --- a/internal/console/apiservices.go |
| +++ b/internal/console/apiservices.go |
| @@ -63,6 +63,9 @@ type serviceView struct { |
| // rendered rather than omitted so "bound to nothing" and "bound to an |
| // app whose name did not survive the wire" cannot be confused. |
| App string `json:"app"` |
| + // Host is an instance token's instance (#155); omitted for every other |
| + // row. |
| + Host string `json:"host,omitempty"` |
| // Rungs is a deploy credential's ladder bound, canonical order. An |
| // empty array, never null, for every other row. |
| Rungs []string `json:"rungs"` |
| @@ -314,6 +317,7 @@ func (s *Server) handleAPIServiceList(w http.ResponseWriter, r *http.Request) { |
| Label: t.Label, |
| Role: t.Role, |
| App: t.App, |
| + Host: t.Host, |
| Rungs: rungs, |
| Created: serviceStamp(t.CreatedAt), |
| Rotated: serviceStamp(t.RotatedAt), |
| @@ -449,6 +453,9 @@ func serviceWhat(h serviceHandle, row CLIToken, found bool) (subject, what strin |
| if row.Name != "" { |
| return row.Name, "service credential " + row.Name |
| } |
| + if row.IsInstance() { |
| + return row.App, fmt.Sprintf(instanceTokenRevokeWhat, row.Host, row.App) |
| + } |
| what = "app credential for " + row.App |
| if row.Label != "" { |
| what = "app credential " + row.Label + " for " + row.App |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index cbb002ef5..4803c5631 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -761,6 +761,10 @@ func (s *Server) registerCLIRoutes() { |
| // credential gets here from 401 to 403. Pinned by |
| // TestNoRoleReachesAMintFleetOrPolicyDoor and TestAdminIsRefusedAtAClosedDoor. |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/instance-token", svcrole.Set{}, s.handleAPIInstanceToken) |
| + // The instance-bound mint (#155, spec 2026-10-16-instance-bound-tokens |
| + // §5): the zero Set for the app-token mint's reason, a credential never |
| + // mints a credential. |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/token", svcrole.Set{}, s.handleAPIInstanceTokenMint) |
| // Door keys (#175 P7): person-only, the instance-token rule. A door key |
| // reaches every instance of the app through every box's edge-local door. |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyList) |
| @@ -1506,7 +1510,7 @@ func (s *Server) handleCLIApprove(w http.ResponseWriter, r *http.Request) { |
| func (s *Server) serviceAccountSqids(toks []CLIToken) map[int64]string { |
| out := map[int64]string{} |
| for _, t := range toks { |
| - if !t.IsService() || t.AccountID == 0 { |
| + if !(t.IsService() || t.IsInstance()) || t.AccountID == 0 { |
| continue |
| } |
| if _, done := out[t.AccountID]; done { |
| @@ -1636,7 +1640,11 @@ func (s *Server) handleCLITokenRevoke(w http.ResponseWriter, r *http.Request) { |
| "recorded in that account's activity. Nothing was revoked.") |
| http.Redirect(w, r, "/settings", http.StatusSeeOther) |
| return |
| - case svc.IsApp(): |
| + case svc.IsApp() || svc.IsInstance(): |
| + // An instance token takes this branch too (spec |
| + // 2026-10-16-instance-bound-tokens §3.5): current membership of its |
| + // account, then the same admitted delete. |
| + // |
| // Fails closed on a read error, the same direction as the classify |
| // above, and is refused before the passkey for the same reason. |
| role, rerr := s.store.Role(svc.AccountID, addr) |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 215960ec9..8ba604b05 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -1289,6 +1289,16 @@ var claimFixtures = map[string]claimFixture{ |
| }, |
| do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/web.go.tito.io/channel", `{"channel":"beta"}`), |
| }}, |
| + // The instance-bound mint (#155): its insert is admitted for the app, as |
| + // the app-token mint's is. |
| + "route POST /api/cli/apps/{account}/{app}/instances/{host}/token": {door: &claimDoor{ |
| + apps: claimRefs("bab/titogo"), |
| + setup: func(t *testing.T, cx *claimCase) { |
| + cx.instanceWorld(t) |
| + cx.instance(t, `{"host":"web.go.tito.io","region":"ie"}`) |
| + }, |
| + do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/web.go.tito.io/token", `{}`), |
| + }}, |
| // Seeded instances wait for the claim's protocol (#250, seeded-instances |
| // spec §3.4): every import door refuses on a host-claim console before |
| // it reads the record (claimUnsupported). |
diff --git a/internal/console/instancetoken.go b/internal/console/instancetoken.go| index 3b4e75122..863c20720 100644 |
| --- a/internal/console/instancetoken.go |
| +++ b/internal/console/instancetoken.go |
| @@ -4,7 +4,12 @@ import ( |
| "context" |
| "errors" |
| "fmt" |
| + "log" |
| + "net/http" |
| + "os" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| ) |
| // Instance-bound tokens: the minter, the revoker and the listing rule (spec |
| @@ -67,3 +72,106 @@ WHERE kind = ? AND account_id = ? AND app = ? AND host = ? AND revoked_at = 0`, |
| now.Unix(), cliKindInstance, accountID, app, host) |
| return err |
| } |
| + |
| +// InstanceScope is an instance token's scope as the account pane shows it. |
| +// A method because a template cannot format a Go const. |
| +func (t CLIToken) InstanceScope() string { return fmt.Sprintf(instanceTokenScope, t.App, t.Host) } |
| + |
| +// InstanceBadge is an instance token's note beside its label on a person's |
| +// own settings page, as "app token" is for an app token. |
| +func (t CLIToken) InstanceBadge() string { return fmt.Sprintf(instanceTokenBadge, t.Host) } |
| + |
| +// handleAPIInstanceTokenMint is POST …/instances/{host}/token: it mints a |
| +// credential bound to one incarnation of one instance (spec §5). People |
| +// only, for the app-token mint's reason: a credential never mints a |
| +// credential. The zero Set at cliRoute is the outer wall, apiPerson the |
| +// inner one, exactly as at handleAPIInstanceToken. |
| +func (s *Server) handleAPIInstanceTokenMint(w http.ResponseWriter, r *http.Request) { |
| + if !s.apiPerson(w, r) { |
| + return |
| + } |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + jsonError(w, http.StatusServiceUnavailable, mainGateCopy().noBucket) |
| + return |
| + } |
| + host := r.PathValue("host") |
| + if !provision.ValidHostShape(host) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + var req struct { |
| + Label string `json:"label"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if err != nil { |
| + log.Printf("console: api instance token %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the instance") |
| + return |
| + } |
| + // The incarnation is the record's CreatedAt (spec §3.4); a record |
| + // without one has nothing to bind to. |
| + if rec.CreatedAt.IsZero() { |
| + jsonError(w, http.StatusConflict, fmt.Sprintf(instanceTokenNoBirth, host)) |
| + return |
| + } |
| + raw, err := s.store.NewInstanceTokenContext(r.Context(), acct.ID, app, host, rec.CreatedAt.UnixNano(), addr, cleanLabel(req.Label), time.Now()) |
| + if err != nil { |
| + log.Printf("console: api instance token %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusInternalServerError, "could not mint the token — try again") |
| + return |
| + } |
| + s.audit(acct, app, addr, "instance-token", "minted an instance token for "+host+" (cli)") |
| + // Shown exactly once: only the hash is stored. |
| + writeJSON(w, http.StatusCreated, map[string]any{"token": raw, "account": acct.Sqid, "app": app, "host": host}) |
| +} |
| + |
| +// revokeInstanceTokensFor is the delete door's revoke, through the test seam |
| +// when one is set. |
| +func (s *Server) revokeInstanceTokensFor(ctx context.Context, accountID int64, app, host string, now time.Time) error { |
| + if s.revokeInstanceTokens != nil { |
| + return s.revokeInstanceTokens(ctx, accountID, app, host, now) |
| + } |
| + return s.store.RevokeInstanceTokensContext(ctx, accountID, app, host, now) |
| +} |
| + |
| +// revokeDeletedInstanceTokens revokes host's instance tokens once its record |
| +// is gone (spec §3.4). Best effort, and after the delete rather than before |
| +// it: the incarnation check is the guarantee, so a token this misses can |
| +// only answer 404, while a revoke made a precondition would refuse a delete |
| +// retry whose app is under its own exclusive operation (claim mode). |
| +func (s *Server) revokeDeletedInstanceTokens(ctx context.Context, acct *Account, app, host string) { |
| + if err := s.revokeInstanceTokensFor(ctx, acct.ID, app, host, time.Now()); err != nil { |
| + log.Printf("console: api instances delete %s/%s/%s: revoke instance tokens (they answer 404 regardless): %v", acct.Sqid, app, host, err) |
| + } |
| +} |
| + |
| +// refuseSelfPatchFields is the PATCH door's Self rule (spec §4.4.1): an |
| +// instance's own credential may repoint its upstreams and probe, nothing |
| +// else, because environment alone would bind it to a sibling's bundle and |
| +// its secrets. It answers before any read or write. others reports whether |
| +// the request names environment, steering or gate. |
| +func refuseSelfPatchFields(w http.ResponseWriter, p Principal, others bool) bool { |
| + if p.Instance == "" || !others { |
| + return false |
| + } |
| + jsonError(w, http.StatusForbidden, selfPatchFieldsRefused) |
| + return true |
| +} |
diff --git a/internal/console/instancetoken_mint_test.go b/internal/console/instancetoken_mint_test.go| new file mode 100644 |
| index 000000000..0b47c7981 |
| --- /dev/null |
| +++ b/internal/console/instancetoken_mint_test.go |
| @@ -0,0 +1,194 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// The mint door, the revoke on delete and the credential surfaces (spec |
| +// 2026-10-16-instance-bound-tokens §3.4, §3.5, §5). |
| + |
| +func mintURL(w *selfWorld, host string) string { |
| + return instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + host + "/token" |
| +} |
| + |
| +func TestInstanceTokenMint(t *testing.T) { |
| + w := newSelfWorld(t) |
| + st, _, body := rawReq(t, http.MethodPost, mintURL(w, selfHostB), w.member, `{"label":"b's own"}`) |
| + if st != http.StatusCreated { |
| + t.Fatalf("mint = %d %s", st, body) |
| + } |
| + var out struct{ Token, Account, App, Host string } |
| + if err := json.Unmarshal([]byte(body), &out); err != nil { |
| + t.Fatal(err) |
| + } |
| + if out.Account != w.fx.sqid || out.App != "titogo" || out.Host != selfHostB || out.Token == "" { |
| + t.Fatalf("mint answer = %+v", out) |
| + } |
| + p, ok := w.fx.st.CLIPrincipal(out.Token, time.Now()) |
| + if !ok || p.Role != svcrole.Self || p.Instance != selfHostB || p.InstanceBorn != w.b.CreatedAt.UnixNano() { |
| + t.Fatalf("minted principal = %+v, %v (record born %d)", p, ok, w.b.CreatedAt.UnixNano()) |
| + } |
| + var n int |
| + if err := w.fx.st.db.QueryRow(`SELECT COUNT(*) FROM audit WHERE action = 'instance-token' AND detail LIKE ?`, |
| + "%"+selfHostB+"%").Scan(&n); err != nil || n != 1 { |
| + t.Fatalf("instance-token audit rows naming the host = %d, %v", n, err) |
| + } |
| + // The token works on its own record and nowhere else. |
| + if st, _, body := rawReq(t, http.MethodGet, instancesURL(w.fx, w.fx.sqid, "titogo")+"/"+selfHostB, out.Token, ""); st != http.StatusOK { |
| + t.Fatalf("minted token on its own record = %d %s", st, body) |
| + } |
| +} |
| + |
| +func TestInstanceTokenMintRefusals(t *testing.T) { |
| + w := newSelfWorld(t) |
| + want := canonicalNotFound(t) |
| + st, h, body := rawReq(t, http.MethodPost, mintURL(w, "c.example.com"), w.member, `{}`) |
| + assertNotFound(t, "mint for a host with no record", want, st, h, body) |
| + |
| + // A record with no creation time cannot be bound to. |
| + bare := w.a |
| + bare.Host, bare.Slug, bare.CreatedAt, bare.RecordSeq = "bare.example.com", "bare", time.Time{}, 0 |
| + raw, err := json.Marshal(bare) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := provision.WriteRecord(context.Background(), w.fx.rel, |
| + provision.RecordKey(release.OpsAccount, "titogo", bare.Host), []byte(`{}`), raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, _, body := rawReq(t, http.MethodPost, mintURL(w, bare.Host), w.member, `{}`); st != http.StatusConflict || |
| + !strings.Contains(body, strings.SplitN(instanceTokenNoBirth, "%s", 2)[1][:20]) { |
| + t.Fatalf("mint for a record with no creation time = %d %s", st, body) |
| + } |
| + |
| + // No service credential mints one, whatever its role, its own included. |
| + for _, role := range []svcrole.Role{svcrole.Admin, svcrole.Instance, svcrole.Publish} { |
| + tok := serviceToken(t, w.fx.st, OpsAccountID, "m-"+string(role), "titogo", role) |
| + if st, _, body := rawReq(t, http.MethodPost, mintURL(w, selfHostA), tok, `{}`); st != http.StatusForbidden { |
| + t.Errorf("mint as %s = %d %s, want the closed-door 403", role, st, body) |
| + } |
| + } |
| + if st, _, body := rawReq(t, http.MethodPost, mintURL(w, selfHostA), w.tok, `{}`); st != http.StatusForbidden { |
| + t.Errorf("mint with an instance token = %d %s, want the closed-door 403", st, body) |
| + } |
| + // A person with no membership meets the family's 404. |
| + stranger := cliToken(t, w.fx, "mallory@keymail.dev", "code-mint-stranger") |
| + st, h, body = rawReq(t, http.MethodPost, mintURL(w, selfHostA), stranger, `{}`) |
| + if st != http.StatusNotFound { |
| + t.Errorf("a stranger's mint = %d %s, want 404", st, body) |
| + } |
| + _ = h |
| +} |
| + |
| +func TestDeleteRevokesInstanceTokens(t *testing.T) { |
| + w := newSelfWorld(t) |
| + base := instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + selfHostA |
| + if st, _, body := rawReq(t, http.MethodDelete, base, w.member, ""); st != http.StatusOK { |
| + t.Fatalf("delete = %d %s", st, body) |
| + } |
| + if _, ok := w.fx.st.CLIPrincipal(w.tok, time.Now()); ok { |
| + t.Fatal("deleting the instance left its token live") |
| + } |
| +} |
| + |
| +// The revoke is best effort after the delete (spec §3.4): when it fails the |
| +// delete still lands, and the surviving token can only answer 404, even once |
| +// the host is created again. |
| +func TestDeleteSurvivesAFailedRevoke(t *testing.T) { |
| + w := newSelfWorld(t) |
| + w.fx.srv.revokeInstanceTokens = func(context.Context, int64, string, string, time.Time) error { |
| + return errors.New("database is locked") |
| + } |
| + base := instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + selfHostA |
| + if st, _, body := rawReq(t, http.MethodDelete, base, w.member, ""); st != http.StatusOK { |
| + t.Fatalf("delete with a failing revoke = %d %s, want 200", st, body) |
| + } |
| + if _, ok := w.fx.st.CLIPrincipal(w.tok, time.Now()); !ok { |
| + t.Fatal("setup: the failed revoke should have left the token live") |
| + } |
| + time.Sleep(time.Millisecond) |
| + w.create(t, selfHostA, "service") |
| + st, h, body := rawReq(t, http.MethodGet, base, w.tok, "") |
| + assertNotFound(t, "the unrevoked token on the re-created host", canonicalNotFound(t), st, h, body) |
| +} |
| + |
| +func TestInstanceTokensOnTheCredentialSurfaces(t *testing.T) { |
| + w := newSelfWorld(t) |
| + // The services API names the role and the host. |
| + owner := cliToken(t, w.fx, "paul@keymail.dev", "code-surfaces-owner") |
| + st, _, body := rawReq(t, http.MethodGet, w.fx.ts.URL+"/api/cli/services/"+w.fx.sqid, owner, "") |
| + if st != http.StatusOK || !strings.Contains(body, `"role":"self"`) || !strings.Contains(body, `"host":"`+selfHostA+`"`) { |
| + t.Fatalf("services list = %d %s", st, body) |
| + } |
| + // serviceWhat names the host for the activity feed. |
| + rows, err := w.fx.st.ServicePrincipals(OpsAccountID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, r := range rows { |
| + if r.IsInstance() { |
| + if _, what := serviceWhat(serviceHandle{id: r.ID}, r, true); !strings.Contains(what, selfHostA) { |
| + t.Fatalf("serviceWhat = %q, want the host named", what) |
| + } |
| + if !strings.Contains(r.InstanceScope(), selfHostA) || !strings.Contains(r.InstanceBadge(), selfHostA) { |
| + t.Fatalf("row labels %q / %q do not name the host", r.InstanceScope(), r.InstanceBadge()) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// A minting member removed from the account cannot revoke it from their own |
| +// settings; a current member can (spec §3.5, codex 4). |
| +func TestPersonalSettingsInstanceTokenRevoke(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + otherID := testAccountID() |
| + if err := fx.st.CreateAccount(otherID, accountSqid(otherID), "Sam's", "sam@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(otherID, "paul@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(otherID, "kass", "sam@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + secret, err := fx.st.NewInstanceTokenContext(context.Background(), otherID, "kass", "a.example.com", testBorn, "paul@keymail.dev", "", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + toks, err := fx.st.CLITokens("paul@keymail.dev") |
| + if err != nil || len(toks) != 1 { |
| + t.Fatalf("CLITokens = %d rows %v, want the instance token", len(toks), err) |
| + } |
| + id := toks[0].ID |
| + c := signInPaul(t, fx) |
| + _, _, page := get(t, c, fx.ts.URL+"/settings") |
| + if !strings.Contains(page, toks[0].InstanceBadge()) { |
| + t.Fatalf("/settings does not label the instance token:\n%s", page) |
| + } |
| + if err := fx.st.RemoveMember(otherID, "paul@keymail.dev"); err != nil { |
| + t.Fatal(err) |
| + } |
| + csrf := sessionToken(t, fx, c) |
| + status, loc, body := postForm(t, c, fx.ts.URL+"/auth/cli/tokens/revoke", url.Values{"csrf": {csrf}, "id": {id}}) |
| + if status != http.StatusSeeOther || loc != "/settings" { |
| + t.Fatalf("revoke after removal = %d -> %q:\n%s", status, loc, body) |
| + } |
| + if _, ok := fx.st.CLIPrincipal(secret, time.Now()); !ok { |
| + t.Fatal("a removed member revoked the account's instance token") |
| + } |
| + _, _, page = get(t, c, fx.ts.URL+"/settings") |
| + if !strings.Contains(page, "no longer a member") { |
| + t.Fatalf("/settings does not say why the revoke was refused:\n%s", page) |
| + } |
| +} |
diff --git a/internal/console/instancetoken_words.go b/internal/console/instancetoken_words.go| index a2896e31e..6a8378c16 100644 |
| --- a/internal/console/instancetoken_words.go |
| +++ b/internal/console/instancetoken_words.go |
| @@ -5,4 +5,8 @@ package console |
| 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" |
| + instanceTokenNoBirth = "%s has no creation time on its record, so a token cannot be bound to it. Delete and create it again" |
| + instanceTokenRevokeWhat = "instance credential for %s in %s" |
| + instanceTokenScope = "app %s, instance %s" |
| + instanceTokenBadge = "instance token · %s" |
| ) |
diff --git a/internal/console/server.go b/internal/console/server.go| index 5f89d5076..635048816 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -853,6 +853,10 @@ type Server struct { |
| // it is made (a TEST SEAM: a release that fails after its log entry is |
| // queued, movesrecovery.go). |
| moveReleaseFault func() error |
| + // revokeInstanceTokens, when set, replaces the delete door's revoke of |
| + // the host's instance tokens (a TEST SEAM: a revoke that fails leaves a |
| + // token that can only 404, instancetoken.go). |
| + revokeInstanceTokens func(ctx context.Context, accountID int64, app, host string, now time.Time) error |
| // moveLogSettleHook, when set, runs as the drainer settles an |
| // unconfirmed declared_lost row, after it read the claim (a TEST SEAM: |
| // a declaration that commits in between, movesrecovery.go). |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index e59f12074..aeac4059d 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -195,6 +195,8 @@ 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", |
| + // The instance-bound mint (#155): a credential never mints a credential. |
| + "POST /api/cli/apps/{account}/{app}/instances/{host}/token", |
| // 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 |
| @@ -957,8 +959,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - 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)", |
| + if seen != len(closedDoors) || seen != 58 { |
| + t.Errorf("walked %d closed doors, want all %d (and 58 today: instance-token, the instance-bound mint, 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/templates/account_settings.html b/internal/console/templates/account_settings.html| index 921d193de..1346c68f3 100644 |
| --- a/internal/console/templates/account_settings.html |
| +++ b/internal/console/templates/account_settings.html |
| @@ -109,7 +109,7 @@ |
| <tr> |
| <td>{{if .Name}}{{.Name}}{{else}}<span class="small">(unnamed)</span>{{end}}</td> |
| <td>{{.Role}}</td> |
| - <td>{{if .App}}app {{.App}}{{else}}account-wide{{end}}</td> |
| + <td>{{if .IsInstance}}{{.InstanceScope}}{{else if .App}}app {{.App}}{{else}}account-wide{{end}}</td> |
| <td>{{emptyOrDash .RungList}}</td> |
| <td>{{emptyOrDash .Label}}</td> |
| <td>{{if .RotatedAt.IsZero}}—{{else}}{{fmtTime .RotatedAt}}{{end}}</td> |
diff --git a/internal/console/templates/settings.html b/internal/console/templates/settings.html| index 396ec8d20..78ea00cb1 100644 |
| --- a/internal/console/templates/settings.html |
| +++ b/internal/console/templates/settings.html |
| @@ -14,7 +14,7 @@ |
| <tr><th>Terminal</th><th>From</th><th>Signed in</th><th>Last used</th><th>Expires</th><th></th></tr> |
| {{range .Tokens}} |
| <tr> |
| - <td>{{if .IsService}}{{.Name}} <span class="small">(service credential{{if .App}} · {{.App}}{{end}})</span>{{else}}{{emptyOrDash .Label}}{{if .IsApp}} <span class="small">(app token{{if .App}} · {{.App}}{{end}})</span>{{end}}{{end}}</td> |
| + <td>{{if .IsService}}{{.Name}} <span class="small">(service credential{{if .App}} · {{.App}}{{end}})</span>{{else}}{{emptyOrDash .Label}}{{if .IsApp}} <span class="small">(app token{{if .App}} · {{.App}}{{end}})</span>{{else if .IsInstance}} <span class="small">({{.InstanceBadge}})</span>{{end}}{{end}}</td> |
| <td>{{emptyOrDash .RemoteAddr}}</td> |
| <td>{{fmtTime .CreatedAt}}</td> |
| <td>{{if .LastUsed.IsZero}}—{{else}}{{fmtTime .LastUsed}}{{end}}</td> |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index fc667b937..b19e47dcd 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -178,6 +178,7 @@ route POST /api/cli/apps/{account}/{app}/features |
| route POST /api/cli/apps/{account}/{app}/instance-token |
| route POST /api/cli/apps/{account}/{app}/instances |
| route POST /api/cli/apps/{account}/{app}/instances/{host}/channel |
| +route POST /api/cli/apps/{account}/{app}/instances/{host}/token |
| route POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort |
| route POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit |
| route POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped |