admin: promote and demote instance admins !151

merged merged by cmc on 2026-09-02 01:01 UTC · krz/gitbay:admin-promote into main

6 files changed, +162 −1

Layout: unified · split

cmd/gitbay/main.go +2
@@ -79,6 +79,8 @@ func newRoot() *cobra.Command {
7979 group("user", "accounts on this instance",
8080 pass("list", "list accounts: [--state active|pending|disabled|admin] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "user", "list"}}),
8181 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"}}),
8284 ),
8385 ),
8486 manCmd(root),
cmd/gitbayd/adminusers.go +26
@@ -77,6 +77,32 @@ func adminUserEnableCmd() *cobra.Command {
7777 })
7878}
7979
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
80106// adminMigrateCommitRefsCmd is a one-shot backfill: legacy commit-reference
81107// comments (author-attributed, bare sha) become system messages with a
82108// linked sha. Idempotent.
cmd/gitbayd/main.go +2 −1
@@ -309,7 +309,8 @@ func adminCmd() *cobra.Command {
309309 Short: "host-local administration",
310310 }
311311 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())
313314 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
314315 emailCmd.AddCommand(adminEmailVerifyCmd())
315316 admin.AddCommand(
e2e/adminusers_test.go +55
@@ -166,3 +166,58 @@ func TestAdminUserListAndShow(t *testing.T) {
166166 t.Fatalf("plain show:\n%s", out)
167167 }
168168}
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() {
2020 Summary: "show an account: keys, emails, orgs, tokens, sessions (instance admins)",
2121 Usage: "admin user show <username>",
2222 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})
2331}
2432
2533// requireInstanceAdmin gates the admin noun. -1 means proceed.
@@ -243,3 +251,41 @@ func runAdminUserShow(c *Ctx, args []string) int {
243251 }
244252 })
245253}
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) {
100100 userID, fmtTime(time.Now())).Scan(&n)
101101 return n, err
102102}
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}