console: a principal that is not an address
Task 2 of the service-principals plan: widen cli_tokens with role, name and revoked_at (additive ALTER, swallowed-duplicate idiom), add the kind='service' row shape, and give it a resolver — Store.CLIPrincipal — that returns a Principal (PrincipalUser | PrincipalService) instead of the bare address apiAccount reads today. A legacy kind='app' row reads back as a PrincipalService holding role svcrole.Instance, App/AccountID from its own columns, and Name empty — the migration guarantee: identical authority to what apiInstancePrincipal has always granted it, expressed in the new vocabulary. Nothing consumes Principal yet; the old resolvers (CLITokenAddress, CLIAppToken) are untouched except for CLITokenAddress, which now also refuses revoked_at != 0 rows, closing the same door a service principal's soft revoke opens. Store methods: NewServicePrincipal (name-unique per account among live rows), ServicePrincipals (lists service + legacy app rows for an account, normalizing the legacy row's Role in the scan), RotateServicePrincipal and RevokeServicePrincipal (both guarded with name != '' so a call with an empty name can never match a legacy nameless row), and RevokeAccountToken (kills by listing id, scoped to the account rather than the minting address — the only handle a legacy nameless row has). CLIToken gains Role/Name/RevokedAt and an IsService() alongside the existing IsApp(), so NeverExpires() and the settings template need no string-literal kind comparisons of their own. internal/console/svcfixture_test.go adds serviceToken(t, st, accountID, name, app, role) for tasks 4-8 to share.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
4 files changed,
+637
−24
internal/console/authstore.go+63 −24internal/console/svcfixture_test.go+21 −0internal/console/svcprincipal.go+266 −0internal/console/svcprincipal_test.go+287 −0
diff --git a/internal/console/authstore.go b/internal/console/authstore.go| index dde723d60..25119d92d 100644 |
| --- a/internal/console/authstore.go |
| +++ b/internal/console/authstore.go |
| @@ -29,6 +29,8 @@ import ( |
| "strings" |
| "sync/atomic" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| const ( |
| @@ -99,20 +101,26 @@ type CLIToken struct { |
| Address string |
| Label string |
| RemoteAddr string |
| - Kind string // "" = a person's token, "app" = app-held (see NewAppToken) |
| - AccountID int64 // app tokens only: the account it is bound to |
| - App string // app tokens only: the app it is bound to |
| - CreatedAt time.Time |
| - ExpiresAt time.Time |
| - LastUsed time.Time |
| + Kind string // "" = a person's token, "app" = app-held (see NewAppToken), "service" = a service principal |
| + AccountID int64 // app/service tokens only: the account it is bound to |
| + App string // app/service tokens only: the app it is bound to ("" = account-scoped) |
| + // Role is a svcrole catalogue name, service principals only. Plain |
| + // string, not svcrole.Role: this crosses into an html/template, which |
| + // cannot call svcrole.Parse. |
| + Role string |
| + Name string // service principals only: the stable handle rotate/revoke address by |
| + CreatedAt time.Time |
| + ExpiresAt time.Time |
| + LastUsed time.Time |
| + RevokedAt time.Time // zero unless the credential has been revoked |
| } |
| // NeverExpires reports whether this credential has no expiry — true only |
| -// for app tokens, whose expires_at is 0 by design (spec §8). The page asks |
| -// this rather than testing ExpiresAt against the epoch itself, so the "0 |
| -// means never" convention lives in exactly one place. |
| +// for app and service tokens, whose expires_at is 0 by design (spec §8). The |
| +// page asks this rather than testing ExpiresAt against the epoch itself, so |
| +// the "0 means never" convention lives in exactly one place. |
| func (t CLIToken) NeverExpires() bool { |
| - return t.Kind == cliKindApp && (t.ExpiresAt.IsZero() || t.ExpiresAt.Unix() == 0) |
| + return (t.IsApp() || t.IsService()) && (t.ExpiresAt.IsZero() || t.ExpiresAt.Unix() == 0) |
| } |
| // IsApp reports whether this row is an app-held token, for the page's |
| @@ -120,6 +128,10 @@ func (t CLIToken) NeverExpires() bool { |
| // comparing against a string literal of its own. |
| func (t CLIToken) IsApp() bool { return t.Kind == cliKindApp } |
| +// IsService reports whether this row is a service principal, for the same |
| +// reason IsApp exists: a template cannot see cliKindService. |
| +func (t CLIToken) IsService() bool { return t.Kind == cliKindService } |
| + |
| // migrateCLIAuth creates the CLI-identity tables. Additive and idempotent, |
| // called from Store.migrate. |
| func (s *Store) migrateCLIAuth() error { |
| @@ -163,25 +175,39 @@ func (s *Store) migrateCLIAuth() error { |
| // swallowed so the list stays idempotent across reopens, the same pattern |
| // the registry migrate uses. Every one defaults to the USER-token value, |
| // so every row written before this change reads back as exactly what it |
| - // was: kind '', bound to no account and no app. |
| + // was: kind '', bound to no account and no app, no role, no name, not |
| + // revoked. |
| for _, stmt := range []string{ |
| `ALTER TABLE cli_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE cli_tokens ADD COLUMN account_id INTEGER NOT NULL DEFAULT 0`, |
| `ALTER TABLE cli_tokens ADD COLUMN app TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE cli_tokens ADD COLUMN role TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE cli_tokens ADD COLUMN name TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE cli_tokens ADD COLUMN revoked_at INTEGER NOT NULL DEFAULT 0`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| } |
| } |
| + // This index needs `name`, which the ALTER loop above just added (or |
| + // already had, on a reopened database). It cannot live in the |
| + // CREATE INDEX IF NOT EXISTS block above: that block runs before the |
| + // ALTER loop, so on a fresh database it would fail with "no such column: |
| + // name" and take Open down with it. |
| + if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS cli_tokens_account_name ON cli_tokens (account_id, name);`); err != nil { |
| + return err |
| + } |
| return nil |
| } |
| -// The two token kinds cli_tokens holds. The user kind is the empty string on |
| -// purpose: it is what every row written before the app kind existed already |
| -// says, so the default IS the safe answer. |
| +// The three token kinds cli_tokens holds. The user kind is the empty string |
| +// on purpose: it is what every row written before the app kind existed |
| +// already says, so the default IS the safe answer. cliKindService is the |
| +// principal that is not an address (see svcprincipal.go). |
| const ( |
| - cliKindUser = "" |
| - cliKindApp = "app" |
| + cliKindUser = "" |
| + cliKindApp = "app" |
| + cliKindService = "service" |
| ) |
| // newUserCode mints the typed code, grouped XXXX-XXXX so it can be read out |
| @@ -481,20 +507,27 @@ func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) { |
| } |
| hash := hashToken(rawToken) |
| var storedHash, addr, kind string |
| - var expires int64 |
| - err := s.db.QueryRow(`SELECT token_hash, address, kind, expires_at FROM cli_tokens WHERE token_hash = ?`, |
| - hash).Scan(&storedHash, &addr, &kind, &expires) |
| + var expires, revoked int64 |
| + err := s.db.QueryRow(`SELECT token_hash, address, kind, expires_at, revoked_at FROM cli_tokens WHERE token_hash = ?`, |
| + hash).Scan(&storedHash, &addr, &kind, &expires, &revoked) |
| if err != nil { |
| return "", false |
| } |
| if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 { |
| return "", false |
| } |
| - // An app token presented to a user door must look exactly like a token |
| - // that does not exist. |
| + // An app or service token presented to a user door must look exactly |
| + // like a token that does not exist. |
| if kind != cliKindUser { |
| return "", false |
| } |
| + // A revoked credential must look exactly like a token that does not |
| + // exist — a service principal killed through RevokeServicePrincipal |
| + // leaves a row behind (soft revoke, see ServicePrincipals), and this door |
| + // must not answer for it just because it once could. |
| + if revoked != 0 { |
| + return "", false |
| + } |
| if now.Unix() > expires { |
| return "", false |
| } |
| @@ -509,7 +542,7 @@ func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) { |
| // 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, |
| -created_at, expires_at, last_used |
| +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 |
| @@ -518,9 +551,9 @@ FROM cli_tokens WHERE address = ? ORDER BY created_at DESC, token_hash`, address |
| var out []CLIToken |
| for rows.Next() { |
| var t CLIToken |
| - var created, expires, used int64 |
| + var created, expires, used, revoked int64 |
| if err := rows.Scan(&t.ID, &t.Address, &t.Label, &t.RemoteAddr, &t.Kind, &t.AccountID, &t.App, |
| - &created, &expires, &used); err != nil { |
| + &t.Role, &t.Name, &created, &expires, &used, &revoked); err != nil { |
| return nil, err |
| } |
| t.CreatedAt = time.Unix(created, 0).UTC() |
| @@ -528,6 +561,12 @@ FROM cli_tokens WHERE address = ? ORDER BY created_at DESC, token_hash`, address |
| 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() |
diff --git a/internal/console/svcfixture_test.go b/internal/console/svcfixture_test.go| new file mode 100644 |
| index 000000000..59c3ebbb2 |
| --- /dev/null |
| +++ b/internal/console/svcfixture_test.go |
| @@ -0,0 +1,21 @@ |
| +package console |
| + |
| +import ( |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// serviceToken mints a service principal and returns its bearer — the |
| +// service-principal counterpart of cliToken, and deliberately the same |
| +// shape, so a test reads the same whichever kind of caller it is exercising. |
| +// Tasks 4-8 use this; do not invent a second one. |
| +func serviceToken(t *testing.T, st *Store, accountID int64, name, app string, role svcrole.Role) string { |
| + t.Helper() |
| + raw, err := st.NewServicePrincipal(accountID, name, app, role, "paul@example.test", time.Now()) |
| + if err != nil { |
| + t.Fatalf("NewServicePrincipal(%q): %v", name, err) |
| + } |
| + return raw |
| +} |
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go| new file mode 100644 |
| index 000000000..4d7d3d1ac |
| --- /dev/null |
| +++ b/internal/console/svcprincipal.go |
| @@ -0,0 +1,266 @@ |
| +// Service principals: the console API's non-address caller. |
| +// |
| +// A console API token has always resolved to a Keymail address, and an |
| +// address's authority is the union of its memberships evaluated at request |
| +// time (apiAccount). That is right for a human at a terminal and wrong for a |
| +// server holding a credential: the reach grows without the holder acting, and |
| +// the rate limits are the operator's own. This file is the principal that is |
| +// not an address. |
| +// |
| +// It WIDENS an existing model rather than inventing one. cli_tokens has |
| +// carried kind='app' — bound to one account and one app, minted by a member, |
| +// never expiring — since the instances work, and apiInstancePrincipal has |
| +// gated six doors on it. A legacy row reads back here as an app-scoped |
| +// service principal holding role `instance`, which is defined by observation |
| +// of what those tokens reach TODAY (instances, domains, logs — spec §5), not |
| +// by what the word sounds like. |
| +// |
| +// Spec: docs/superpowers/specs/2026-08-18-service-principals-design.md §2. |
| +package console |
| + |
| +import ( |
| + "crypto/subtle" |
| + "errors" |
| + "fmt" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// PrincipalKind separates the two things a bearer token can be. It is a typed |
| +// constant rather than a string compare at each call site so that "is this a |
| +// person?" cannot be written two subtly different ways in two files. |
| +type PrincipalKind int |
| + |
| +const ( |
| + PrincipalUser PrincipalKind = iota |
| + PrincipalService |
| +) |
| + |
| +// Principal is one resolved caller. |
| +// |
| +// Address is the CALLER for a person and the MINTER for a service — a fact |
| +// recorded so a human stays answerable, never an authority. Nothing in the |
| +// gate path may read a service's Address as a set of memberships; that |
| +// mistake is exactly what issue #220 is about. |
| +type Principal struct { |
| + Kind PrincipalKind |
| + Address string |
| + AccountID int64 |
| + App string // "" means account-scoped |
| + Role svcrole.Role |
| + Name string // "" for a person and for legacy kind='app' rows |
| +} |
| + |
| +// ErrServiceNameTaken is returned by NewServicePrincipal when the account |
| +// already holds a live credential of that name. |
| +var ErrServiceNameTaken = errors.New("console: a service credential of that name already exists") |
| + |
| +// 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 |
| +// "unparseable" as "no authority" would still be wrong if this returned a |
| +// role-less service principal instead of refusing outright, so it refuses. |
| +func (s *Store) CLIPrincipal(rawToken string, now time.Time) (Principal, bool) { |
| + if rawToken == "" { |
| + return Principal{}, false |
| + } |
| + hash := hashToken(rawToken) |
| + var storedHash, addr, kind, app, role, name string |
| + var accountID, expires, revoked int64 |
| + err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at |
| +FROM cli_tokens WHERE token_hash = ?`, hash). |
| + Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked) |
| + if err != nil { |
| + return Principal{}, false |
| + } |
| + if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 { |
| + return Principal{}, false |
| + } |
| + if revoked != 0 { |
| + return Principal{}, false |
| + } |
| + // 0 is "never" (app and service tokens); any other value is still |
| + // honoured. |
| + if !(expires == 0 || now.Unix() <= expires) { |
| + return Principal{}, false |
| + } |
| + var p Principal |
| + switch kind { |
| + case cliKindUser: |
| + // A person carries no role and no scope — nothing else here, ever. |
| + p = Principal{Kind: PrincipalUser, Address: addr} |
| + case cliKindApp: |
| + // 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} |
| + case cliKindService: |
| + r, ok := svcrole.Parse(role) |
| + if !ok { |
| + return Principal{}, false |
| + } |
| + p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: r, Name: name} |
| + default: |
| + return Principal{}, false |
| + } |
| + // 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) |
| + return p, true |
| +} |
| + |
| +// NewServicePrincipal mints a credential a SERVER holds, scoped to one |
| +// account and — optionally — one app within it, and carrying exactly one |
| +// role from the closed svcrole catalogue. mintedBy is the member who minted |
| +// it: it goes in `address`, the same field NewAppToken uses for the same |
| +// reason — the credential speaks for a service, but a human stays |
| +// answerable for it. |
| +// |
| +// 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 _, ok := svcrole.Parse(string(role)); !ok { |
| + return "", fmt.Errorf("console: unknown service role %q", role) |
| + } |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return "", err |
| + } |
| + defer tx.Rollback() |
| + var count int |
| + if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind = ? AND account_id = ? AND name = ? AND revoked_at = 0`, |
| + cliKindService, accountID, name).Scan(&count); err != nil { |
| + return "", err |
| + } |
| + if count > 0 { |
| + return "", ErrServiceNameTaken |
| + } |
| + raw, err := randomToken() |
| + if err != nil { |
| + return "", err |
| + } |
| + if _, err := tx.Exec(`INSERT INTO cli_tokens |
| +(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app, role, name) |
| +VALUES (?, ?, '', '', ?, 0, ?, ?, ?, ?, ?)`, |
| + hashToken(raw), mintedBy, now.Unix(), cliKindService, accountID, app, string(role), name); err != nil { |
| + return "", err |
| + } |
| + if err := tx.Commit(); err != nil { |
| + return "", err |
| + } |
| + return raw, nil |
| +} |
| + |
| +// 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. |
| +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 |
| +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 { |
| + 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() |
| +} |
| + |
| +// 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. |
| +// |
| +// 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. |
| +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 |
| +WHERE kind = ? AND account_id = ? AND name = ? AND name != '' AND revoked_at = 0`, |
| + hashToken(raw), now.Unix(), cliKindService, accountID, name) |
| + if err != nil { |
| + return "", err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return "", err |
| + } |
| + if n == 0 { |
| + return "", errors.New("console: no such service credential") |
| + } |
| + 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 { |
| + 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) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return errors.New("console: no such service credential") |
| + } |
| + 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) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return errors.New("console: no such token") |
| + } |
| + return nil |
| +} |
diff --git a/internal/console/svcprincipal_test.go b/internal/console/svcprincipal_test.go| new file mode 100644 |
| index 000000000..834db70d2 |
| --- /dev/null |
| +++ b/internal/console/svcprincipal_test.go |
| @@ -0,0 +1,287 @@ |
| +package console |
| + |
| +import ( |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// mustAccount mints a fresh account for a test, using the same constructor |
| +// the console itself calls (Store.CreateAccount) and the package's own |
| +// testAccountID for a collision-free id. |
| +func mustAccount(t *testing.T, s *Store, name string) *Account { |
| + t.Helper() |
| + id := testAccountID() |
| + if err := s.CreateAccount(id, name, name, "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatalf("CreateAccount(%q): %v", name, err) |
| + } |
| + return &Account{ID: id, Sqid: name, Name: name} |
| +} |
| + |
| +// TestCLIPrincipalLegacyAppTokenIsInstanceRole is the migration guarantee: |
| +// a row written before this change must read back with IDENTICAL authority. |
| +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) |
| + if err != nil { |
| + t.Fatalf("NewAppToken: %v", err) |
| + } |
| + p, ok := s.CLIPrincipal(raw, now) |
| + if !ok { |
| + t.Fatal("CLIPrincipal(app token) = !ok; want ok") |
| + } |
| + if p.Kind != PrincipalService { |
| + t.Errorf("Kind = %v; want PrincipalService", p.Kind) |
| + } |
| + if p.Role != svcrole.Instance { |
| + t.Errorf("Role = %q; want %q", p.Role, svcrole.Instance) |
| + } |
| + if p.AccountID != acct.ID || p.App != "titogo" { |
| + t.Errorf("scope = %d/%q; want %d/%q", p.AccountID, p.App, acct.ID, "titogo") |
| + } |
| + // The minter is a recorded fact, never the owner. |
| + if p.Address != "paul@example.test" { |
| + t.Errorf("Address = %q; want the minter", p.Address) |
| + } |
| +} |
| + |
| +func TestCLIPrincipalUserToken(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + raw := mintUserToken(t, s, "paul@example.test", now) |
| + p, ok := s.CLIPrincipal(raw, now) |
| + if !ok { |
| + t.Fatal("CLIPrincipal(user token) = !ok; want ok") |
| + } |
| + if p.Kind != PrincipalUser { |
| + t.Errorf("Kind = %v; want PrincipalUser", p.Kind) |
| + } |
| + if p.Address != "paul@example.test" { |
| + t.Errorf("Address = %q", p.Address) |
| + } |
| + // A person carries no role and no scope. Anything else here would let a |
| + // role check read a person as a service with authority. |
| + if p.Role != "" || p.AccountID != 0 || p.App != "" || p.Name != "" { |
| + t.Errorf("person carries scope/role: %+v", p) |
| + } |
| +} |
| + |
| +func TestCLIPrincipalServiceToken(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + acct := mustAccount(t, s, "amadan") |
| + raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatalf("NewServicePrincipal: %v", err) |
| + } |
| + p, ok := s.CLIPrincipal(raw, now) |
| + if !ok { |
| + t.Fatal("CLIPrincipal(service token) = !ok; want ok") |
| + } |
| + if p.Kind != PrincipalService || p.Role != svcrole.Publish || p.Name != "hub" { |
| + t.Errorf("got %+v", p) |
| + } |
| + if p.AccountID != acct.ID { |
| + t.Errorf("AccountID = %d; want %d", p.AccountID, acct.ID) |
| + } |
| + if p.App != "" { |
| + t.Errorf("App = %q; want account-scoped (empty)", p.App) |
| + } |
| +} |
| + |
| +// TestServiceTokenIsNotAPerson is the containment property read from the |
| +// other side: the person door must not answer for a service credential, and |
| +// its refusal must be the one a fabricated token gets. |
| +func TestServiceTokenIsNotAPerson(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + acct := mustAccount(t, s, "amadan") |
| + raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatalf("NewServicePrincipal: %v", err) |
| + } |
| + if addr, ok := s.CLITokenAddress(raw, now); ok { |
| + t.Errorf("CLITokenAddress(service token) = %q,true; want \"\",false", addr) |
| + } |
| +} |
| + |
| +func TestRevokedServicePrincipalStopsResolving(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + acct := mustAccount(t, s, "amadan") |
| + raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatalf("NewServicePrincipal: %v", err) |
| + } |
| + if err := s.RevokeServicePrincipal(acct.ID, "hub"); err != nil { |
| + t.Fatalf("RevokeServicePrincipal: %v", err) |
| + } |
| + if _, ok := s.CLIPrincipal(raw, now); ok { |
| + t.Error("CLIPrincipal(revoked) = ok; want !ok") |
| + } |
| +} |
| + |
| +// TestRotateKeepsTheNameAndKillsTheOldSecret is why `name` exists as a column |
| +// separate from the token hash: rate-limit keys and audit rows stay |
| +// continuous across a secret change, and the old secret stops working in the |
| +// same call. |
| +func TestRotateKeepsTheNameAndKillsTheOldSecret(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + acct := mustAccount(t, s, "amadan") |
| + old, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatalf("NewServicePrincipal: %v", err) |
| + } |
| + fresh, err := s.RotateServicePrincipal(acct.ID, "hub", now) |
| + if err != nil { |
| + t.Fatalf("RotateServicePrincipal: %v", err) |
| + } |
| + if fresh == old { |
| + t.Fatal("rotate returned the same secret") |
| + } |
| + if _, ok := s.CLIPrincipal(old, now); ok { |
| + t.Error("old secret still resolves after rotate") |
| + } |
| + p, ok := s.CLIPrincipal(fresh, now) |
| + if !ok { |
| + t.Fatal("fresh secret does not resolve") |
| + } |
| + if p.Name != "hub" || p.Role != svcrole.Publish { |
| + t.Errorf("rotate changed identity: %+v", p) |
| + } |
| +} |
| + |
| +// TestNameIsUniquePerAccountAmongLiveRows — the name is the handle every |
| +// other surface addresses, so two live rows sharing one would make rotate and |
| +// revoke ambiguous. |
| +func TestNameIsUniquePerAccountAmongLiveRows(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.NewServicePrincipal(acct.ID, "hub", "", svcrole.Operate, "paul@example.test", now); err == nil { |
| + t.Fatal("second mint with the same name succeeded; want ErrServiceNameTaken") |
| + } |
| + // A different account may hold the same name: the namespace is per |
| + // account, like an app name, not global like a fleet name. |
| + other := mustAccount(t, s, "other") |
| + if _, err := s.NewServicePrincipal(other.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { |
| + t.Errorf("same name in another account: %v", err) |
| + } |
| +} |
| + |
| +// TestUnparseableRoleRefusesRatherThanFallingThrough: a kind='service' row |
| +// whose role name this binary does not know must refuse outright, never |
| +// resolve as a role-less service a gate might treat as anything. |
| +func TestUnparseableRoleRefusesRatherThanFallingThrough(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + acct := mustAccount(t, s, "amadan") |
| + raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatalf("NewServicePrincipal: %v", err) |
| + } |
| + if _, err := s.db.Exec(`UPDATE cli_tokens SET role = 'made-up-role' WHERE token_hash = ?`, hashToken(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := s.CLIPrincipal(raw, now); ok { |
| + t.Error("CLIPrincipal(unparseable role) = ok; want !ok") |
| + } |
| +} |
| + |
| +// TestServicePrincipalsListsBothKinds: the account's listing must show |
| +// legacy kind='app' rows too, normalized to the instance role, or absorbing |
| +// them into the new model would make them invisible to their own owner. |
| +func TestServicePrincipalsListsBothKinds(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) |
| + } |
| + if _, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now); err != nil { |
| + t.Fatalf("NewAppToken: %v", err) |
| + } |
| + toks, err := s.ServicePrincipals(acct.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(toks) != 2 { |
| + t.Fatalf("ServicePrincipals = %d rows, want 2", len(toks)) |
| + } |
| + var svc, app *CLIToken |
| + for i := range toks { |
| + switch toks[i].Kind { |
| + case cliKindApp: |
| + app = &toks[i] |
| + case cliKindService: |
| + svc = &toks[i] |
| + } |
| + } |
| + if svc == nil || app == nil { |
| + t.Fatalf("ServicePrincipals = %+v, want one row of each kind", toks) |
| + } |
| + if svc.Name != "hub" || svc.Role != string(svcrole.Publish) { |
| + t.Errorf("service row = %+v", svc) |
| + } |
| + if app.Role != string(svcrole.Instance) { |
| + t.Errorf("legacy app row Role = %q; want normalized to %q", app.Role, svcrole.Instance) |
| + } |
| +} |
| + |
| +// TestRotateAndRevokeIgnoreLegacyNamelessRows: a legacy kind='app' row's name |
| +// is ”, and RotateServicePrincipal/RevokeServicePrincipal must never treat |
| +// that empty string as a name to match — that would let one call rotate or |
| +// revoke an unrelated, unnamed credential. |
| +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) |
| + if err != nil { |
| + t.Fatalf("NewAppToken: %v", err) |
| + } |
| + 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 { |
| + t.Fatal("RevokeServicePrincipal(\"\") succeeded against a legacy row; want a refusal") |
| + } |
| + // The legacy app token must still work: neither call touched it. |
| + if _, ok := s.CLIPrincipal(app, now); !ok { |
| + t.Error("legacy app token stopped resolving after a no-op rotate/revoke by empty name") |
| + } |
| +} |
| + |
| +// TestRevokeAccountTokenScopesByAccountNotAddress: the only handle a legacy |
| +// nameless row has is its listing id, and the account (not the minting |
| +// address) must be able to use it. |
| +func TestRevokeAccountTokenScopesByAccountNotAddress(t *testing.T) { |
| + s := openTestStore(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) |
| + if err != nil { |
| + t.Fatalf("NewAppToken: %v", err) |
| + } |
| + id := hashToken(app) |
| + if err := s.RevokeAccountToken(other.ID, id); 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 { |
| + t.Fatalf("RevokeAccountToken: %v", err) |
| + } |
| + if _, ok := s.CLIPrincipal(app, now); ok { |
| + t.Error("token still resolves after RevokeAccountToken") |
| + } |
| +} |