carlos / platform Public

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
Paul Campbell 550a889827b40010ea8b479d827c7178d635866a parent 92427a9
4 files changed, +637 −24
  • internal/console/authstore.go +63 −24
  • internal/console/svcfixture_test.go +21 −0
  • internal/console/svcprincipal.go +266 −0
  • internal/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")
+ }
+}