console: the move-capable instance token: a moves column, Principal.Moves and TokenID, an owner-only mint
cli_tokens gains moves (default 0, idempotent ALTER). NewAppToken and
NewAppTokenContext take moves; every existing caller passes false. Both
listings and CLIPrincipal read it (Moves for kind='app' rows only), and
Principal.TokenID is the stored token_hash. The mint door takes
{"label","moves"}; moves needs RoleOwner, else 403, and its audit line names
the token id. The tokens page marks a move-capable app token.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
23 files changed,
+292
−65
internal/console/apidb_test.go+1 −1internal/console/apidomains_test.go+5 −5internal/console/apidoorkeys_test.go+2 −2internal/console/apiinstances.go+14 −2internal/console/apiinstances_test.go+4 −4internal/console/apiinstancesenable_test.go+2 −2internal/console/apiroutes_test.go+1 −1internal/console/apiservices_test.go+3 −3internal/console/apisidecars_test.go+3 −3internal/console/authstore.go+32 −12internal/console/authstore_test.go+5 −5internal/console/channelmove_test.go+1 −1internal/console/claim_invariant_fixtures_test.go+1 −1internal/console/claim_invariant_probes_test.go+1 −1internal/console/claim_invariant_test.go+1 −1internal/console/domaincatchall_test.go+1 −1internal/console/domainplatform_test.go+1 −1internal/console/instancetoken_moves_test.go+185 −0internal/console/svcpage_test.go+4 −4internal/console/svcprincipal.go+15 −5internal/console/svcprincipal_test.go+5 −5internal/console/svcscope_test.go+4 −4internal/console/templates/settings.html+1 −1
diff --git a/internal/console/apidb_test.go b/internal/console/apidb_test.go| index bf7501a20..cbc07f785 100644 |
| --- a/internal/console/apidb_test.go |
| +++ b/internal/console/apidb_test.go |
| @@ -358,7 +358,7 @@ func TestAPIDBStrangersMeetTheByteIdentical404(t *testing.T) { |
| func TestAPIDBRefusesEveryServicePrincipalTheSameWay(t *testing.T) { |
| fx := dbFixture(t) |
| fx.replicate(t, dbTestHost, 1) |
| - appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| index a8088c186..cb6a512ce 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -174,11 +174,11 @@ func TestAPIDomainAttachAppToken(t *testing.T) { |
| if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil { |
| t.Fatal(err) |
| } |
| - mine, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now) |
| + mine, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - foreign, err := fx.st.NewAppToken(OpsAccountID, "other", "alice@keymail.dev", "signup", now) |
| + foreign, err := fx.st.NewAppToken(OpsAccountID, "other", "alice@keymail.dev", "signup", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -932,7 +932,7 @@ func TestAttachWildcardCNAMEGate(t *testing.T) { |
| if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil { |
| t.Fatal(err) |
| } |
| - foreign, err := fx.st.NewAppToken(OpsAccountID, "other", "alice@keymail.dev", "signup", now) |
| + foreign, err := fx.st.NewAppToken(OpsAccountID, "other", "alice@keymail.dev", "signup", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -1381,11 +1381,11 @@ func TestLegacyAppTokensHaveSeparateWildFailBudgets(t *testing.T) { |
| if _, err := fx.st.CreateAppIn(OpsAccountID, "widgets", "paul@keymail.dev", now); err != nil { |
| t.Fatal(err) |
| } |
| - tokTitogo, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router-titogo", now) |
| + tokTitogo, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router-titogo", false, now) |
| if err != nil { |
| t.Fatalf("NewAppToken(titogo): %v", err) |
| } |
| - tokWidgets, err := fx.st.NewAppToken(OpsAccountID, "widgets", "paul@keymail.dev", "router-widgets", now) |
| + tokWidgets, err := fx.st.NewAppToken(OpsAccountID, "widgets", "paul@keymail.dev", "router-widgets", false, now) |
| if err != nil { |
| t.Fatalf("NewAppToken(widgets): %v", err) |
| } |
diff --git a/internal/console/apidoorkeys_test.go b/internal/console/apidoorkeys_test.go| index 0e224b2fd..db836d10a 100644 |
| --- a/internal/console/apidoorkeys_test.go |
| +++ b/internal/console/apidoorkeys_test.go |
| @@ -456,7 +456,7 @@ func TestPurgeAppRowLeavesNoCredentialsForTheName(t *testing.T) { |
| if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@keymail.dev", now); err != nil { |
| t.Fatal(err) |
| } |
| - appTok, err := s.NewAppToken(acct.ID, "titogo", "paul@keymail.dev", "router", now) |
| + appTok, err := s.NewAppToken(acct.ID, "titogo", "paul@keymail.dev", "router", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -524,7 +524,7 @@ func TestPurgeAppRowKeepsALiveAppsCredentials(t *testing.T) { |
| if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@keymail.dev", now); err != nil { |
| t.Fatal(err) |
| } |
| - appTok, err := s.NewAppToken(acct.ID, "titogo", "paul@keymail.dev", "router", now) |
| + appTok, err := s.NewAppToken(acct.ID, "titogo", "paul@keymail.dev", "router", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 52eea7e84..64ffce062 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -2360,10 +2360,18 @@ func (s *Server) handleAPIInstanceToken(w http.ResponseWriter, r *http.Request) |
| } |
| var req struct { |
| Label string `json:"label"` |
| + // Moves mints a move-capable token (self-serve moves, spec §5): the |
| + // credential the self-serve move doors require. Owner only. When |
| + // #155's instance-bound tokens arrive, moves is refused on them here. |
| + Moves bool `json:"moves"` |
| } |
| if !readJSON(w, r, &req) { |
| return |
| } |
| + if req.Moves && acct.Role != RoleOwner { |
| + jsonError(w, http.StatusForbidden, "only an account owner can mint a move-capable instance token") |
| + return |
| + } |
| // Same de-fanging every terminal label gets: this string is rendered on |
| // the minter's tokens page, where it is the one field a human reads to |
| // decide whether to revoke. |
| @@ -2371,13 +2379,17 @@ func (s *Server) handleAPIInstanceToken(w http.ResponseWriter, r *http.Request) |
| if label == "" { |
| label = app + " instance token" |
| } |
| - raw, err := s.store.NewAppTokenContext(r.Context(), acct.ID, app, addr, label, time.Now()) |
| + raw, err := s.store.NewAppTokenContext(r.Context(), acct.ID, app, addr, label, req.Moves, time.Now()) |
| if err != nil { |
| log.Printf("console: api instance token %s/%s: %v", acct.Sqid, app, 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 "+app+" (cli)") |
| + if req.Moves { |
| + s.audit(acct, app, addr, "instance-token", "minted a move-capable instance token for "+app+" (cli), token "+hashToken(raw)) |
| + } else { |
| + s.audit(acct, app, addr, "instance-token", "minted an instance token for "+app+" (cli)") |
| + } |
| // Shown exactly once: only the hash is stored, so there is no second |
| // copy to hand out and no way to recover this one. Delivery to the app |
| // is the config-env layer, like any other app secret — |
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go| index 7bd168219..c43f23f88 100644 |
| --- a/internal/console/apiinstances_test.go |
| +++ b/internal/console/apiinstances_test.go |
| @@ -150,7 +150,7 @@ func TestAPIInstanceRefusalMatrix(t *testing.T) { |
| t.Fatal(err) |
| } |
| // An app token bound to (bab, titogo) — the honest binding. |
| - appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -221,7 +221,7 @@ func TestAPIInstanceNoToken(t *testing.T) { |
| func TestAPIInstanceAppTokenContainment(t *testing.T) { |
| fx, _ := instanceFixture(t, "code-inst-contain") |
| enableInstances(t, fx, "titogo", titoManifest()) |
| - appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -1255,7 +1255,7 @@ func TestAPIInstanceUpdateRefusalMatrix(t *testing.T) { |
| if _, err := fx.st.CreateAppIn(42, "titogo", "bob@keymail.dev", now); err != nil { |
| t.Fatal(err) |
| } |
| - appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -2800,7 +2800,7 @@ func TestAPIInstanceUpstreamsAreOperatorOnly(t *testing.T) { |
| } |
| // A service credential is not a person, operator or not. |
| - appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "ci", time.Now()) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "ci", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/apiinstancesenable_test.go b/internal/console/apiinstancesenable_test.go| index 2a9c6857a..97cc987cd 100644 |
| --- a/internal/console/apiinstancesenable_test.go |
| +++ b/internal/console/apiinstancesenable_test.go |
| @@ -198,7 +198,7 @@ func TestAPIInstanceCreateSiblingApexRefused(t *testing.T) { |
| // The app token meets the same wall: it authorizes titogo's records, |
| // and a sibling's apex is not one of them. |
| - appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -264,7 +264,7 @@ func TestAPIInstanceEnableRefusals(t *testing.T) { |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| want := canonicalNotFound(t) |
| eve := cliToken(t, fx, "eve@keymail.dev", "code-inst-enable-eve") |
| - appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/apiroutes_test.go b/internal/console/apiroutes_test.go| index 4c8740738..9dd4f1e96 100644 |
| --- a/internal/console/apiroutes_test.go |
| +++ b/internal/console/apiroutes_test.go |
| @@ -280,7 +280,7 @@ func TestAPIRoutesSaysSoWhenTheConsoleServesNoRegistry(t *testing.T) { |
| // and the instance family's. |
| func TestAPIRoutesAppTokenIsRefused(t *testing.T) { |
| fx, _ := routesFixture(t, "code-routes-apptoken") |
| - appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "test", time.Now()) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "test", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/apiservices_test.go b/internal/console/apiservices_test.go| index eadb5b618..49317bcef 100644 |
| --- a/internal/console/apiservices_test.go |
| +++ b/internal/console/apiservices_test.go |
| @@ -319,7 +319,7 @@ func sortedStrings(s []string) bool { |
| func TestLegacyAppTokensAppearInTheAccountListing(t *testing.T) { |
| fx := newConsoleFixture(t) |
| owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-legacy") |
| - if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now()); err != nil { |
| + if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", false, time.Now()); err != nil { |
| t.Fatalf("NewAppToken: %v", err) |
| } |
| code, body := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, owner) |
| @@ -446,7 +446,7 @@ func TestRotateAnUnknownNameIs404(t *testing.T) { |
| // A legacy app token has no name, so an empty-ish name must not reach |
| // it: rotating "the nameless row" would hand out one secret while |
| // invalidating several credentials. |
| - if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now()); err != nil { |
| + if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", false, time.Now()); err != nil { |
| t.Fatal(err) |
| } |
| if code, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid+"/%20/rotate", |
| @@ -688,7 +688,7 @@ func TestUnusableRevokeHandleIsAudited(t *testing.T) { |
| func TestRevokeAddressesARowByItsListingID(t *testing.T) { |
| fx := newConsoleFixture(t) |
| owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-id") |
| - legacy, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now()) |
| + legacy, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/apisidecars_test.go b/internal/console/apisidecars_test.go| index 62ff24636..0d36559b7 100644 |
| --- a/internal/console/apisidecars_test.go |
| +++ b/internal/console/apisidecars_test.go |
| @@ -85,7 +85,7 @@ func TestAPISidecarRefusalMatrix(t *testing.T) { |
| t.Fatal(err) |
| } |
| enableSidecars(t, fx, "sibling", titoSidecarManifest()) |
| - appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -179,7 +179,7 @@ func TestAPISidecarEnableRefusalMatrix(t *testing.T) { |
| fx, member := instanceFixture(t, "code-side-enable-ref") |
| want := canonicalNotFound(t) |
| eve := cliToken(t, fx, "eve@keymail.dev", "code-side-enable-eve") |
| - appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -233,7 +233,7 @@ func TestAPISidecarNoToken(t *testing.T) { |
| func TestAPISidecarAppTokenContainment(t *testing.T) { |
| fx, _ := instanceFixture(t, "code-side-contain") |
| enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| - appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/authstore.go b/internal/console/authstore.go| index 252792a48..a259eaba7 100644 |
| --- a/internal/console/authstore.go |
| +++ b/internal/console/authstore.go |
| @@ -126,6 +126,9 @@ type CLIToken struct { |
| // Rungs is a deploy credential's scope in its stored, canonical form |
| // ("canary,edge"); empty for every other kind and role. |
| Rungs string |
| + // Moves marks a move-capable instance token (spec §5): a kind='app' row |
| + // an account owner minted with moves. False for every other row. |
| + Moves bool |
| } |
| // RungList is Rungs for a person to read: "canary, edge". |
| @@ -222,6 +225,10 @@ func (s *Store) migrateCLIAuth() error { |
| `ALTER TABLE cli_tokens ADD COLUMN revoked_at INTEGER NOT NULL DEFAULT 0`, |
| `ALTER TABLE cli_tokens ADD COLUMN rotated_at INTEGER NOT NULL DEFAULT 0`, |
| `ALTER TABLE cli_tokens ADD COLUMN rungs TEXT NOT NULL DEFAULT ''`, |
| + // moves marks a move-capable instance token (self-serve moves, spec |
| + // §5): only NewAppToken writes 1, at an owner's mint. Every row |
| + // written before it reads 0, and nothing upgrades a row. |
| + `ALTER TABLE cli_tokens ADD COLUMN moves INTEGER NOT NULL DEFAULT 0`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -503,15 +510,25 @@ VALUES (?, ?, ?, ?, ?, ?)`, |
| // credential whose rotation is one `carlos env set` + bounce away. Revocation |
| // (the minting member's tokens page, where it lists under their address) is |
| // the control. |
| -func (s *Store) claimRawNewAppToken(accountID int64, app, mintedBy, label string, now time.Time) (string, error) { |
| +// |
| +// moves mints a move-capable token (self-serve moves, spec §5): the mint |
| +// door passes true only for an account owner, and every other caller passes |
| +// false. The token stays app-wide; #155's instance-bound tokens, when they |
| +// exist, are refused moves at the mint (a token bound to one instance moving |
| +// that instance is the member-minted shape M18 closed). |
| +func (s *Store) claimRawNewAppToken(accountID int64, app, mintedBy, label string, moves bool, now time.Time) (string, error) { |
| raw, err := randomToken() |
| if err != nil { |
| return "", err |
| } |
| + movesCol := 0 |
| + if moves { |
| + movesCol = 1 |
| + } |
| if _, err := s.db.Exec(`INSERT INTO cli_tokens |
| -(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app) |
| -VALUES (?, ?, ?, '', ?, 0, ?, ?, ?)`, |
| - hashToken(raw), mintedBy, label, now.Unix(), cliKindApp, accountID, app); err != nil { |
| +(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app, moves) |
| +VALUES (?, ?, ?, '', ?, 0, ?, ?, ?, ?)`, |
| + hashToken(raw), mintedBy, label, now.Unix(), cliKindApp, accountID, app, movesCol); err != nil { |
| return "", err |
| } |
| return raw, nil |
| @@ -558,7 +575,7 @@ func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) { |
| // scanCLITokenRows drains a `cli_tokens` listing query into CLIToken values. |
| // CLITokens (by address) and ServicePrincipals (by account) select the same |
| -// fifteen columns in the same order and need the same legacy-row |
| +// sixteen columns in the same order and need the same legacy-row |
| // normalization — a kind='app' row's `role` column is empty in the database but |
| // IS the instance role (spec §5). This is the one place that normalization |
| // happens, so neither caller, nor any reader downstream of them, has to |
| @@ -568,11 +585,14 @@ func scanCLITokenRows(rows *sql.Rows) ([]CLIToken, error) { |
| var out []CLIToken |
| for rows.Next() { |
| var t CLIToken |
| - var created, expires, used, revoked, rotated int64 |
| + var created, expires, used, revoked, rotated, moves int64 |
| if err := rows.Scan(&t.ID, &t.Address, &t.Label, &t.RemoteAddr, &t.Kind, &t.AccountID, &t.App, |
| - &t.Role, &t.Name, &created, &expires, &used, &revoked, &rotated, &t.Rungs); err != nil { |
| + &t.Role, &t.Name, &created, &expires, &used, &revoked, &rotated, &t.Rungs, &moves); err != nil { |
| return nil, err |
| } |
| + // Only a kind='app' row is ever minted move-capable; any other row |
| + // lists as not, whatever its column says. |
| + t.Moves = moves != 0 && t.Kind == cliKindApp |
| t.CreatedAt = time.Unix(created, 0).UTC() |
| t.ExpiresAt = time.Unix(expires, 0).UTC() |
| if used != 0 { |
| @@ -601,7 +621,7 @@ func scanCLITokenRows(rows *sql.Rows) ([]CLIToken, error) { |
| // keep that promise. |
| func (s *Store) CLITokens(address string) ([]CLIToken, error) { |
| rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, kind, account_id, app, |
| -role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs |
| +role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs, moves |
| FROM cli_tokens WHERE address = ? AND revoked_at = 0 ORDER BY created_at DESC, token_hash`, address) |
| if err != nil { |
| return nil, err |
| @@ -696,12 +716,12 @@ func (s *Store) claimRawRevokeCLITokenValue(hash string) error { |
| _, err := s.db.Exec(`DELETE FROM cli_tokens WHERE token_hash = ?`, hash) |
| return err |
| } |
| -func (s *Store) NewAppToken(accountID int64, app, mintedBy, label string, now time.Time) (claimResult0 string, claimResult1 error) { |
| - return s.NewAppTokenContext(context.Background(), accountID, app, mintedBy, label, now) |
| +func (s *Store) NewAppToken(accountID int64, app, mintedBy, label string, moves bool, now time.Time) (claimResult0 string, claimResult1 error) { |
| + return s.NewAppTokenContext(context.Background(), accountID, app, mintedBy, label, moves, now) |
| } |
| -func (s *Store) NewAppTokenContext(claimContext context.Context, accountID int64, app, mintedBy, label string, now time.Time) (claimResult0 string, claimResult1 error) { |
| +func (s *Store) NewAppTokenContext(claimContext context.Context, accountID int64, app, mintedBy, label string, moves bool, now time.Time) (claimResult0 string, claimResult1 error) { |
| claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.NewAppToken", func(claimView *Store) error { |
| - claimResult0, claimResult1 = claimView.claimRawNewAppToken(accountID, app, mintedBy, label, now) |
| + claimResult0, claimResult1 = claimView.claimRawNewAppToken(accountID, app, mintedBy, label, moves, now) |
| return claimResult1 |
| }) |
| return |
diff --git a/internal/console/authstore_test.go b/internal/console/authstore_test.go| index 68b083351..7cc17a92e 100644 |
| --- a/internal/console/authstore_test.go |
| +++ b/internal/console/authstore_test.go |
| @@ -243,7 +243,7 @@ func TestAppTokenContainment(t *testing.T) { |
| s := openTestStore(t) |
| now := time.Now() |
| - app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now) |
| + app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -307,7 +307,7 @@ VALUES (?, 'paul@keymail.dev', 'dated', '', ?, ?, 'app', 9, 'tito')`, |
| func TestAppTokenNeverExpires(t *testing.T) { |
| s := openTestStore(t) |
| now := time.Now() |
| - app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now) |
| + app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -336,7 +336,7 @@ func TestAppTokenNeverExpires(t *testing.T) { |
| func TestAppTokenRevocation(t *testing.T) { |
| s := openTestStore(t) |
| now := time.Now() |
| - app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now) |
| + app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -373,7 +373,7 @@ func TestCLITokenMigrationReopens(t *testing.T) { |
| t.Fatal(err) |
| } |
| user := mintUserToken(t, first, "paul@keymail.dev", now) |
| - app, err := first.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now) |
| + app, err := first.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -406,7 +406,7 @@ func TestCLITokensReportsKind(t *testing.T) { |
| s := openTestStore(t) |
| now := time.Now() |
| mintUserToken(t, s, "paul@keymail.dev", now) |
| - if _, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now); err != nil { |
| + if _, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", false, now); err != nil { |
| t.Fatal(err) |
| } |
| toks, err := s.CLITokens("paul@keymail.dev") |
diff --git a/internal/console/channelmove_test.go b/internal/console/channelmove_test.go| index f3165c061..1e3376d6d 100644 |
| --- a/internal/console/channelmove_test.go |
| +++ b/internal/console/channelmove_test.go |
| @@ -253,7 +253,7 @@ func TestAPIInstanceChannelRefusesAppToken(t *testing.T) { |
| fx, _ := instanceFixture(t, "code-chan-apptok") |
| const host = "web.go.tito.io" |
| recordOn(t, fx, "titogo", host, "edge") |
| - appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 99e4abf25..02ea1539d 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -127,7 +127,7 @@ func claimLegacyAppTokenRevokeDoor(path string) *claimDoor { |
| return &claimDoor{ |
| apps: claimRefs("bab/kass"), |
| setup: func(t *testing.T, cx *claimCase) { |
| - raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census legacy app token", time.Now()) |
| + raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census legacy app token", false, time.Now()) |
| if err != nil { |
| t.Fatalf("stage legacy app token: %v", err) |
| } |
diff --git a/internal/console/claim_invariant_probes_test.go b/internal/console/claim_invariant_probes_test.go| index 77914218c..0f8c5c1ca 100644 |
| --- a/internal/console/claim_invariant_probes_test.go |
| +++ b/internal/console/claim_invariant_probes_test.go |
| @@ -687,7 +687,7 @@ func TestClaimProbeRunnerDetectsAHollowRequest(t *testing.T) { |
| t.Run("an app token at the logout door", func(t *testing.T) { |
| // A bearer the door does not admit is a 401, and a 401 never counts. |
| cx := newClaimCase(t) |
| - raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census app token", time.Now()) |
| + raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census app token", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/claim_invariant_test.go b/internal/console/claim_invariant_test.go| index 9a32ada29..3703ca8e7 100644 |
| --- a/internal/console/claim_invariant_test.go |
| +++ b/internal/console/claim_invariant_test.go |
| @@ -1073,7 +1073,7 @@ func TestClaimConsoleMutationInvariantWitnesses(t *testing.T) { |
| // row that names an app is admitted for that app first. |
| t.Run("an app token revoked by its own value is admitted", func(t *testing.T) { |
| cx := newClaimCase(t) |
| - raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census app token", time.Now()) |
| + raw, err := cx.st.NewAppToken(OpsAccountID, "kass", claimFxOwner, "census app token", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/domaincatchall_test.go b/internal/console/domaincatchall_test.go| index b657a70a5..b2da334bc 100644 |
| --- a/internal/console/domaincatchall_test.go |
| +++ b/internal/console/domaincatchall_test.go |
| @@ -145,7 +145,7 @@ func TestInstanceCatchallRefusals(t *testing.T) { |
| // A person's act: the app's own credential cannot set or remove one. |
| func TestInstanceCatchallIsAPersonsAct(t *testing.T) { |
| fx, member := catchallFixture(t, "code-ca-person") |
| - tok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + tok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/domainplatform_test.go b/internal/console/domainplatform_test.go| index 9a31576cc..643dad7ff 100644 |
| --- a/internal/console/domainplatform_test.go |
| +++ b/internal/console/domainplatform_test.go |
| @@ -113,7 +113,7 @@ func TestPlatformAttachRefusesMembers(t *testing.T) { |
| // minter's address). |
| func TestPlatformAttachRefusesServices(t *testing.T) { |
| fx, _, _ := platformFixture(t, "code-plat-svc") |
| - tok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "signup", time.Now()) |
| + tok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "signup", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/instancetoken_moves_test.go b/internal/console/instancetoken_moves_test.go| new file mode 100644 |
| index 000000000..a956b1666 |
| --- /dev/null |
| +++ b/internal/console/instancetoken_moves_test.go |
| @@ -0,0 +1,185 @@ |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// The move-capable instance token (self-serve moves plan 2, Task 1; spec |
| +// §5): an app-wide instance token with moves=1, minted only by an account |
| +// owner through the existing person-only mint door. |
| + |
| +const msgMoveTokenNeedsOwner = "only an account owner can mint a move-capable instance token" |
| + |
| +// mintURL is shop's mint door on the fixture account. |
| +func mintURL(l lifetimesFx) string { |
| + return l.fx.ts.URL + "/api/cli/apps/bab/" + ltApp + "/instance-token" |
| +} |
| + |
| +// mintedToken decodes a 201 mint answer. |
| +func mintedToken(t *testing.T, body string) string { |
| + t.Helper() |
| + var m struct { |
| + Token string `json:"token"` |
| + } |
| + if err := json.Unmarshal([]byte(body), &m); err != nil || m.Token == "" { |
| + t.Fatalf("mint body %s: %v", body, err) |
| + } |
| + return m.Token |
| +} |
| + |
| +// appTokenListed is the one kind='app' row minted by addr for shop in the |
| +// minter's listing, and in the account's. |
| +func appTokenListed(t *testing.T, l lifetimesFx, addr string) (mine, account CLIToken) { |
| + t.Helper() |
| + toks, err := l.fx.st.CLITokens(addr) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + n := 0 |
| + for _, tk := range toks { |
| + if tk.Kind == cliKindApp && tk.App == ltApp { |
| + mine = tk |
| + n++ |
| + } |
| + } |
| + if n != 1 { |
| + t.Fatalf("%s lists %d app tokens for shop, want 1: %+v", addr, n, toks) |
| + } |
| + svcs, err := l.fx.st.ServicePrincipals(OpsAccountID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, tk := range svcs { |
| + if tk.ID == mine.ID { |
| + return mine, tk |
| + } |
| + } |
| + t.Fatalf("the account's listing lacks the token %s: %+v", mine.ID, svcs) |
| + return |
| +} |
| + |
| +// instanceTokenAudits are shop's instance-token audit details, newest first. |
| +func instanceTokenAudits(t *testing.T, l lifetimesFx) []Event { |
| + t.Helper() |
| + events, err := l.fx.st.Events(OpsAccountID, ltApp, 50) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out []Event |
| + for _, e := range events { |
| + if e.Action == "instance-token" { |
| + out = append(out, e) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func TestOwnerMintsAMoveCapableToken(t *testing.T) { |
| + l := newLifetimesFx(t) |
| + code, _, body := rawReq(t, http.MethodPost, mintURL(l), l.operator, `{"label":"router","moves":true}`) |
| + if code != http.StatusCreated { |
| + t.Fatalf("the owner's move-capable mint = %d %s, want 201", code, body) |
| + } |
| + raw := mintedToken(t, body) |
| + mine, account := appTokenListed(t, l, "paul@keymail.dev") |
| + if !mine.Moves || !account.Moves { |
| + t.Fatalf("listings: minter's %+v, account's %+v, want both move-capable", mine, account) |
| + } |
| + if mine.ID != hashToken(raw) { |
| + t.Fatalf("listing id %s is not the token's hash", mine.ID) |
| + } |
| + p, ok := l.fx.st.CLIPrincipal(raw, time.Now()) |
| + if !ok { |
| + t.Fatal("the minted token does not resolve") |
| + } |
| + if !p.Moves || p.TokenID != mine.ID || p.Kind != PrincipalService || p.App != ltApp || p.AccountID != OpsAccountID { |
| + t.Fatalf("principal = %+v, want a move-capable instance principal of bab/shop with TokenID %s", p, mine.ID) |
| + } |
| + audits := instanceTokenAudits(t, l) |
| + want := "minted a move-capable instance token for shop (cli), token " + mine.ID |
| + if len(audits) != 1 || audits[0].Detail != want || audits[0].Address != "paul@keymail.dev" { |
| + t.Fatalf("audit = %+v, want one row by paul: %q", audits, want) |
| + } |
| + // The tokens page marks it. |
| + if code, _, page := get(t, signInPaul(t, l.fx), l.fx.ts.URL+"/settings"); code != http.StatusOK || !strings.Contains(page, "(app token · shop · move-capable)") { |
| + t.Fatalf("GET /settings = %d, want the token marked move-capable:\n%s", code, page) |
| + } |
| + // The column survives a reopen of the database (the migration is |
| + // idempotent) and the token still reads as move-capable. |
| + path := filepath.Join(t.TempDir(), "console.db") |
| + for i := 0; i < 2; i++ { |
| + st, err := Open(path) |
| + if err != nil { |
| + t.Fatalf("open %d: %v", i, err) |
| + } |
| + if i == 0 { |
| + if _, err := st.NewAppToken(OpsAccountID, ltApp, "paul@keymail.dev", "router", true, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + toks, err := st.CLITokens("paul@keymail.dev") |
| + if err != nil || len(toks) != 1 || !toks[0].Moves { |
| + t.Fatalf("after a reopen: %+v %v, want one move-capable token", toks, err) |
| + } |
| + } |
| + st.Close() |
| + } |
| +} |
| + |
| +func TestMemberCannotMintAMoveCapableToken(t *testing.T) { |
| + l := newLifetimesFx(t) |
| + code, _, body := rawReq(t, http.MethodPost, mintURL(l), l.member, `{"label":"router","moves":true}`) |
| + if code != http.StatusForbidden || cutoverErrorOf(body) != msgMoveTokenNeedsOwner { |
| + t.Fatalf("a member's move-capable mint = %d %s, want 403 %q", code, body, msgMoveTokenNeedsOwner) |
| + } |
| + toks, err := l.fx.st.CLITokens("alice@keymail.dev") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, tk := range toks { |
| + if tk.Kind == cliKindApp { |
| + t.Fatalf("the refused mint left a token: %+v", tk) |
| + } |
| + } |
| + if a := instanceTokenAudits(t, l); len(a) != 0 { |
| + t.Fatalf("the refused mint was audited: %+v", a) |
| + } |
| +} |
| + |
| +func TestPlainMintIsUnchanged(t *testing.T) { |
| + l := newLifetimesFx(t) |
| + for _, c := range []struct{ who, token, addr, body string }{ |
| + {"owner, no moves field", l.operator, "paul@keymail.dev", `{"label":"router"}`}, |
| + {"member, moves false", l.member, "alice@keymail.dev", `{"label":"router","moves":false}`}, |
| + } { |
| + t.Run(c.who, func(t *testing.T) { |
| + code, _, body := rawReq(t, http.MethodPost, mintURL(l), c.token, c.body) |
| + if code != http.StatusCreated { |
| + t.Fatalf("plain mint = %d %s, want 201", code, body) |
| + } |
| + raw := mintedToken(t, body) |
| + mine, account := appTokenListed(t, l, c.addr) |
| + if mine.Moves || account.Moves { |
| + t.Fatalf("a plain token lists as move-capable: %+v %+v", mine, account) |
| + } |
| + p, ok := l.fx.st.CLIPrincipal(raw, time.Now()) |
| + if !ok || p.Moves || p.TokenID != mine.ID { |
| + t.Fatalf("principal = %+v %v, want not move-capable, TokenID %s", p, ok, mine.ID) |
| + } |
| + if code, _, page := get(t, firstOf(signInAs(t, l.fx, c.addr, "code-page-"+c.addr)), l.fx.ts.URL+"/settings"); code != http.StatusOK || strings.Contains(page, "move-capable") { |
| + t.Fatalf("GET /settings = %d, want no token marked move-capable:\n%s", code, page) |
| + } |
| + audits := instanceTokenAudits(t, l) |
| + if len(audits) == 0 || audits[0].Detail != "minted an instance token for shop (cli)" || audits[0].Address != c.addr { |
| + t.Fatalf("audit = %+v, want main's line by %s", audits, c.addr) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func firstOf(c *http.Client, _ string) *http.Client { return c } |
diff --git a/internal/console/svcpage_test.go b/internal/console/svcpage_test.go| index 407d6b093..6b1a13b62 100644 |
| --- a/internal/console/svcpage_test.go |
| +++ b/internal/console/svcpage_test.go |
| @@ -230,7 +230,7 @@ func TestRevokeSaysWhenNothingMatched(t *testing.T) { |
| // kill it from there. |
| func TestPaneRevokesALegacyAppTokenByItsListingID(t *testing.T) { |
| fx := newConsoleFixture(t) |
| - tok, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now()) |
| + tok, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -268,7 +268,7 @@ func TestThePageNeverRendersASecret(t *testing.T) { |
| fx := newConsoleFixture(t) |
| a := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) |
| b := serviceToken(t, fx.st, OpsAccountID, "shipbot", "kass", svcrole.Operate) |
| - legacy, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now()) |
| + legacy, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -490,7 +490,7 @@ func TestPersonalSettingsMirrorsServiceCredentialsReadOnly(t *testing.T) { |
| // revocable from this page, and its minter may not be an account owner. |
| func TestPersonalSettingsStillRevokesALegacyAppToken(t *testing.T) { |
| fx := newConsoleFixture(t) |
| - if _, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now()); err != nil { |
| + if _, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", false, time.Now()); err != nil { |
| t.Fatal(err) |
| } |
| c := signInPaul(t, fx) |
| @@ -567,7 +567,7 @@ func TestPersonalSettingsRefusesAnAppTokenRevokeAfterRemoval(t *testing.T) { |
| if err := fx.st.AddMember(otherID, "paul@keymail.dev", RoleMember, time.Now()); err != nil { |
| t.Fatal(err) |
| } |
| - secret, err := fx.st.NewAppToken(otherID, "kass", "paul@keymail.dev", "router", time.Now()) |
| + secret, err := fx.st.NewAppToken(otherID, "kass", "paul@keymail.dev", "router", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go| index cdd8940c9..61c8271ce 100644 |
| --- a/internal/console/svcprincipal.go |
| +++ b/internal/console/svcprincipal.go |
| @@ -57,6 +57,13 @@ type Principal struct { |
| // other role, and never empty for a deploy principal: CLIPrincipal |
| // refuses a deploy row it cannot read a scope from. |
| Rungs []string |
| + // Moves marks a move-capable instance token (self-serve moves, spec §5): |
| + // a legacy kind='app' row an account owner minted with moves. False for |
| + // a person and for every other service row. |
| + Moves bool |
| + // TokenID is the credential's stored token_hash: the id its listings |
| + // show and its audit lines name. |
| + TokenID string |
| } |
| // ErrServiceNameTaken is returned by NewServicePrincipal when the account |
| @@ -81,10 +88,10 @@ func (s *Store) CLIPrincipal(rawToken string, now time.Time) (Principal, bool) { |
| } |
| hash := hashToken(rawToken) |
| var storedHash, addr, kind, app, role, name, rungs string |
| - var accountID, expires, revoked int64 |
| - err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs |
| + var accountID, expires, revoked, moves int64 |
| + err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs, moves |
| FROM cli_tokens WHERE token_hash = ?`, hash). |
| - Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs) |
| + Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs, &moves) |
| if err != nil { |
| return Principal{}, false |
| } |
| @@ -126,7 +133,9 @@ FROM cli_tokens WHERE token_hash = ?`, hash). |
| // The migration guarantee: identical authority to what |
| // apiInstancePrincipal has always granted this row, expressed in the |
| // new vocabulary. |
| - p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: svcrole.Instance} |
| + // Moves is read from this kind alone: it is the only kind the mint |
| + // door ever marks. |
| + p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: svcrole.Instance, Moves: moves != 0} |
| case cliKindService: |
| r, ok := svcrole.Parse(role) |
| if !ok { |
| @@ -149,6 +158,7 @@ FROM cli_tokens WHERE token_hash = ?`, hash). |
| default: |
| return Principal{}, false |
| } |
| + p.TokenID = storedHash |
| // Best-effort: "last used" is for the owner's benefit on the tokens page, |
| // and a failed write must not fail the request it is annotating. |
| s.db.Exec(`UPDATE cli_tokens SET last_used = ? WHERE token_hash = ?`, now.Unix(), hash) |
| @@ -279,7 +289,7 @@ VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?, ?)`, |
| // stays out of CLITokens. |
| func (s *Store) ServicePrincipals(accountID int64) ([]CLIToken, error) { |
| rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, kind, account_id, app, |
| -role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs |
| +role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs, moves |
| FROM cli_tokens WHERE account_id = ? AND kind IN (?, ?) AND revoked_at = 0 |
| ORDER BY created_at DESC, token_hash`, accountID, cliKindService, cliKindApp) |
| if err != nil { |
diff --git a/internal/console/svcprincipal_test.go b/internal/console/svcprincipal_test.go| index 2ab0ebc13..fe5be3305 100644 |
| --- a/internal/console/svcprincipal_test.go |
| +++ b/internal/console/svcprincipal_test.go |
| @@ -26,7 +26,7 @@ func TestCLIPrincipalLegacyAppTokenIsInstanceRole(t *testing.T) { |
| s := openTestStore(t) |
| now := time.Now() |
| acct := mustAccount(t, s, "legacy") |
| - raw, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now) |
| + raw, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", false, now) |
| if err != nil { |
| t.Fatalf("NewAppToken: %v", err) |
| } |
| @@ -206,7 +206,7 @@ func TestServicePrincipalsListsBothKinds(t *testing.T) { |
| if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { |
| t.Fatalf("NewServicePrincipal: %v", err) |
| } |
| - if _, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now); err != nil { |
| + if _, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", false, now); err != nil { |
| t.Fatalf("NewAppToken: %v", err) |
| } |
| toks, err := s.ServicePrincipals(acct.ID) |
| @@ -244,7 +244,7 @@ func TestRotateAndRevokeIgnoreLegacyNamelessRows(t *testing.T) { |
| s := openTestStore(t) |
| now := time.Now() |
| acct := mustAccount(t, s, "amadan") |
| - app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now) |
| + app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", false, now) |
| if err != nil { |
| t.Fatalf("NewAppToken: %v", err) |
| } |
| @@ -268,7 +268,7 @@ func TestRevokeAccountTokenScopesByAccountNotAddress(t *testing.T) { |
| now := time.Now() |
| acct := mustAccount(t, s, "amadan") |
| other := mustAccount(t, s, "other") |
| - app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now) |
| + app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", false, now) |
| if err != nil { |
| t.Fatalf("NewAppToken: %v", err) |
| } |
| @@ -616,7 +616,7 @@ func TestRevokeAccountTokenSoftRevokesNotDeletes(t *testing.T) { |
| s := openTestStore(t) |
| now := time.Now() |
| acct := mustAccount(t, s, "amadan") |
| - app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now) |
| + app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", false, now) |
| if err != nil { |
| t.Fatalf("NewAppToken: %v", err) |
| } |
diff --git a/internal/console/svcscope_test.go b/internal/console/svcscope_test.go| index 03c9493d7..b1abe32cd 100644 |
| --- a/internal/console/svcscope_test.go |
| +++ b/internal/console/svcscope_test.go |
| @@ -194,7 +194,7 @@ func TestAppsListForAServiceIsItsOwnScope(t *testing.T) { |
| } |
| // A legacy kind='app' credential is a service principal too, and reads |
| // the same way here — its scope is its one app. |
| - legacy, err := fx.st.NewAppToken(OpsAccountID, "two", serviceMinter, "router", now) |
| + legacy, err := fx.st.NewAppToken(OpsAccountID, "two", serviceMinter, "router", false, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -270,7 +270,7 @@ func TestServiceAuditActorIsNotABareAddress(t *testing.T) { |
| // move — it is the exact failure mode the collapse could have introduced. |
| func TestLegacyBindingWithNoAppIsNotAWildcard(t *testing.T) { |
| fx := newConsoleFixture(t) |
| - raw, err := fx.st.NewAppToken(OpsAccountID, "", serviceMinter, "bound to nothing", time.Now()) |
| + raw, err := fx.st.NewAppToken(OpsAccountID, "", serviceMinter, "bound to nothing", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -313,7 +313,7 @@ func TestEnableStaysAHumanAct(t *testing.T) { |
| fx, _ := instanceFixture(t, "code-scope-enable-human") |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| named := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Instance) |
| - legacy, err := fx.st.NewAppToken(OpsAccountID, "titogo", serviceMinter, "router", time.Now()) |
| + legacy, err := fx.st.NewAppToken(OpsAccountID, "titogo", serviceMinter, "router", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -450,7 +450,7 @@ func TestAppScopedServiceCannotClaimANewApp(t *testing.T) { |
| // walls are not confused for one — if the role table ever put `instance` |
| // on this door, THIS is the assertion that would move, and the guard |
| // above is what would then be doing the work. |
| - legacy, err := fx.st.NewAppToken(OpsAccountID, "one", serviceMinter, "router", time.Now()) |
| + legacy, err := fx.st.NewAppToken(OpsAccountID, "one", serviceMinter, "router", false, time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/templates/settings.html b/internal/console/templates/settings.html| index 396ec8d20..24750a5e7 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}}{{if .Moves}} · move-capable{{end}})</span>{{end}}{{end}}</td> |
| <td>{{emptyOrDash .RemoteAddr}}</td> |
| <td>{{fmtTime .CreatedAt}}</td> |
| <td>{{if .LastUsed.IsZero}}—{{else}}{{fmtTime .LastUsed}}{{end}}</td> |