Commit 9f77ab1b00
Verified · cmc
Layout: unified · split
internal/store/revoke.go added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 10 | type 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. | |
| 18 | func (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. | |
| 26 | func (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. | |
| 37 | func (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 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "slices" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | func 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 | ||
| 23 | func 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 | ||
| 32 | func 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 | ||
| 69 | func 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 | ||
| 79 | func 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 | 27 | // by the next change to that build's row (BuildLogWait). |
| 28 | 28 | logMu sync.Mutex |
| 29 | 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 | 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 | 95 | if n, _ := res.RowsAffected(); n == 0 { |
| 96 | 96 | return ErrNotFound |
| 97 | 97 | } |
| 98 | s.announce(Revoked{UserID: id}) | |
| 98 | 99 | return nil |
| 99 | 100 | } |
| 100 | 101 | |
| @@ -171,7 +172,8 @@ func (s *Store) ListEmails(userID int64) ([]Email, error) { | ||
| 171 | 172 | // SetUserDisabled suspends or restores an account. Disabling drops every |
| 172 | 173 | // credential that would grant a session on its own — web sessions, API |
| 173 | 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. | |
| 175 | 177 | func (s *Store) SetUserDisabled(userID int64, disabled bool) error { |
| 176 | 178 | v := 0 |
| 177 | 179 | if disabled { |
| @@ -192,6 +194,7 @@ func (s *Store) SetUserDisabled(userID int64, disabled bool) error { | ||
| 192 | 194 | return err |
| 193 | 195 | } |
| 194 | 196 | } |
| 197 | s.announce(Revoked{UserID: userID}) | |
| 195 | 198 | } |
| 196 | 199 | return err |
| 197 | 200 | } |
| @@ -288,24 +291,30 @@ func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, s | ||
| 288 | 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. | |
| 292 | 296 | func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error { |
| 293 | 297 | tx, err := s.DB.Begin() |
| 294 | 298 | if err != nil { |
| 295 | 299 | return err |
| 296 | 300 | } |
| 297 | 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 | 307 | if err != nil { |
| 300 | 308 | return err |
| 301 | 309 | } |
| 302 | if n, _ := res.RowsAffected(); n == 0 { | |
| 303 | return ErrNotFound | |
| 304 | } | |
| 305 | 310 | if err := bumpKeyEpoch(tx); err != nil { |
| 306 | 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 | 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 | 547 | return err |
| 539 | 548 | } |
| 540 | 549 | defer tx.Rollback() |
| 541 | res, err := tx.Exec( | |
| 542 | "DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%'", | |
| 543 | fingerprint, repoID) | |
| 550 | var id int64 | |
| 551 | err = tx.QueryRow( | |
| 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 | 557 | if err != nil { |
| 545 | 558 | return err |
| 546 | 559 | } |
| 547 | if n, _ := res.RowsAffected(); n == 0 { | |
| 548 | return ErrNotFound | |
| 549 | } | |
| 550 | 560 | if err := bumpKeyEpoch(tx); err != nil { |
| 551 | 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 | } |