console: service-principal review fixes (Risk A/B, F1-F11)
Fix round on d4eebbe per the coordinator's review rulings. Risk A: revoked_at is a tombstone, not history. Struck the last-used-history rationale from RevokeServicePrincipal's comment; it is kept so CLIPrincipal keeps refusing the secret and a re-mint of the same name is a new row, not a resurrection. ServicePrincipals already filtered revoked_at = 0; that stands. Risk B: CLITokens gains AND revoked_at = 0, matching its own doc comment's claim to list "live" credentials -- a soft-revoked service principal no longer renders there as an unbadged, still-revocable row. F3b/C14: RevokeAccountToken is now a soft UPDATE (same shape as RevokeServicePrincipal), takes now time.Time, and stays broad -- no kind filter -- because a person's cli_tokens row always carries account_id = 0, so it can never reach a human's terminal token. It is the account pane's one universal revoke handle. F7/C15: RotateServicePrincipal no longer overwrites created_at; only token_hash and last_used move, so a year-old credential does not read as freshly minted on the page an owner uses to judge staleness. F1: MoveApp and RenameAppIn widened to kind IN (?, ?) with cliKindApp and cliKindService, in both the stale-token guard and the moving UPDATE -- an app-scoped service principal now follows a rename/move instead of becoming an orphaned credential a later same-named app inherits unminted. Covered by TestRenameAppMovesServicePrincipalToo and TestMoveAppMovesServicePrincipalToo. F2: NewServicePrincipal rejects name == "" -- an empty-named row would be addressable by neither Rotate nor Revoke (both guard on name != ''). F4: added the ErrNoServicePrincipal sentinel; RotateServicePrincipal, RevokeServicePrincipal and RevokeAccountToken all return it instead of an anonymous errors.New, so a door can render 404 vs 500 without string-matching. F5: added tests for NeverExpires() and IsService() on a kind='service' row (C8 had shipped with no test). F6: extracted scanCLITokenRows, shared by CLITokens and ServicePrincipals, so the legacy-role normalization comment's "one place" claim is true. F8: RevokeServicePrincipal takes now time.Time instead of calling time.Now() internally, matching every neighbouring Store method's clock-injection convention. F9: added a UNIQUE partial index (cli_tokens_service_name, WHERE kind='service' AND revoked_at=0) alongside the plain lookup index, so name uniqueness among live service principals is structural rather than resting on SetMaxOpenConns(1) two files away. The transaction's COUNT-then-INSERT stays, so a collision still surfaces as the friendly ErrServiceNameTaken. F10: added a test that stamps revoked_at on a user row via raw SQL (the Go API cannot produce that state) and asserts CLITokenAddress refuses it. F11: corrected the report's test count (brief has 7 tests, not 11; the file holds 7 + 4 = 11).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
4 files changed,
+284
−86
internal/console/authstore.go+40 −10internal/console/store.go+27 −21internal/console/svcprincipal.go+73 −51internal/console/svcprincipal_test.go+144 −4
diff --git a/internal/console/authstore.go b/internal/console/authstore.go| index 25119d92d..dfb7bac77 100644 |
| --- a/internal/console/authstore.go |
| +++ b/internal/console/authstore.go |
| @@ -197,6 +197,21 @@ func (s *Store) migrateCLIAuth() error { |
| if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS cli_tokens_account_name ON cli_tokens (account_id, name);`); err != nil { |
| return err |
| } |
| + // Name uniqueness among live service principals is enforced by |
| + // NewServicePrincipal's own COUNT-then-INSERT inside a transaction, which |
| + // is race-free only because SetMaxOpenConns(1) (store.go) gives that |
| + // transaction the sole connection — a fact this file cannot see. This |
| + // UNIQUE partial index makes the invariant structural rather than |
| + // resting on a pool setting two files away: it is scoped to |
| + // kind='service' AND revoked_at=0 so it composes with the soft revoke — |
| + // a revoked `hub` does not block a fresh `hub` of the same name. The |
| + // transaction stays too, belt and braces, and is what turns a would-be |
| + // constraint violation into the friendly ErrServiceNameTaken. |
| + if _, err := s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS cli_tokens_service_name |
| +ON cli_tokens (account_id, name) |
| +WHERE kind = 'service' AND revoked_at = 0;`); err != nil { |
| + return err |
| + } |
| return nil |
| } |
| @@ -537,16 +552,14 @@ func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) { |
| return addr, true |
| } |
| -// CLITokens lists one address's live CLI credentials, newest first — the |
| -// listing the tokens page renders so an owner can see, and kill, every |
| -// terminal that speaks as them. |
| -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 |
| -FROM cli_tokens WHERE address = ? ORDER BY created_at DESC, token_hash`, address) |
| - if err != nil { |
| - return nil, err |
| - } |
| +// scanCLITokenRows drains a `cli_tokens` listing query into CLIToken values. |
| +// CLITokens (by address) and ServicePrincipals (by account) select the same |
| +// thirteen columns in the same order and need the same legacy-row |
| +// normalization — a kind='app' row's `role` column is ” 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 |
| +// re-derive it. |
| +func scanCLITokenRows(rows *sql.Rows) ([]CLIToken, error) { |
| defer rows.Close() |
| var out []CLIToken |
| for rows.Next() { |
| @@ -572,6 +585,23 @@ FROM cli_tokens WHERE address = ? ORDER BY created_at DESC, token_hash`, address |
| return out, rows.Err() |
| } |
| +// CLITokens lists one address's live CLI credentials, newest first — the |
| +// listing the tokens page renders so an owner can see, and kill, every |
| +// terminal that speaks as them. revoked_at = 0: a soft-revoked service |
| +// principal minted under this address (see RevokeServicePrincipal) must not |
| +// render here as an unbadged, still-live, still-revocable row — the comment |
| +// above already promised "live", and until this filter the query did not |
| +// 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 |
| +FROM cli_tokens WHERE address = ? AND revoked_at = 0 ORDER BY created_at DESC, token_hash`, address) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return scanCLITokenRows(rows) |
| +} |
| + |
| // RevokeCLIToken kills one token by its listing id, scoped to its owner: an |
| // id is not a secret, so the address is what makes this safe. |
| func (s *Store) RevokeCLIToken(address, id string) error { |
diff --git a/internal/console/store.go b/internal/console/store.go| index daa7b4463..babaa4f9a 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -1354,15 +1354,20 @@ func (s *Store) MoveApp(app string, from, to int64) error { |
| if atSrc == 0 { |
| return fmt.Errorf("console: account %d does not claim %q", from, app) |
| } |
| - // App-kind CLI tokens are bound (account, name), so they must follow the |
| - // app or the running deployment's credential 404s while retaining |
| - // authority over the name in the SOURCE account. Tokens already at the |
| - // destination under this name are a purged predecessor's leftovers — |
| - // re-homing onto them would hand this app a credential nobody minted for |
| - // it, so that refuses, exactly as RenameAppIn refuses. |
| + // App-scoped CLI tokens — legacy kind='app' AND kind='service' — are |
| + // bound (account, name), so they must follow the app or the running |
| + // deployment's credential 404s while retaining authority over the name |
| + // in the SOURCE account. Tokens already at the destination under this |
| + // name are a purged predecessor's leftovers — re-homing onto them would |
| + // hand this app a credential nobody minted for it, so that refuses, |
| + // exactly as RenameAppIn refuses. Both kinds must be counted here: a |
| + // service principal the guard cannot see is an orphaned credential a |
| + // LATER app of the same name inherits unminted (see |
| + // TestRenameAppMovesServicePrincipalToo for the RenameAppIn twin of this |
| + // hazard). |
| var staleTokens int |
| - if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind = ? AND account_id = ? AND app = ?`, |
| - cliKindApp, to, app).Scan(&staleTokens); err != nil { |
| + if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind IN (?, ?) AND account_id = ? AND app = ?`, |
| + cliKindApp, cliKindService, to, app).Scan(&staleTokens); err != nil { |
| return err |
| } |
| if staleTokens > 0 { |
| @@ -1372,8 +1377,8 @@ func (s *Store) MoveApp(app string, from, to int64) error { |
| to, app, from); err != nil { |
| return err |
| } |
| - if _, err := tx.Exec(`UPDATE cli_tokens SET account_id = ? WHERE kind = ? AND account_id = ? AND app = ?`, |
| - to, cliKindApp, from, app); err != nil { |
| + if _, err := tx.Exec(`UPDATE cli_tokens SET account_id = ? WHERE kind IN (?, ?) AND account_id = ? AND app = ?`, |
| + to, cliKindApp, cliKindService, from, app); err != nil { |
| return err |
| } |
| return tx.Commit() |
| @@ -1391,16 +1396,17 @@ func (s *Store) MoveApp(app string, from, to int64) error { |
| // keyed by bare name across ALL accounts, so rewriting them could hand |
| // one account's grant to another, and renaming ONTO one would let the new |
| // name inherit a grant nobody issued it. Claim-proof stays claim-proof. |
| -// - The new name must carry no app-kind CLI tokens. A purged app's tokens |
| -// outlive its row, and a credential that suddenly binds to a NEW app is |
| -// authority nobody minted. |
| +// - The new name must carry no app-scoped CLI tokens — legacy kind='app' |
| +// OR kind='service'. A purged app's tokens outlive its row, and a |
| +// credential that suddenly binds to a NEW app is authority nobody |
| +// minted. |
| // |
| // What follows the app: its audit rows — but only those written since THIS |
| // app's row was created, because a purged predecessor of the same name left |
| -// its rows behind and its history is not this app's; and its app-kind CLI |
| -// tokens, which are bound (account, name) and would otherwise 404 the |
| -// running app while retaining authority over the freed name. The rename |
| -// itself is the caller's audit line. |
| +// its rows behind and its history is not this app's; and its app-scoped CLI |
| +// tokens (both kinds), which are bound (account, name) and would otherwise |
| +// 404 the running app while retaining authority over the freed name. The |
| +// rename itself is the caller's audit line. |
| func (s *Store) RenameAppIn(accountID int64, from, to string) error { |
| tx, err := s.db.Begin() |
| if err != nil { |
| @@ -1430,8 +1436,8 @@ func (s *Store) RenameAppIn(accountID int64, from, to string) error { |
| return fmt.Errorf("console: this account already has an app (or a trashed app) named %q", to) |
| } |
| var staleTokens int |
| - if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind = ? AND account_id = ? AND app = ?`, |
| - cliKindApp, accountID, to).Scan(&staleTokens); err != nil { |
| + if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind IN (?, ?) AND account_id = ? AND app = ?`, |
| + cliKindApp, cliKindService, accountID, to).Scan(&staleTokens); err != nil { |
| return err |
| } |
| if staleTokens > 0 { |
| @@ -1445,8 +1451,8 @@ func (s *Store) RenameAppIn(accountID int64, from, to string) error { |
| to, accountID, from, createdAt); err != nil { |
| return err |
| } |
| - if _, err := tx.Exec(`UPDATE cli_tokens SET app = ? WHERE kind = ? AND account_id = ? AND app = ?`, |
| - to, cliKindApp, accountID, from); err != nil { |
| + if _, err := tx.Exec(`UPDATE cli_tokens SET app = ? WHERE kind IN (?, ?) AND account_id = ? AND app = ?`, |
| + to, cliKindApp, cliKindService, accountID, from); err != nil { |
| return err |
| } |
| return tx.Commit() |
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go| index 4d7d3d1ac..6fa759179 100644 |
| --- a/internal/console/svcprincipal.go |
| +++ b/internal/console/svcprincipal.go |
| @@ -56,6 +56,13 @@ type Principal struct { |
| // already holds a live credential of that name. |
| var ErrServiceNameTaken = errors.New("console: a service credential of that name already exists") |
| +// ErrNoServicePrincipal is returned by RotateServicePrincipal, |
| +// RevokeServicePrincipal and RevokeAccountToken when nothing matched — |
| +// zero rows affected, not a database failure. A door needs to tell the two |
| +// apart (404 vs 500) without string-matching an error, which an anonymous |
| +// errors.New at each call site would force it to do. |
| +var ErrNoServicePrincipal = errors.New("console: no such service credential") |
| + |
| // CLIPrincipal resolves a bearer token to whichever kind of caller minted it. |
| // ok is false for unknown, revoked, expired tokens, and — for a kind='service' |
| // row — for a role name this binary does not recognise: a caller that reads |
| @@ -117,11 +124,25 @@ FROM cli_tokens WHERE token_hash = ?`, hash). |
| // reason — the credential speaks for a service, but a human stays |
| // answerable for it. |
| // |
| +// name must be non-empty. ” is not a name a caller chose — it is the value |
| +// every legacy kind='app' row already carries by default, and both |
| +// RotateServicePrincipal and RevokeServicePrincipal refuse to match it for |
| +// exactly that reason (a call with an empty name must never reach a legacy |
| +// row it did not name). Minting a kind='service' row with name=” would |
| +// create a credential neither of those doors could ever address again except |
| +// by RevokeAccountToken's raw id, so the store refuses it at the door |
| +// instead of producing an unaddressable row later. Grammar validation for |
| +// what a GOOD name looks like stays at the console API (Task 6); this is |
| +// only "not empty". |
| +// |
| // The token never expires (expires_at = 0), the same deliberate trade |
| // NewAppToken documents: a production path that silently dies on day 91 is a |
| // worse failure than a long-lived credential whose rotation is one command. |
| // Revocation is the control. |
| func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcrole.Role, mintedBy string, now time.Time) (string, error) { |
| + if name == "" { |
| + return "", errors.New("console: a service credential needs a name") |
| + } |
| if _, ok := svcrole.Parse(string(role)); !ok { |
| return "", fmt.Errorf("console: unknown service role %q", role) |
| } |
| @@ -155,11 +176,14 @@ VALUES (?, ?, '', '', ?, 0, ?, ?, ?, ?, ?)`, |
| } |
| // ServicePrincipals lists one account's live service credentials, newest |
| -// first: kind='service' rows AND legacy kind='app' rows, normalized here to |
| -// the instance role they have always held. Absorbing the legacy kind into |
| -// this model must not make it invisible to the account it is bound to — this |
| -// is the one place that normalization happens, so no reader downstream has |
| -// to re-derive it. |
| +// first: kind='service' rows AND legacy kind='app' rows, normalized by |
| +// scanCLITokenRows to the instance role they have always held. Absorbing the |
| +// legacy kind into this model must not make it invisible to the account it |
| +// is bound to. |
| +// |
| +// revoked_at = 0: a revoked row is a tombstone (see RevokeServicePrincipal), |
| +// not history to display — it stays out of this listing the same way it |
| +// 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 |
| @@ -168,48 +192,30 @@ ORDER BY created_at DESC, token_hash`, accountID, cliKindService, cliKindApp) |
| if err != nil { |
| return nil, err |
| } |
| - defer rows.Close() |
| - var out []CLIToken |
| - for rows.Next() { |
| - var t CLIToken |
| - var created, expires, used, revoked 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); err != nil { |
| - return nil, err |
| - } |
| - t.CreatedAt = time.Unix(created, 0).UTC() |
| - t.ExpiresAt = time.Unix(expires, 0).UTC() |
| - if used != 0 { |
| - t.LastUsed = time.Unix(used, 0).UTC() |
| - } |
| - if revoked != 0 { |
| - t.RevokedAt = time.Unix(revoked, 0).UTC() |
| - } |
| - if t.Kind == cliKindApp { |
| - t.Role = string(svcrole.Instance) |
| - } |
| - out = append(out, t) |
| - } |
| - return out, rows.Err() |
| + return scanCLITokenRows(rows) |
| } |
| // RotateServicePrincipal mints a fresh secret for a live, NAMED credential |
| // and retires the old one in the same statement: same row, same name, same |
| -// role, only the secret moves. Zero rows affected — including a call with |
| -// name="" — is an error the door renders as 404. |
| +// role, same age — only the secret moves and last_used resets, because a |
| +// fresh secret has not been used yet. created_at is deliberately left alone: |
| +// overwriting it would make a credential granted a year ago read as newly |
| +// minted on the very page an owner uses to decide what is stale. Zero rows |
| +// affected — including a call with name="" — is ErrNoServicePrincipal, which |
| +// the door renders as 404. |
| // |
| // name != ” guards against a legacy kind='app' row, whose name column is |
| // always ”: without it, RotateServicePrincipal(acctID, "") would match that |
| -// row's own nameless twin (or, if the account somehow held more than one, all |
| -// of them) and rotate a credential the caller never named. |
| +// row (or, if the account somehow held more than one, all of them) and |
| +// rotate a credential the caller never named. |
| func (s *Store) RotateServicePrincipal(accountID int64, name string, now time.Time) (string, error) { |
| raw, err := randomToken() |
| if err != nil { |
| return "", err |
| } |
| - res, err := s.db.Exec(`UPDATE cli_tokens SET token_hash = ?, created_at = ?, last_used = 0 |
| + res, err := s.db.Exec(`UPDATE cli_tokens SET token_hash = ?, last_used = 0 |
| WHERE kind = ? AND account_id = ? AND name = ? AND name != '' AND revoked_at = 0`, |
| - hashToken(raw), now.Unix(), cliKindService, accountID, name) |
| + hashToken(raw), cliKindService, accountID, name) |
| if err != nil { |
| return "", err |
| } |
| @@ -218,19 +224,25 @@ WHERE kind = ? AND account_id = ? AND name = ? AND name != '' AND revoked_at = 0 |
| return "", err |
| } |
| if n == 0 { |
| - return "", errors.New("console: no such service credential") |
| + return "", ErrNoServicePrincipal |
| } |
| return raw, nil |
| } |
| -// RevokeServicePrincipal soft-revokes a live, NAMED credential — UPDATE, not |
| -// DELETE, so ServicePrincipals can still show a revoked credential's last-used |
| -// time to whoever is working out what broke. name != ” guards against a |
| -// legacy kind='app' row for the same reason RotateServicePrincipal needs it. |
| -func (s *Store) RevokeServicePrincipal(accountID int64, name string) error { |
| +// RevokeServicePrincipal soft-revokes a live, NAMED credential: UPDATE, not |
| +// DELETE. The row is kept as a TOMBSTONE, not as history for a listing to |
| +// show — nothing lists a revoked row (ServicePrincipals and CLITokens both |
| +// filter revoked_at = 0). It is kept for two reasons: so CLIPrincipal goes on |
| +// refusing this exact secret rather than a gap in the table quietly |
| +// resolving it again, and so a later NewServicePrincipal minting the same |
| +// name creates a NEW row rather than resurrecting this one's identity — |
| +// the unique index (cli_tokens_service_name) is scoped to live rows for |
| +// exactly that reason. name != ” guards against a legacy kind='app' row for |
| +// the same reason RotateServicePrincipal needs it. |
| +func (s *Store) RevokeServicePrincipal(accountID int64, name string, now time.Time) error { |
| res, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ? |
| WHERE kind = ? AND account_id = ? AND name = ? AND name != '' AND revoked_at = 0`, |
| - time.Now().Unix(), cliKindService, accountID, name) |
| + now.Unix(), cliKindService, accountID, name) |
| if err != nil { |
| return err |
| } |
| @@ -239,19 +251,29 @@ WHERE kind = ? AND account_id = ? AND name = ? AND name != '' AND revoked_at = 0 |
| return err |
| } |
| if n == 0 { |
| - return errors.New("console: no such service credential") |
| + return ErrNoServicePrincipal |
| } |
| return nil |
| } |
| -// RevokeAccountToken kills one credential by its listing id (the token hash), |
| -// scoped to the ACCOUNT rather than to the address that minted it. |
| -// RevokeCLIToken scopes by address, which is right for a person killing their |
| -// own terminal and wrong for an owner revoking a credential a colleague |
| -// minted — and legacy kind='app' rows have no name to address them by, so |
| -// this is the only handle they have. |
| -func (s *Store) RevokeAccountToken(accountID int64, id string) error { |
| - res, err := s.db.Exec(`DELETE FROM cli_tokens WHERE token_hash = ? AND account_id = ?`, id, accountID) |
| +// RevokeAccountToken soft-revokes one credential by its listing id (the |
| +// token hash), scoped to the ACCOUNT rather than to the address that minted |
| +// it — the universal handle: it is the only path that works for every row |
| +// the account pane lists, service-named or legacy-nameless alike, so that |
| +// pane needs exactly one Revoke button rather than one per kind. |
| +// RevokeCLIToken scopes by address instead, which is right for a person |
| +// killing their own terminal — but a legacy kind='app' row has no name to |
| +// address by RevokeServicePrincipal, so this is the only handle it has, and |
| +// an owner revoking a credential a colleague minted needs the account, not |
| +// the minter's address, as the scope. |
| +// |
| +// The broad query (no kind filter) is not a hazard: a person's cli_tokens |
| +// row always carries account_id = 0 (see CLIToken), so an account-scoped |
| +// revoke can never reach a human's terminal token — there is no account id |
| +// for it to match. |
| +func (s *Store) RevokeAccountToken(accountID int64, id string, now time.Time) error { |
| + res, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ? |
| +WHERE token_hash = ? AND account_id = ? AND revoked_at = 0`, now.Unix(), id, accountID) |
| if err != nil { |
| return err |
| } |
| @@ -260,7 +282,7 @@ func (s *Store) RevokeAccountToken(accountID int64, id string) error { |
| return err |
| } |
| if n == 0 { |
| - return errors.New("console: no such token") |
| + return ErrNoServicePrincipal |
| } |
| return nil |
| } |
diff --git a/internal/console/svcprincipal_test.go b/internal/console/svcprincipal_test.go| index 834db70d2..d619049ce 100644 |
| --- a/internal/console/svcprincipal_test.go |
| +++ b/internal/console/svcprincipal_test.go |
| @@ -116,7 +116,7 @@ func TestRevokedServicePrincipalStopsResolving(t *testing.T) { |
| if err != nil { |
| t.Fatalf("NewServicePrincipal: %v", err) |
| } |
| - if err := s.RevokeServicePrincipal(acct.ID, "hub"); err != nil { |
| + if err := s.RevokeServicePrincipal(acct.ID, "hub", now); err != nil { |
| t.Fatalf("RevokeServicePrincipal: %v", err) |
| } |
| if _, ok := s.CLIPrincipal(raw, now); ok { |
| @@ -250,7 +250,7 @@ func TestRotateAndRevokeIgnoreLegacyNamelessRows(t *testing.T) { |
| if _, err := s.RotateServicePrincipal(acct.ID, "", now); err == nil { |
| t.Fatal("RotateServicePrincipal(\"\") succeeded against a legacy row; want a refusal") |
| } |
| - if err := s.RevokeServicePrincipal(acct.ID, ""); err == nil { |
| + if err := s.RevokeServicePrincipal(acct.ID, "", now); err == nil { |
| t.Fatal("RevokeServicePrincipal(\"\") succeeded against a legacy row; want a refusal") |
| } |
| // The legacy app token must still work: neither call touched it. |
| @@ -272,16 +272,156 @@ func TestRevokeAccountTokenScopesByAccountNotAddress(t *testing.T) { |
| t.Fatalf("NewAppToken: %v", err) |
| } |
| id := hashToken(app) |
| - if err := s.RevokeAccountToken(other.ID, id); err == nil { |
| + if err := s.RevokeAccountToken(other.ID, id, now); err == nil { |
| t.Fatal("a different account revoked this one's token") |
| } |
| if _, ok := s.CLIPrincipal(app, now); !ok { |
| t.Fatal("token stopped working after a refused cross-account revoke") |
| } |
| - if err := s.RevokeAccountToken(acct.ID, id); err != nil { |
| + if err := s.RevokeAccountToken(acct.ID, id, now); err != nil { |
| t.Fatalf("RevokeAccountToken: %v", err) |
| } |
| if _, ok := s.CLIPrincipal(app, now); ok { |
| t.Error("token still resolves after RevokeAccountToken") |
| } |
| } |
| + |
| +// TestNewServicePrincipalRejectsEmptyName: ” is not a name a caller chose — |
| +// it is the default every legacy kind='app' row already carries — and both |
| +// RotateServicePrincipal and RevokeServicePrincipal refuse to match it, so a |
| +// kind='service' row minted with name=” would be unaddressable except by |
| +// RevokeAccountToken's raw id. The store refuses it at the door instead. |
| +func TestNewServicePrincipalRejectsEmptyName(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + acct := mustAccount(t, s, "amadan") |
| + if _, err := s.NewServicePrincipal(acct.ID, "", "", svcrole.Publish, "paul@example.test", now); err == nil { |
| + t.Fatal("NewServicePrincipal(name=\"\") succeeded; want a refusal") |
| + } |
| +} |
| + |
| +// TestServiceKindNeverExpiresAndIsService is C8's missing test: a |
| +// kind='service' row must report NeverExpires() == true (expires_at is 0 by |
| +// design) and IsService() == true — otherwise templates/settings.html would |
| +// render every service credential as expiring 1 Jan 1970, the exact failure |
| +// C8 exists to prevent. |
| +func TestServiceKindNeverExpiresAndIsService(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + acct := mustAccount(t, s, "amadan") |
| + if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { |
| + t.Fatalf("NewServicePrincipal: %v", err) |
| + } |
| + toks, err := s.ServicePrincipals(acct.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(toks) != 1 { |
| + t.Fatalf("ServicePrincipals = %d rows, want 1", len(toks)) |
| + } |
| + tok := toks[0] |
| + if !tok.IsService() { |
| + t.Error("IsService() = false for a kind='service' row") |
| + } |
| + if tok.IsApp() { |
| + t.Error("IsApp() = true for a kind='service' row") |
| + } |
| + if !tok.NeverExpires() { |
| + t.Error("NeverExpires() = false for a service principal; would render as expiring 1 Jan 1970") |
| + } |
| +} |
| + |
| +// TestCLITokenAddressRefusesRevokedUserRow exercises a state the Go API |
| +// cannot otherwise produce (RevokeCLIToken deletes rather than soft-revokes |
| +// a user row): same raw-SQL technique as |
| +// TestUnparseableRoleRefusesRatherThanFallingThrough, proving the |
| +// revoked_at != 0 branch in CLITokenAddress is live code, not dead code. |
| +func TestCLITokenAddressRefusesRevokedUserRow(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + raw := mintUserToken(t, s, "paul@example.test", now) |
| + if _, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ? WHERE token_hash = ?`, now.Unix(), hashToken(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if addr, ok := s.CLITokenAddress(raw, now); ok { |
| + t.Errorf("CLITokenAddress(revoked user row) = %q,true; want \"\",false", addr) |
| + } |
| +} |
| + |
| +// TestRevokedNameIsFreeForAFreshMint: RevokeServicePrincipal tombstones the |
| +// row rather than deleting it (Risk A), and the unique index |
| +// (cli_tokens_service_name) is scoped to live rows — a revoked `hub` must |
| +// not permanently squat the name. |
| +func TestRevokedNameIsFreeForAFreshMint(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + acct := mustAccount(t, s, "amadan") |
| + if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { |
| + t.Fatalf("first mint: %v", err) |
| + } |
| + if err := s.RevokeServicePrincipal(acct.ID, "hub", now); err != nil { |
| + t.Fatalf("RevokeServicePrincipal: %v", err) |
| + } |
| + if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Operate, "paul@example.test", now); err != nil { |
| + t.Fatalf("re-mint after revoke: %v", err) |
| + } |
| +} |
| + |
| +// TestRenameAppMovesServicePrincipalToo is F1's authority bug, made |
| +// concrete: an app-scoped service principal that does not follow a rename |
| +// becomes an orphaned credential a LATER app of the same (freed) name |
| +// inherits — authority nobody minted it for. |
| +func TestRenameAppMovesServicePrincipalToo(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + acct := mustAccount(t, s, "amadan") |
| + if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@example.test", now); err != nil { |
| + t.Fatalf("CreateAppIn: %v", err) |
| + } |
| + raw, err := s.NewServicePrincipal(acct.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatalf("NewServicePrincipal: %v", err) |
| + } |
| + if err := s.RenameAppIn(acct.ID, "titogo", "tito"); err != nil { |
| + t.Fatalf("RenameAppIn: %v", err) |
| + } |
| + // The freed name is claimable again — by a brand new app nobody minted a |
| + // credential for. |
| + if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@example.test", now); err != nil { |
| + t.Fatalf("CreateAppIn (fresh titogo): %v", err) |
| + } |
| + p, ok := s.CLIPrincipal(raw, now) |
| + if !ok { |
| + t.Fatal("CLIPrincipal(hub) = !ok after rename; want ok") |
| + } |
| + if p.App != "tito" { |
| + t.Errorf("App = %q after rename; want the RENAMED app (tito) — not the freed name a new, different app now holds", p.App) |
| + } |
| +} |
| + |
| +// TestMoveAppMovesServicePrincipalToo is the MoveApp twin of the rename |
| +// guard fix: an app-scoped service principal must follow its app across |
| +// accounts, the same way a legacy app-kind token already does. |
| +func TestMoveAppMovesServicePrincipalToo(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + from := mustAccount(t, s, "amadan") |
| + to := mustAccount(t, s, "other") |
| + if _, err := s.CreateAppIn(from.ID, "titogo", "paul@example.test", now); err != nil { |
| + t.Fatalf("CreateAppIn: %v", err) |
| + } |
| + raw, err := s.NewServicePrincipal(from.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatalf("NewServicePrincipal: %v", err) |
| + } |
| + if err := s.MoveApp("titogo", from.ID, to.ID); err != nil { |
| + t.Fatalf("MoveApp: %v", err) |
| + } |
| + p, ok := s.CLIPrincipal(raw, now) |
| + if !ok { |
| + t.Fatal("CLIPrincipal(hub) = !ok after move; want ok") |
| + } |
| + if p.AccountID != to.ID { |
| + t.Errorf("AccountID = %d after move; want %d (the destination account)", p.AccountID, to.ID) |
| + } |
| +} |