Commit 4b94fa96a1

4b94fa96a19d8b59773e5f62689a31bf45558d05

parent: 4bbf4f8114

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 06:49 UTC

store: tokens and keys record the token that created them; chained revoke

Ref #257

Layout: unified · split

internal/store/migrations/0060_credential_origin.down.sql added +2
@@ -0,0 +1,2 @@
1ALTER TABLE ssh_keys DROP COLUMN created_by_token;
2ALTER TABLE api_tokens DROP COLUMN created_by_token;
internal/store/migrations/0060_credential_origin.up.sql added +4
@@ -0,0 +1,4 @@
1-- The API token a credential was created through. NULL when it was not,
2-- and once that token is revoked.
3ALTER TABLE api_tokens ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL;
4ALTER TABLE ssh_keys ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL;
internal/store/tokens.go +117 −22
@@ -4,55 +4,71 @@ import (
4 "database/sql" 4 "database/sql"
5 "errors" 5 "errors"
6 "fmt" 6 "fmt"
7 "strings"
7 "time" 8 "time"
8) 9)
9 10
10type APIToken struct { 11type APIToken struct {
12 ID int64
11 Name string 13 Name string
12 Scope string 14 Scope string
13 CreatedAt string 15 CreatedAt string
14 ExpiresAt *time.Time 16 ExpiresAt *time.Time
15 LastUsedAt *time.Time 17 LastUsedAt *time.Time
18 CreatedBy string // name of the token that created this one; "" for none
16} 19}
17 20
18// CreateAPIToken stores a token hash; expires nil means no expiry. 21// nullID stores 0 as NULL.
19func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time) error { 22func nullID(id int64) any {
23 if id == 0 {
24 return nil
25 }
26 return id
27}
28
29// CreateAPIToken stores a token hash; expires nil means no expiry,
30// createdByToken 0 means it was not created through a token.
31func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time, createdByToken int64) error {
20 var exp any 32 var exp any
21 if expires != nil { 33 if expires != nil {
22 exp = fmtTime(*expires) 34 exp = fmtTime(*expires)
23 } 35 }
24 _, err := s.DB.Exec( 36 _, err := s.DB.Exec(
25 "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?, ?)", 37 "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at, created_by_token) VALUES (?, ?, ?, ?, ?, ?)",
26 userID, name, tokenHash, scope, exp) 38 userID, name, tokenHash, scope, exp, nullID(createdByToken))
27 if isUniqueErr(err) { 39 if isUniqueErr(err) {
28 return fmt.Errorf("you already have a token named %q", name) 40 return fmt.Errorf("you already have a token named %q", name)
29 } 41 }
30 return err 42 return err
31} 43}
32 44
33// APITokenUser resolves a presented token to its user and scope; expired and 45// APITokenUser resolves a presented token to its user and the token;
34// unknown tokens fail identically. 46// expired and unknown tokens fail identically.
35func (s *Store) APITokenUser(tokenHash string) (User, string, error) { 47func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error) {
36 var userID int64 48 var userID int64
37 var scope string 49 var t APIToken
50 var exp sql.NullString
38 err := s.DB.QueryRow(` 51 err := s.DB.QueryRow(`
39 SELECT user_id, scope FROM api_tokens 52 SELECT user_id, id, name, scope, expires_at FROM api_tokens
40 WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`, 53 WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`,
41 tokenHash, fmtTime(time.Now())).Scan(&userID, &scope) 54 tokenHash, fmtTime(time.Now())).Scan(&userID, &t.ID, &t.Name, &t.Scope, &exp)
42 if errors.Is(err, sql.ErrNoRows) { 55 if errors.Is(err, sql.ErrNoRows) {
43 return User{}, "", ErrNotFound 56 return User{}, APIToken{}, ErrNotFound
44 } 57 }
45 if err != nil { 58 if err != nil {
46 return User{}, "", err 59 return User{}, APIToken{}, err
47 } 60 }
61 t.ExpiresAt = parseTime(exp)
48 s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash) 62 s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash)
49 u, err := s.UserByID(userID) 63 u, err := s.UserByID(userID)
50 return u, scope, err 64 return u, t, err
51} 65}
52 66
53func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) { 67func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
54 rows, err := s.DB.Query( 68 rows, err := s.DB.Query(`
55 "SELECT name, scope, created_at, expires_at, last_used_at FROM api_tokens WHERE user_id = ? ORDER BY name", userID) 69 SELECT t.id, t.name, t.scope, t.created_at, t.expires_at, t.last_used_at, COALESCE(p.name, '')
70 FROM api_tokens t LEFT JOIN api_tokens p ON p.id = t.created_by_token
71 WHERE t.user_id = ? ORDER BY t.name`, userID)
56 if err != nil { 72 if err != nil {
57 return nil, err 73 return nil, err
58 } 74 }
@@ -61,7 +77,7 @@ func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
61 for rows.Next() { 77 for rows.Next() {
62 var t APIToken 78 var t APIToken
63 var exp, used sql.NullString 79 var exp, used sql.NullString
64 if err := rows.Scan(&t.Name, &t.Scope, &t.CreatedAt, &exp, &used); err != nil { 80 if err := rows.Scan(&t.ID, &t.Name, &t.Scope, &t.CreatedAt, &exp, &used, &t.CreatedBy); err != nil {
65 return nil, err 81 return nil, err
66 } 82 }
67 t.ExpiresAt = parseTime(exp) 83 t.ExpiresAt = parseTime(exp)
@@ -71,13 +87,92 @@ func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
71 return out, rows.Err() 87 return out, rows.Err()
72} 88}
73 89
74func (s *Store) RevokeAPIToken(userID int64, name string) error { 90// Created is what a token made, directly or through tokens it made:
75 res, err := s.DB.Exec("DELETE FROM api_tokens WHERE user_id = ? AND name = ?", userID, name) 91// token names and SSH key fingerprints.
92type Created struct {
93 Tokens []string `json:"tokens"`
94 Keys []string `json:"keys"`
95}
96
97// chainCTE selects the token named by the first argument and every
98// token created from it, at any depth.
99const chainCTE = `WITH RECURSIVE chain(id) AS (
100 SELECT ? UNION SELECT t.id FROM api_tokens t JOIN chain ON t.created_by_token = chain.id)`
101
102// RevokeAPIToken deletes the user's token by name and returns what it
103// created. withCreated deletes those too; otherwise they stay and lose
104// the link to the revoked token.
105func (s *Store) RevokeAPIToken(userID int64, name string, withCreated bool) (Created, error) {
106 tx, err := s.DB.Begin()
76 if err != nil { 107 if err != nil {
77 return err 108 return Created{}, err
109 }
110 defer tx.Rollback()
111 var id int64
112 err = tx.QueryRow("SELECT id FROM api_tokens WHERE user_id = ? AND name = ?", userID, name).Scan(&id)
113 if errors.Is(err, sql.ErrNoRows) {
114 return Created{}, ErrNotFound
115 }
116 if err != nil {
117 return Created{}, err
118 }
119 var c Created
120 rows, err := tx.Query(chainCTE+` SELECT name FROM api_tokens WHERE id IN (SELECT id FROM chain) AND id != ? ORDER BY name`, id, id)
121 if err != nil {
122 return Created{}, err
123 }
124 for rows.Next() {
125 var n string
126 if err := rows.Scan(&n); err != nil {
127 rows.Close()
128 return Created{}, err
129 }
130 c.Tokens = append(c.Tokens, n)
131 }
132 rows.Close()
133 var keyIDs []int64
134 rows, err = tx.Query(chainCTE+` SELECT id, fingerprint FROM ssh_keys WHERE created_by_token IN (SELECT id FROM chain) ORDER BY id`, id)
135 if err != nil {
136 return Created{}, err
137 }
138 for rows.Next() {
139 var kid int64
140 var fp string
141 if err := rows.Scan(&kid, &fp); err != nil {
142 rows.Close()
143 return Created{}, err
144 }
145 keyIDs = append(keyIDs, kid)
146 c.Keys = append(c.Keys, fp)
147 }
148 rows.Close()
149
150 if !withCreated {
151 if _, err := tx.Exec("DELETE FROM api_tokens WHERE id = ?", id); err != nil {
152 return Created{}, err
153 }
154 return c, tx.Commit()
155 }
156 if len(keyIDs) > 0 {
157 args := make([]any, len(keyIDs))
158 for i, k := range keyIDs {
159 args[i] = k
160 }
161 if _, err := tx.Exec("DELETE FROM ssh_keys WHERE id IN (?"+strings.Repeat(", ?", len(keyIDs)-1)+")", args...); err != nil {
162 return Created{}, err
163 }
164 if err := bumpKeyEpoch(tx); err != nil {
165 return Created{}, err
166 }
167 }
168 if _, err := tx.Exec(chainCTE+` DELETE FROM api_tokens WHERE id IN (SELECT id FROM chain)`, id); err != nil {
169 return Created{}, err
170 }
171 if err := tx.Commit(); err != nil {
172 return Created{}, err
78 } 173 }
79 if n, _ := res.RowsAffected(); n == 0 { 174 if len(keyIDs) > 0 {
80 return ErrNotFound 175 s.announce(Revoked{KeyIDs: keyIDs})
81 } 176 }
82 return nil 177 return c, nil
83} 178}
internal/store/tokens_test.go added +115
@@ -0,0 +1,115 @@
1package store
2
3import (
4 "slices"
5 "testing"
6 "time"
7)
8
9func tokenID(t *testing.T, s *Store, hash string) int64 {
10 t.Helper()
11 _, tok, err := s.APITokenUser(hash)
12 if err != nil {
13 t.Fatal(err)
14 }
15 return tok.ID
16}
17
18// parent made child, child made grandchild and a key; the key belongs
19// to another account, as admin user create --key makes one.
20func tokenChain(t *testing.T) (*Store, int64, *[]Revoked) {
21 t.Helper()
22 s, uid, got := revokeFixture(t)
23 bob, err := s.CreateUser("bob", false)
24 if err != nil {
25 t.Fatal(err)
26 }
27 if err := s.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
28 t.Fatal(err)
29 }
30 if err := s.CreateAPIToken(uid, "child", "h-child", "full", nil, tokenID(t, s, "h-parent")); err != nil {
31 t.Fatal(err)
32 }
33 child := tokenID(t, s, "h-child")
34 if err := s.CreateAPIToken(uid, "grandchild", "h-grand", "read", nil, child); err != nil {
35 t.Fatal(err)
36 }
37 if err := s.AddSSHKeyFrom(bob, "SHA256:k", "ssh-ed25519", []byte("k"), "full", "", KeyOrigin{CreatedByToken: child}); err != nil {
38 t.Fatal(err)
39 }
40 return s, uid, got
41}
42
43func TestTokenRecordsItsCreator(t *testing.T) {
44 s, uid, _ := tokenChain(t)
45 toks, err := s.ListAPITokens(uid)
46 if err != nil {
47 t.Fatal(err)
48 }
49 by := map[string]string{}
50 for _, tk := range toks {
51 by[tk.Name] = tk.CreatedBy
52 }
53 if by["parent"] != "" || by["child"] != "parent" || by["grandchild"] != "child" {
54 t.Fatalf("created by: %v", by)
55 }
56 bob, _ := s.UserByUsername("bob")
57 keys, err := s.ListSSHKeys(bob.ID)
58 if err != nil || len(keys) != 1 || keys[0].CreatedBy != "child" {
59 t.Fatalf("key created by: %+v %v", keys, err)
60 }
61}
62
63func TestRevokeAPITokenListsWhatItCreated(t *testing.T) {
64 s, uid, got := tokenChain(t)
65 c, err := s.RevokeAPIToken(uid, "parent", false)
66 if err != nil {
67 t.Fatal(err)
68 }
69 if !slices.Equal(c.Tokens, []string{"child", "grandchild"}) || !slices.Equal(c.Keys, []string{"SHA256:k"}) {
70 t.Fatalf("created = %+v", c)
71 }
72 // Listed, not removed; the link to the revoked parent is gone.
73 toks, _ := s.ListAPITokens(uid)
74 if len(toks) != 2 || toks[0].Name != "child" || toks[0].CreatedBy != "" {
75 t.Fatalf("tokens after revoke: %+v", toks)
76 }
77 if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != nil {
78 t.Fatalf("the key went: %v", err)
79 }
80 if len(*got) != 0 {
81 t.Fatalf("announced %+v with nothing revoked but the token", *got)
82 }
83}
84
85func TestRevokeAPITokenWithCreated(t *testing.T) {
86 s, uid, got := tokenChain(t)
87 k, _ := s.SSHKeyByFingerprint("SHA256:k")
88 if _, err := s.RevokeAPIToken(uid, "parent", true); err != nil {
89 t.Fatal(err)
90 }
91 if toks, _ := s.ListAPITokens(uid); len(toks) != 0 {
92 t.Fatalf("tokens left: %+v", toks)
93 }
94 if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != ErrNotFound {
95 t.Fatalf("key left: %v", err)
96 }
97 if len(*got) != 1 || !slices.Equal((*got)[0].KeyIDs, []int64{k.ID}) {
98 t.Fatalf("announced %+v", *got)
99 }
100 if _, err := s.RevokeAPIToken(uid, "parent", true); err != ErrNotFound {
101 t.Fatalf("second revoke: %v", err)
102 }
103}
104
105func TestAPITokenUserCarriesExpiry(t *testing.T) {
106 s, uid, _ := revokeFixture(t)
107 exp := time.Now().Add(time.Hour)
108 if err := s.CreateAPIToken(uid, "brief", "h-brief", "full", &exp, 0); err != nil {
109 t.Fatal(err)
110 }
111 _, tok, err := s.APITokenUser("h-brief")
112 if err != nil || tok.ExpiresAt == nil || tok.Name != "brief" || tok.ID == 0 {
113 t.Fatalf("token %+v %v", tok, err)
114 }
115}
internal/store/users.go +18 −5
@@ -25,6 +25,7 @@ type SSHKey struct {
25 Label string // "" when the key was added with no name 25 Label string // "" when the key was added with no name
26 CreatedAt string 26 CreatedAt string
27 LastUsedAt string // "" when the key has never authenticated 27 LastUsedAt string // "" when the key has never authenticated
28 CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only.
28} 29}
29 30
30// ErrDuplicateKey carries the exact user-facing message from the spec. It 31// ErrDuplicateKey carries the exact user-facing message from the spec. It
@@ -270,16 +271,26 @@ func (s *Store) UserByID(id int64) (User, error) {
270 return u, err 271 return u, err
271} 272}
272 273
274// KeyOrigin is how a key came to be.
275type KeyOrigin struct {
276 CreatedByToken int64 // the API token that added it; 0 for none
277}
278
273// AddSSHKey registers a key and bumps the key epoch in one transaction. 279// AddSSHKey registers a key and bumps the key epoch in one transaction.
274func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error { 280func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error {
281 return s.AddSSHKeyFrom(userID, fingerprint, algo, blob, scope, label, KeyOrigin{})
282}
283
284// AddSSHKeyFrom is AddSSHKey recording where the key came from.
285func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error {
275 tx, err := s.DB.Begin() 286 tx, err := s.DB.Begin()
276 if err != nil { 287 if err != nil {
277 return err 288 return err
278 } 289 }
279 defer tx.Rollback() 290 defer tx.Rollback()
280 if _, err := tx.Exec( 291 if _, err := tx.Exec(
281 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label) VALUES (?, ?, ?, ?, ?, ?)", 292 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token) VALUES (?, ?, ?, ?, ?, ?, ?)",
282 userID, fingerprint, algo, blob, scope, label); err != nil { 293 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken)); err != nil {
283 if isUniqueErr(err) { 294 if isUniqueErr(err) {
284 return ErrDuplicateKey 295 return ErrDuplicateKey
285 } 296 }
@@ -343,8 +354,10 @@ func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
343 354
344func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { 355func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
345 rows, err := s.DB.Query( 356 rows, err := s.DB.Query(
346 `SELECT id, user_id, fingerprint, algo, blob, scope, label, created_at, COALESCE(last_used_at, '') 357 `SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at,
347 FROM ssh_keys WHERE user_id = ? ORDER BY id`, 358 COALESCE(k.last_used_at, ''), COALESCE(t.name, '')
359 FROM ssh_keys k LEFT JOIN api_tokens t ON t.id = k.created_by_token
360 WHERE k.user_id = ? ORDER BY k.id`,
348 userID) 361 userID)
349 if err != nil { 362 if err != nil {
350 return nil, err 363 return nil, err
@@ -353,7 +366,7 @@ func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
353 var keys []SSHKey 366 var keys []SSHKey
354 for rows.Next() { 367 for rows.Next() {
355 var k SSHKey 368 var k SSHKey
356 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt); err != nil { 369 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt, &k.CreatedBy); err != nil {
357 return nil, err 370 return nil, err
358 } 371 }
359 keys = append(keys, k) 372 keys = append(keys, k)