Commit 9f77ab1b00

9f77ab1b00d30c62a47e7dad42a868942e7b7c4f

parent: b711b70da5

Verified · cmc

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

store: announce key revocations; LiveSSHKeys

Ref #256

Layout: unified · split

internal/store/revoke.go added +60
@@ -0,0 +1,60 @@
1package store
2
3import (
4 "slices"
5 "strings"
6)
7
8// Revoked names SSH keys that stopped being valid: by id, or every key
9// of an account. The SSH listener closes the connections they opened.
10type Revoked struct {
11 KeyIDs []int64
12 UserID int64 // every key of this account; 0 for none
13}
14
15// OnRevoke registers f to run after each revocation this process
16// commits. Revocations committed by another process (gitbayd admin on
17// the host) are not announced; the listener's sweep finds those.
18func (s *Store) OnRevoke(f func(Revoked)) {
19 s.revokeMu.Lock()
20 defer s.revokeMu.Unlock()
21 s.onRevoke = append(s.onRevoke, f)
22}
23
24// announce runs the subscribers. Call it after the commit, outside any
25// transaction.
26func (s *Store) announce(r Revoked) {
27 s.revokeMu.Lock()
28 fs := slices.Clone(s.onRevoke)
29 s.revokeMu.Unlock()
30 for _, f := range fs {
31 f(r)
32 }
33}
34
35// LiveSSHKeys reports which of ids still name a registered key on an
36// account that is not disabled.
37func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) {
38 live := map[int64]bool{}
39 if len(ids) == 0 {
40 return live, nil
41 }
42 args := make([]any, len(ids))
43 for i, id := range ids {
44 args[i] = id
45 }
46 rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id
47 WHERE u.disabled = 0 AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...)
48 if err != nil {
49 return nil, err
50 }
51 defer rows.Close()
52 for rows.Next() {
53 var id int64
54 if err := rows.Scan(&id); err != nil {
55 return nil, err
56 }
57 live[id] = true
58 }
59 return live, rows.Err()
60}
internal/store/revoke_test.go added +107
@@ -0,0 +1,107 @@
1package store
2
3import (
4 "slices"
5 "testing"
6)
7
8func revokeFixture(t *testing.T) (*Store, int64, *[]Revoked) {
9 t.Helper()
10 s := open(t)
11 if err := s.MigrateUp(); err != nil {
12 t.Fatal(err)
13 }
14 uid, err := s.CreateUser("alice", false)
15 if err != nil {
16 t.Fatal(err)
17 }
18 var got []Revoked
19 s.OnRevoke(func(r Revoked) { got = append(got, r) })
20 return s, uid, &got
21}
22
23func keyID(t *testing.T, s *Store, fp string) int64 {
24 t.Helper()
25 k, err := s.SSHKeyByFingerprint(fp)
26 if err != nil {
27 t.Fatal(err)
28 }
29 return k.ID
30}
31
32func TestRemovalsAnnounceTheirKeys(t *testing.T) {
33 s, uid, got := revokeFixture(t)
34 if err := s.AddSSHKey(uid, "SHA256:a", "ssh-ed25519", []byte("a"), "full", ""); err != nil {
35 t.Fatal(err)
36 }
37 if err := s.AddSSHKey(uid, "SHA256:d", "ssh-ed25519", []byte("d"), "deploy:7:ro", ""); err != nil {
38 t.Fatal(err)
39 }
40 a, d := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:d")
41
42 if err := s.RemoveSSHKey(uid, "SHA256:a"); err != nil {
43 t.Fatal(err)
44 }
45 if err := s.RemoveDeployKey(7, "SHA256:d"); err != nil {
46 t.Fatal(err)
47 }
48 if err := s.SetUserDisabled(uid, true); err != nil {
49 t.Fatal(err)
50 }
51 if err := s.SetUserDisabled(uid, false); err != nil {
52 t.Fatal(err)
53 }
54 want := []Revoked{{KeyIDs: []int64{a}}, {KeyIDs: []int64{d}}, {UserID: uid}}
55 if !slices.EqualFunc(*got, want, func(x, y Revoked) bool {
56 return slices.Equal(x.KeyIDs, y.KeyIDs) && x.UserID == y.UserID
57 }) {
58 t.Fatalf("announced %+v, want %+v (enabling announces nothing)", *got, want)
59 }
60 // A removal that found nothing announces nothing.
61 if err := s.RemoveSSHKey(uid, "SHA256:a"); err != ErrNotFound {
62 t.Fatalf("second remove: %v", err)
63 }
64 if len(*got) != 3 {
65 t.Fatalf("a miss was announced: %+v", *got)
66 }
67}
68
69func TestDeleteUserAnnounces(t *testing.T) {
70 s, uid, got := revokeFixture(t)
71 if err := s.DeleteUser(uid); err != nil {
72 t.Fatal(err)
73 }
74 if len(*got) != 1 || (*got)[0].UserID != uid {
75 t.Fatalf("announced %+v", *got)
76 }
77}
78
79func TestLiveSSHKeys(t *testing.T) {
80 s, uid, _ := revokeFixture(t)
81 bob, err := s.CreateUser("bob", false)
82 if err != nil {
83 t.Fatal(err)
84 }
85 for _, k := range []struct {
86 uid int64
87 fp string
88 }{{uid, "SHA256:a"}, {bob, "SHA256:b"}} {
89 if err := s.AddSSHKey(k.uid, k.fp, "ssh-ed25519", []byte(k.fp), "full", ""); err != nil {
90 t.Fatal(err)
91 }
92 }
93 a, b := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:b")
94 if _, err := s.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", bob); err != nil {
95 t.Fatal(err)
96 }
97 live, err := s.LiveSSHKeys([]int64{a, b, 999})
98 if err != nil {
99 t.Fatal(err)
100 }
101 if !live[a] || live[b] || live[999] {
102 t.Fatalf("live = %v; want only %d", live, a)
103 }
104 if live, err := s.LiveSSHKeys(nil); err != nil || len(live) != 0 {
105 t.Fatalf("no ids: %v %v", live, err)
106 }
107}
internal/store/store.go +4
@@ -27,6 +27,10 @@ type Store struct {
27 // by the next change to that build's row (BuildLogWait). 27 // by the next change to that build's row (BuildLogWait).
28 logMu sync.Mutex 28 logMu sync.Mutex
29 logWait map[int64]chan struct{} 29 logWait map[int64]chan struct{}
30
31 // onRevoke runs after each key revocation this process commits.
32 revokeMu sync.Mutex
33 onRevoke []func(Revoked)
30} 34}
31 35
32// Open opens (creating if needed) the database at path with WAL mode and 36// Open opens (creating if needed) the database at path with WAL mode and
internal/store/users.go +28 −14
@@ -95,6 +95,7 @@ func (s *Store) DeleteUser(id int64) error {
95 if n, _ := res.RowsAffected(); n == 0 { 95 if n, _ := res.RowsAffected(); n == 0 {
96 return ErrNotFound 96 return ErrNotFound
97 } 97 }
98 s.announce(Revoked{UserID: id})
98 return nil 99 return nil
99} 100}
100 101
@@ -171,7 +172,8 @@ func (s *Store) ListEmails(userID int64) ([]Email, error) {
171// SetUserDisabled suspends or restores an account. Disabling drops every 172// SetUserDisabled suspends or restores an account. Disabling drops every
172// credential that would grant a session on its own — web sessions, API 173// credential that would grant a session on its own — web sessions, API
173// tokens, unclaimed login links — and leaves the SSH keys registered but 174// tokens, unclaimed login links — and leaves the SSH keys registered but
174// refused at every entry point until re-enabled. 175// refused at every entry point until re-enabled; connections they opened
176// are closed.
175func (s *Store) SetUserDisabled(userID int64, disabled bool) error { 177func (s *Store) SetUserDisabled(userID int64, disabled bool) error {
176 v := 0 178 v := 0
177 if disabled { 179 if disabled {
@@ -192,6 +194,7 @@ func (s *Store) SetUserDisabled(userID int64, disabled bool) error {
192 return err 194 return err
193 } 195 }
194 } 196 }
197 s.announce(Revoked{UserID: userID})
195 } 198 }
196 return err 199 return err
197} 200}
@@ -288,24 +291,30 @@ func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, s
288 return tx.Commit() 291 return tx.Commit()
289} 292}
290 293
291// RemoveSSHKey removes a key owned by userID and bumps the key epoch. 294// RemoveSSHKey removes a key owned by userID, bumps the key epoch, and
295// announces the revocation.
292func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error { 296func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error {
293 tx, err := s.DB.Begin() 297 tx, err := s.DB.Begin()
294 if err != nil { 298 if err != nil {
295 return err 299 return err
296 } 300 }
297 defer tx.Rollback() 301 defer tx.Rollback()
298 res, err := tx.Exec("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ?", userID, fingerprint) 302 var id int64
303 err = tx.QueryRow("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ? RETURNING id", userID, fingerprint).Scan(&id)
304 if errors.Is(err, sql.ErrNoRows) {
305 return ErrNotFound
306 }
299 if err != nil { 307 if err != nil {
300 return err 308 return err
301 } 309 }
302 if n, _ := res.RowsAffected(); n == 0 {
303 return ErrNotFound
304 }
305 if err := bumpKeyEpoch(tx); err != nil { 310 if err := bumpKeyEpoch(tx); err != nil {
306 return err 311 return err
307 } 312 }
308 return tx.Commit() 313 if err := tx.Commit(); err != nil {
314 return err
315 }
316 s.announce(Revoked{KeyIDs: []int64{id}})
317 return nil
309} 318}
310 319
311// SetSSHKeyLabel renames a key owned by userID. Labels do not touch the 320// SetSSHKeyLabel renames a key owned by userID. Labels do not touch the
@@ -538,17 +547,22 @@ func (s *Store) RemoveDeployKey(repoID int64, fingerprint string) error {
538 return err 547 return err
539 } 548 }
540 defer tx.Rollback() 549 defer tx.Rollback()
541 res, err := tx.Exec( 550 var id int64
542 "DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%'", 551 err = tx.QueryRow(
543 fingerprint, repoID) 552 "DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%' RETURNING id",
553 fingerprint, repoID).Scan(&id)
554 if errors.Is(err, sql.ErrNoRows) {
555 return ErrNotFound
556 }
544 if err != nil { 557 if err != nil {
545 return err 558 return err
546 } 559 }
547 if n, _ := res.RowsAffected(); n == 0 {
548 return ErrNotFound
549 }
550 if err := bumpKeyEpoch(tx); err != nil { 560 if err := bumpKeyEpoch(tx); err != nil {
551 return err 561 return err
552 } 562 }
553 return tx.Commit() 563 if err := tx.Commit(); err != nil {
564 return err
565 }
566 s.announce(Revoked{KeyIDs: []int64{id}})
567 return nil
554} 568}