Commit a779ff0efe
Verified · cmc ci/build: success ci/test: success ci/vuln: success
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. | ||
| 82 | func 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 | |||
| 94 | func 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 | |||
| 170 | func 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 | |||
| 255 | func runAdminUserPromote(c *Ctx, args []string) int { return setAdmin(c, args, true) } | ||
| 256 | func runAdminUserDemote(c *Ctx, args []string) int { return setAdmin(c, args, false) } | ||
| 257 | |||
| 258 | func 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. | ||
| 106 | var 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. | ||
| 110 | func (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 | } | ||