A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit b2f4a8aca0

b2f4a8aca0b0b1c87c247c8df3ff3b7ff7139084

parent: 7ddcd8b4a9

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-25T19:45:38Z

admin user delete for accounts that anchor nothing

Refuses while the account owns repositories, authored issues, MRs,
comments, or reviews, or is the only admin of an org — each blocker
named — pointing at transfer, deletion, or disable instead. A clean
account deletes with its keys, emails, sessions, tokens, pins,
memberships, and activity via the existing cascades. --yes required;
audit-logged.

Closes #33
cmd/gitbayd/adminusers.go +18
@@ -47,6 +47,24 @@ func adminUserDisableCmd() *cobra.Command {
4747 })
4848 }
4949
50func 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
5068 func adminUserEnableCmd() *cobra.Command {
5169 return withUser("enable", "restore a suspended account",
5270 func(st *store.Store, u store.User) error {
cmd/gitbayd/main.go +1 −1
@@ -284,7 +284,7 @@ func adminCmd() *cobra.Command {
284284 Short: "host-local administration",
285285 }
286286 userCmd := &cobra.Command{Use: "user", Short: "manage users"}
287 userCmd.AddCommand(adminUserCreateCmd(), adminUserDisableCmd(), adminUserEnableCmd())
287 userCmd.AddCommand(adminUserCreateCmd(), adminUserDisableCmd(), adminUserEnableCmd(), adminUserDeleteCmd())
288288 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
289289 emailCmd.AddCommand(adminEmailVerifyCmd())
290290 admin.AddCommand(
e2e/userdelete_test.go added +68
@@ -0,0 +1,68 @@
1package e2e
2
3import (
4 "strings"
5 "testing"
6)
7
8func 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) {
4646 return res.LastInsertId()
4747 }
4848
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.
55func (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
4998 // OwnerExists reports whether a user or org owns the name — the ACME host
5099 // policy check for pages subdomains.
51100 func (s *Store) OwnerExists(name string) bool {