Commit a779ff0efe

a779ff0efee3397408fd602b4dab958bd48295ab

parent: 8f2ddee1da

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-02 00:53 UTC

admin: promote and demote instance admins

The only path to admin was admin user create --admin. An existing
account could not be promoted and an admin could not be demoted, even
host-locally.

admin user promote|demote <name> over SSH, gated on IsAdmin and audited
through the dispatcher like every mutating command. Promotion needs an
active account. SetUserAdmin counts the other admins in the same
transaction as the update and refuses to demote the last one.

gitbayd admin user promote|demote are the host-local twins. Promote is
the recovery path when no admin key is reachable; demote observes the
same last-admin rule.

Closes #70

Layout: unified · split

cmd/gitbay/main.go +2
@@ -79,6 +79,8 @@ func newRoot() *cobra.Command {
79 group("user", "accounts on this instance", 79 group("user", "accounts on this instance",
80 pass("list", "list accounts: [--state active|pending|disabled|admin] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "user", "list"}}), 80 pass("list", "list accounts: [--state active|pending|disabled|admin] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "user", "list"}}),
81 pass("show", "show an account: <username>", passOpts{server: []string{"admin", "user", "show"}}), 81 pass("show", "show an account: <username>", passOpts{server: []string{"admin", "user", "show"}}),
82 pass("promote", "make an account an instance admin: <username>", passOpts{server: []string{"admin", "user", "promote"}}),
83 pass("demote", "remove instance admin (never the last one): <username>", passOpts{server: []string{"admin", "user", "demote"}}),
82 ), 84 ),
83 ), 85 ),
84 manCmd(root), 86 manCmd(root),
cmd/gitbayd/adminusers.go +26
@@ -77,6 +77,32 @@ func adminUserEnableCmd() *cobra.Command {
77 }) 77 })
78} 78}
79 79
80// adminUserPromoteCmd is the recovery path when no admin key is reachable:
81// it needs the host, not an admin session.
82func adminUserPromoteCmd() *cobra.Command {
83 return withUser("promote", "make an account an instance admin",
84 func(st *store.Store, u store.User) error {
85 if err := st.SetUserAdmin(u.ID, true); err != nil {
86 return err
87 }
88 st.Audit(0, "admin user.promoted", map[string]any{"user": u.Username})
89 fmt.Printf("promoted %s\n", u.Username)
90 return nil
91 })
92}
93
94func adminUserDemoteCmd() *cobra.Command {
95 return withUser("demote", "remove instance admin from an account (never the last one)",
96 func(st *store.Store, u store.User) error {
97 if err := st.SetUserAdmin(u.ID, false); err != nil {
98 return err
99 }
100 st.Audit(0, "admin user.demoted", map[string]any{"user": u.Username})
101 fmt.Printf("demoted %s\n", u.Username)
102 return nil
103 })
104}
105
80// adminMigrateCommitRefsCmd is a one-shot backfill: legacy commit-reference 106// adminMigrateCommitRefsCmd is a one-shot backfill: legacy commit-reference
81// comments (author-attributed, bare sha) become system messages with a 107// comments (author-attributed, bare sha) become system messages with a
82// linked sha. Idempotent. 108// linked sha. Idempotent.
cmd/gitbayd/main.go +2 −1
@@ -309,7 +309,8 @@ func adminCmd() *cobra.Command {
309 Short: "host-local administration", 309 Short: "host-local administration",
310 } 310 }
311 userCmd := &cobra.Command{Use: "user", Short: "manage users"} 311 userCmd := &cobra.Command{Use: "user", Short: "manage users"}
312 userCmd.AddCommand(adminUserCreateCmd(), adminUserDisableCmd(), adminUserEnableCmd(), adminUserDeleteCmd()) 312 userCmd.AddCommand(adminUserCreateCmd(), adminUserDisableCmd(), adminUserEnableCmd(), adminUserDeleteCmd(),
313 adminUserPromoteCmd(), adminUserDemoteCmd())
313 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"} 314 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
314 emailCmd.AddCommand(adminEmailVerifyCmd()) 315 emailCmd.AddCommand(adminEmailVerifyCmd())
315 admin.AddCommand( 316 admin.AddCommand(
e2e/adminusers_test.go +55
@@ -166,3 +166,58 @@ func TestAdminUserListAndShow(t *testing.T) {
166 t.Fatalf("plain show:\n%s", out) 166 t.Fatalf("plain show:\n%s", out)
167 } 167 }
168} 168}
169
170func TestAdminPromoteDemote(t *testing.T) {
171 inst := startInstance(t)
172 rootKey := inst.newKey(t, "root")
173 aliceKey := inst.newKey(t, "alice")
174 bobKey := inst.newKey(t, "bob")
175 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin")
176 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
177 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
178 inst.admin(t, "admin", "user", "disable", "bob")
179
180 if _, _, code := inst.ssh(t, aliceKey, "", "admin", "user", "promote", "alice"); code != 4 {
181 t.Fatalf("non-admin promoted: exit %d", code)
182 }
183 if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "promote", "nobody"); code != 3 {
184 t.Fatalf("unknown user: exit %d", code)
185 }
186 if _, errOut, code := inst.ssh(t, rootKey, "", "admin", "user", "promote", "bob"); code != 2 || !strings.Contains(errOut, "disabled") {
187 t.Fatalf("disabled account promoted: exit %d %s", code, errOut)
188 }
189 // The only admin cannot step down.
190 if _, errOut, code := inst.ssh(t, rootKey, "", "admin", "user", "demote", "root"); code != 2 || !strings.Contains(errOut, "only instance admin") {
191 t.Fatalf("last admin demoted: exit %d %s", code, errOut)
192 }
193 if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "promote", "alice"); code != 0 {
194 t.Fatal("promote failed")
195 }
196 if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "promote", "alice"); code != 2 {
197 t.Fatal("promoting an admin should be a usage error")
198 }
199 if out, _, code := inst.ssh(t, aliceKey, "", "audit"); code != 0 || !strings.Contains(out, "cmd admin user promote") {
200 t.Fatalf("promoted account cannot read the audit log, or the promotion is not in it: exit %d\n%s", code, out)
201 }
202 // With two admins, either may demote the other; then the survivor is stuck.
203 if _, _, code := inst.ssh(t, aliceKey, "", "admin", "user", "demote", "root"); code != 0 {
204 t.Fatal("demote failed")
205 }
206 if _, _, code := inst.ssh(t, rootKey, "", "audit"); code != 4 {
207 t.Fatal("demoted account still admin")
208 }
209 if _, _, code := inst.ssh(t, aliceKey, "", "admin", "user", "demote", "alice"); code != 2 {
210 t.Fatal("last admin demoted")
211 }
212 // Host-local recovery: the operator restores root without an admin key.
213 if out := inst.forgedAdminErr(t, "admin", "user", "demote", "alice"); !strings.Contains(out, "only instance admin") {
214 t.Fatalf("host demote of last admin: %s", out)
215 }
216 inst.admin(t, "admin", "user", "promote", "root")
217 if _, _, code := inst.ssh(t, rootKey, "", "audit"); code != 0 {
218 t.Fatal("host promote did not take")
219 }
220 if out := inst.admin(t, "admin", "audit"); !strings.Contains(out, "admin user.promoted") {
221 t.Fatalf("host promote not audited:\n%s", out)
222 }
223}
internal/control/admin.go +46
@@ -20,6 +20,14 @@ func init() {
20 Summary: "show an account: keys, emails, orgs, tokens, sessions (instance admins)", 20 Summary: "show an account: keys, emails, orgs, tokens, sessions (instance admins)",
21 Usage: "admin user show <username>", 21 Usage: "admin user show <username>",
22 ReadOnly: true, SSHOnly: true, Run: runAdminUserShow}) 22 ReadOnly: true, SSHOnly: true, Run: runAdminUserShow})
23 register(Command{Path: []string{"admin", "user", "promote"},
24 Summary: "make an account an instance admin",
25 Usage: "admin user promote <username>",
26 SSHOnly: true, Run: runAdminUserPromote})
27 register(Command{Path: []string{"admin", "user", "demote"},
28 Summary: "remove instance admin from an account (never the last one)",
29 Usage: "admin user demote <username>",
30 SSHOnly: true, Run: runAdminUserDemote})
23} 31}
24 32
25// requireInstanceAdmin gates the admin noun. -1 means proceed. 33// requireInstanceAdmin gates the admin noun. -1 means proceed.
@@ -243,3 +251,41 @@ func runAdminUserShow(c *Ctx, args []string) int {
243 } 251 }
244 }) 252 })
245} 253}
254
255func runAdminUserPromote(c *Ctx, args []string) int { return setAdmin(c, args, true) }
256func runAdminUserDemote(c *Ctx, args []string) int { return setAdmin(c, args, false) }
257
258func setAdmin(c *Ctx, args []string, admin bool) int {
259 if code := requireInstanceAdmin(c); code >= 0 {
260 return code
261 }
262 verb := "demote"
263 if admin {
264 verb = "promote"
265 }
266 if len(args) != 1 {
267 return c.fail(protocol.ExitUsage, "usage: admin user %s <username>", verb)
268 }
269 u, err := c.Store.UserByUsername(args[0])
270 if errors.Is(err, store.ErrNotFound) {
271 return c.fail(protocol.ExitNotFound, "no user %q", args[0])
272 } else if err != nil {
273 return c.fail(protocol.ExitFailure, "%v", err)
274 }
275 if u.IsAdmin == admin {
276 return c.fail(protocol.ExitUsage, "%s is already %s", u.Username, map[bool]string{true: "an admin", false: "not an admin"}[admin])
277 }
278 if admin && (u.Pending || u.Disabled) {
279 return c.fail(protocol.ExitUsage, "%s is %s; only an active account can be an admin", u.Username,
280 map[bool]string{true: "disabled", false: "pending"}[u.Disabled])
281 }
282 if err := c.Store.SetUserAdmin(u.ID, admin); err != nil {
283 if errors.Is(err, store.ErrLastAdmin) {
284 return c.fail(protocol.ExitUsage, "%v", err)
285 }
286 return c.fail(protocol.ExitFailure, "%v", err)
287 }
288 return c.emit(map[string]any{"user": u.Username, "admin": admin}, func(w io.Writer) {
289 fmt.Fprintf(w, "%sd %s\n", verb, u.Username)
290 })
291}
internal/store/adminusers.go +31
@@ -100,3 +100,34 @@ func (s *Store) WebSessionCount(userID int64) (int64, error) {
100 userID, fmtTime(time.Now())).Scan(&n) 100 userID, fmtTime(time.Now())).Scan(&n)
101 return n, err 101 return n, err
102} 102}
103
104// ErrLastAdmin refuses the demotion that would leave the instance with no
105// admin at all.
106var ErrLastAdmin = errors.New("that is the only instance admin; promote someone else first")
107
108// SetUserAdmin grants or removes instance admin. Removing it from the last
109// admin is refused inside the same transaction that counts them.
110func (s *Store) SetUserAdmin(userID int64, admin bool) error {
111 tx, err := s.DB.Begin()
112 if err != nil {
113 return err
114 }
115 defer tx.Rollback()
116 if !admin {
117 var others int
118 if err := tx.QueryRow("SELECT COUNT(*) FROM users WHERE is_admin = 1 AND id != ?", userID).Scan(&others); err != nil {
119 return err
120 }
121 if others == 0 {
122 return ErrLastAdmin
123 }
124 }
125 res, err := tx.Exec("UPDATE users SET is_admin = ? WHERE id = ?", boolInt(admin), userID)
126 if err != nil {
127 return err
128 }
129 if n, _ := res.RowsAffected(); n == 0 {
130 return ErrNotFound
131 }
132 return tx.Commit()
133}