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 (
44 "database/sql"
55 "errors"
66 "fmt"
7 "strings"
78 "time"
89)
910
1011type APIToken struct {
12 ID int64
1113 Name string
1214 Scope string
1315 CreatedAt string
1416 ExpiresAt *time.Time
1517 LastUsedAt *time.Time
18 CreatedBy string // name of the token that created this one; "" for none
1619}
1720
18// CreateAPIToken stores a token hash; expires nil means no expiry.
19func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time) error {
21// nullID stores 0 as NULL.
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 {
2032 var exp any
2133 if expires != nil {
2234 exp = fmtTime(*expires)
2335 }
2436 _, err := s.DB.Exec(
25 "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?, ?)",
26 userID, name, tokenHash, scope, exp)
37 "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at, created_by_token) VALUES (?, ?, ?, ?, ?, ?)",
38 userID, name, tokenHash, scope, exp, nullID(createdByToken))
2739 if isUniqueErr(err) {
2840 return fmt.Errorf("you already have a token named %q", name)
2941 }
3042 return err
3143}
3244
33// APITokenUser resolves a presented token to its user and scope; expired and
34// unknown tokens fail identically.
35func (s *Store) APITokenUser(tokenHash string) (User, string, error) {
45// APITokenUser resolves a presented token to its user and the token;
46// expired and unknown tokens fail identically.
47func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error) {
3648 var userID int64
37 var scope string
49 var t APIToken
50 var exp sql.NullString
3851 err := s.DB.QueryRow(`
39 SELECT user_id, scope FROM api_tokens
52 SELECT user_id, id, name, scope, expires_at FROM api_tokens
4053 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)
4255 if errors.Is(err, sql.ErrNoRows) {
43 return User{}, "", ErrNotFound
56 return User{}, APIToken{}, ErrNotFound
4457 }
4558 if err != nil {
46 return User{}, "", err
59 return User{}, APIToken{}, err
4760 }
61 t.ExpiresAt = parseTime(exp)
4862 s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash)
4963 u, err := s.UserByID(userID)
50 return u, scope, err
64 return u, t, err
5165}
5266
5367func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
54 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)
68 rows, err := s.DB.Query(`
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)
5672 if err != nil {
5773 return nil, err
5874 }
@@ -61,7 +77,7 @@ func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
6177 for rows.Next() {
6278 var t APIToken
6379 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 {
6581 return nil, err
6682 }
6783 t.ExpiresAt = parseTime(exp)
@@ -71,13 +87,92 @@ func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
7187 return out, rows.Err()
7288}
7389
74func (s *Store) RevokeAPIToken(userID int64, name string) error {
75 res, err := s.DB.Exec("DELETE FROM api_tokens WHERE user_id = ? AND name = ?", userID, name)
90// Created is what a token made, directly or through tokens it made:
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()
76107 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
78173 }
79 if n, _ := res.RowsAffected(); n == 0 {
80 return ErrNotFound
174 if len(keyIDs) > 0 {
175 s.announce(Revoked{KeyIDs: keyIDs})
81176 }
82 return nil
177 return c, nil
83178}
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 {
2525 Label string // "" when the key was added with no name
2626 CreatedAt string
2727 LastUsedAt string // "" when the key has never authenticated
28 CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only.
2829}
2930
3031// ErrDuplicateKey carries the exact user-facing message from the spec. It
@@ -270,16 +271,26 @@ func (s *Store) UserByID(id int64) (User, error) {
270271 return u, err
271272}
272273
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
273279// AddSSHKey registers a key and bumps the key epoch in one transaction.
274280func (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 {
275286 tx, err := s.DB.Begin()
276287 if err != nil {
277288 return err
278289 }
279290 defer tx.Rollback()
280291 if _, err := tx.Exec(
281 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label) VALUES (?, ?, ?, ?, ?, ?)",
282 userID, fingerprint, algo, blob, scope, label); err != nil {
292 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token) VALUES (?, ?, ?, ?, ?, ?, ?)",
293 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken)); err != nil {
283294 if isUniqueErr(err) {
284295 return ErrDuplicateKey
285296 }
@@ -343,8 +354,10 @@ func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
343354
344355func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
345356 rows, err := s.DB.Query(
346 `SELECT id, user_id, fingerprint, algo, blob, scope, label, created_at, COALESCE(last_used_at, '')
347 FROM ssh_keys WHERE user_id = ? ORDER BY id`,
357 `SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at,
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`,
348361 userID)
349362 if err != nil {
350363 return nil, err
@@ -353,7 +366,7 @@ func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
353366 var keys []SSHKey
354367 for rows.Next() {
355368 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 {
357370 return nil, err
358371 }
359372 keys = append(keys, k)