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 | // 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. | ||
| 175 | func (s *Store) SetUserDisabled(userID int64, disabled bool) error { | 177 | func (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. | ||
| 292 | func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error { | 296 | func (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 | } |