Commit b2f4a8aca0
Verified · cmc ci/build: success
cmd/gitbayd/adminusers.go +18
| @@ -47,6 +47,24 @@ func adminUserDisableCmd() *cobra.Command { | ||
| 47 | 47 | }) |
| 48 | 48 | } |
| 49 | 49 | |
| 50 | func adminUserDeleteCmd() *cobra.Command { | |
| 51 | var yes bool | |
| 52 | cmd := withUser("delete", "delete an account that anchors nothing (keys, emails, and sessions go with it)", | |
| 53 | func(st *store.Store, u store.User) error { | |
| 54 | if !yes { | |
| 55 | return fmt.Errorf("deletion is permanent; pass --yes") | |
| 56 | } | |
| 57 | if err := st.DeleteUser(u.ID); err != nil { | |
| 58 | return err | |
| 59 | } | |
| 60 | st.Audit(0, "admin user.deleted", map[string]any{"user": u.Username}) | |
| 61 | fmt.Printf("deleted %s\n", u.Username) | |
| 62 | return nil | |
| 63 | }) | |
| 64 | cmd.Flags().BoolVar(&yes, "yes", false, "confirm permanent deletion") | |
| 65 | return cmd | |
| 66 | } | |
| 67 | ||
| 50 | 68 | func adminUserEnableCmd() *cobra.Command { |
| 51 | 69 | return withUser("enable", "restore a suspended account", |
| 52 | 70 | func(st *store.Store, u store.User) error { |
cmd/gitbayd/main.go +1 −1
| @@ -284,7 +284,7 @@ func adminCmd() *cobra.Command { | ||
| 284 | 284 | Short: "host-local administration", |
| 285 | 285 | } |
| 286 | 286 | userCmd := &cobra.Command{Use: "user", Short: "manage users"} |
| 287 | userCmd.AddCommand(adminUserCreateCmd(), adminUserDisableCmd(), adminUserEnableCmd()) | |
| 287 | userCmd.AddCommand(adminUserCreateCmd(), adminUserDisableCmd(), adminUserEnableCmd(), adminUserDeleteCmd()) | |
| 288 | 288 | emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"} |
| 289 | 289 | emailCmd.AddCommand(adminEmailVerifyCmd()) |
| 290 | 290 | admin.AddCommand( |
e2e/userdelete_test.go added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | func TestAdminUserDelete(t *testing.T) { | |
| 9 | inst := startInstance(t) | |
| 10 | aliceKey := inst.newKey(t, "alice") | |
| 11 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 12 | ||
| 13 | // --yes is required. | |
| 14 | out := inst.forgedAdminErr(t, "admin", "user", "delete", "alice") | |
| 15 | if !strings.Contains(out, "--yes") { | |
| 16 | t.Fatalf("missing confirmation guard: %s", out) | |
| 17 | } | |
| 18 | ||
| 19 | // An owned repo blocks deletion, by name of the blocker. | |
| 20 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 21 | t.Fatalf("repo create: %s", errOut) | |
| 22 | } | |
| 23 | out = inst.forgedAdminErr(t, "admin", "user", "delete", "alice", "--yes") | |
| 24 | if !strings.Contains(out, "owned repositories") { | |
| 25 | t.Fatalf("repo blocker not reported: %s", out) | |
| 26 | } | |
| 27 | ||
| 28 | // Authored content blocks deletion after the repo is gone... the issue | |
| 29 | // lives in bob's repo so it survives alice/app's deletion. | |
| 30 | bobKey := inst.newKey(t, "bob") | |
| 31 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 32 | if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "create", "bob/proj"); code != 0 { | |
| 33 | t.Fatalf("bob repo: %s", errOut) | |
| 34 | } | |
| 35 | if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "bob/proj", "--title", "'from alice'"); code != 0 { | |
| 36 | t.Fatalf("issue create: %s", errOut) | |
| 37 | } | |
| 38 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "delete", "alice/app", "--yes"); code != 0 { | |
| 39 | t.Fatal("repo delete failed") | |
| 40 | } | |
| 41 | out = inst.forgedAdminErr(t, "admin", "user", "delete", "alice", "--yes") | |
| 42 | if !strings.Contains(out, "authored issues") { | |
| 43 | t.Fatalf("issue blocker not reported: %s", out) | |
| 44 | } | |
| 45 | ||
| 46 | // A clean account deletes; its key stops authenticating. | |
| 47 | carolKey := inst.newKey(t, "carol") | |
| 48 | inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub") | |
| 49 | if out := inst.admin(t, "admin", "user", "delete", "carol", "--yes"); !strings.Contains(out, "deleted carol") { | |
| 50 | t.Fatalf("delete: %s", out) | |
| 51 | } | |
| 52 | if _, _, code := inst.ssh(t, carolKey, "", "whoami"); code == 0 { | |
| 53 | t.Fatal("deleted account still authenticates") | |
| 54 | } | |
| 55 | out = inst.forgedAdminErr(t, "admin", "user", "delete", "carol", "--yes") | |
| 56 | if !strings.Contains(out, "no user") { | |
| 57 | t.Fatalf("second delete: %s", out) | |
| 58 | } | |
| 59 | ||
| 60 | // The sole admin of an org is anchored by it. | |
| 61 | if _, errOut, code := inst.ssh(t, bobKey, "", "org", "create", "solo"); code != 0 { | |
| 62 | t.Fatalf("org create: %s", errOut) | |
| 63 | } | |
| 64 | out = inst.forgedAdminErr(t, "admin", "user", "delete", "bob", "--yes") | |
| 65 | if !strings.Contains(out, "no other admin") { | |
| 66 | t.Fatalf("org blocker not reported: %s", out) | |
| 67 | } | |
| 68 | } | |
internal/store/users.go +49
| @@ -46,6 +46,55 @@ func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) { | ||
| 46 | 46 | return res.LastInsertId() |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | // DeleteUser removes an account whose removal orphans nothing: no owned | |
| 50 | // repositories, no authored issues, MRs, comments, or reviews, and not the | |
| 51 | // only admin of an org. Everything else (keys, emails, sessions, tokens, | |
| 52 | // pins, memberships, activity) cascades. Blockers come back as an error | |
| 53 | // naming what stands in the way, so the operator can transfer, delete, or | |
| 54 | // disable instead. | |
| 55 | func (s *Store) DeleteUser(id int64) error { | |
| 56 | var blockers []string | |
| 57 | var checkErr error | |
| 58 | count := func(q string, what string) { | |
| 59 | var n int | |
| 60 | if err := s.DB.QueryRow(q, id).Scan(&n); err != nil { | |
| 61 | if checkErr == nil { | |
| 62 | checkErr = fmt.Errorf("checking %s: %w", what, err) | |
| 63 | } | |
| 64 | return | |
| 65 | } | |
| 66 | if n > 0 { | |
| 67 | blockers = append(blockers, fmt.Sprintf("%d %s", n, what)) | |
| 68 | } | |
| 69 | } | |
| 70 | count("SELECT COUNT(*) FROM repos WHERE owner_kind = 'user' AND owner_id = ?", "owned repositories") | |
| 71 | count("SELECT COUNT(*) FROM issues WHERE author_id = ?", "authored issues") | |
| 72 | count("SELECT COUNT(*) FROM merge_requests WHERE author_id = ?", "authored merge requests") | |
| 73 | count("SELECT COUNT(*) FROM issue_comments WHERE author_id = ?", "issue comments") | |
| 74 | count("SELECT COUNT(*) FROM mr_comments WHERE author_id = ?", "MR comments") | |
| 75 | count("SELECT COUNT(*) FROM mr_diff_comments WHERE author_id = ?", "diff comments") | |
| 76 | count("SELECT COUNT(*) FROM mr_reviews WHERE reviewer_id = ?", "reviews") | |
| 77 | count(`SELECT COUNT(*) FROM org_members m WHERE m.user_id = ? AND m.role = 'admin' | |
| 78 | AND NOT EXISTS (SELECT 1 FROM org_members o | |
| 79 | WHERE o.org_id = m.org_id AND o.role = 'admin' AND o.user_id != m.user_id)`, | |
| 80 | "organizations with no other admin") | |
| 81 | if checkErr != nil { | |
| 82 | return checkErr | |
| 83 | } | |
| 84 | if len(blockers) > 0 { | |
| 85 | return fmt.Errorf("account still anchors: %s — transfer or delete those first, or disable the account instead", | |
| 86 | strings.Join(blockers, ", ")) | |
| 87 | } | |
| 88 | res, err := s.DB.Exec("DELETE FROM users WHERE id = ?", id) | |
| 89 | if err != nil { | |
| 90 | return err | |
| 91 | } | |
| 92 | if n, _ := res.RowsAffected(); n == 0 { | |
| 93 | return ErrNotFound | |
| 94 | } | |
| 95 | return nil | |
| 96 | } | |
| 97 | ||
| 49 | 98 | // OwnerExists reports whether a user or org owns the name — the ACME host |
| 50 | 99 | // policy check for pages subdomains. |
| 51 | 100 | func (s *Store) OwnerExists(name string) bool { |