Commit 8f2ddee1da

8f2ddee1da3456462dbafd2fb210bd3dadee8126

parent: 3a243db45e

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

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

admin: list and show accounts over SSH

The store had no user-list query, so an instance admin could not
enumerate accounts, see which were pending or disabled, or who held
admin, without root on the host.

admin user list pages accounts by username with --state
active|pending|disabled|admin. admin user show <name> carries keys with
last use, emails with how each was verified, PGP keys, org memberships,
owned repo count, API token names, and live web session count. Both are
SSHOnly and refused to non-admins, the shape audit already has.

last_seen is the newest last_used_at across the account's SSH keys and
API tokens, so ListSSHKeys now selects created_at and last_used_at.

Closes #69

Layout: unified · split

cmd/gitbay/main.go +6
@@ -75,6 +75,12 @@ func newRoot() *cobra.Command {
7575 pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>",
7676 passOpts{server: []string{"register"}}),
7777 pass("audit", "instance audit log (admins): [--limit <n>]", passOpts{server: []string{"audit"}}),
78 group("admin", "instance administration (admins)",
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"}}),
81 pass("show", "show an account: <username>", passOpts{server: []string{"admin", "user", "show"}}),
82 ),
83 ),
7884 manCmd(root),
7985 )
8086 return root
e2e/adminusers_test.go added +168
@@ -0,0 +1,168 @@
1package e2e
2
3import (
4 "encoding/json"
5 "strings"
6 "testing"
7)
8
9type adminUserRow struct {
10 Username string `json:"username"`
11 State string `json:"state"`
12 Admin bool `json:"admin"`
13 LastSeen string `json:"last_seen"`
14}
15
16func TestAdminUserListAndShow(t *testing.T) {
17 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
18 adminKey := inst.newKey(t, "root")
19 aliceKey := inst.newKey(t, "alice")
20 bobKey := inst.newKey(t, "bob")
21 inst.admin(t, "admin", "user", "create", "root", "--key", adminKey+".pub", "--admin")
22 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
23 "--email", "alice@example.org", "--verified")
24 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
25 inst.admin(t, "admin", "user", "disable", "bob")
26
27 if _, _, code := inst.ssh(t, aliceKey, "", "admin", "user", "list"); code != 4 {
28 t.Fatalf("non-admin listed users: exit %d", code)
29 }
30 if _, _, code := inst.ssh(t, aliceKey, "", "admin", "user", "show", "bob"); code != 4 {
31 t.Fatalf("non-admin showed a user: exit %d", code)
32 }
33
34 list := func(args ...string) []adminUserRow {
35 t.Helper()
36 out, errOut, code := inst.ssh(t, adminKey, "", append([]string{"admin", "user", "list", "--json"}, args...)...)
37 if code != 0 {
38 t.Fatalf("admin user list %v: exit %d\n%s", args, code, errOut)
39 }
40 var env struct {
41 Data json.RawMessage `json:"data"`
42 }
43 if err := json.Unmarshal([]byte(out), &env); err != nil {
44 t.Fatalf("list envelope: %v\n%s", err, out)
45 }
46 var rows []adminUserRow
47 if err := json.Unmarshal(env.Data, &rows); err != nil {
48 // paged shape
49 var paged struct {
50 Items []adminUserRow `json:"items"`
51 Next string `json:"next"`
52 }
53 if err := json.Unmarshal(env.Data, &paged); err != nil {
54 t.Fatalf("list shape: %v\n%s", err, out)
55 }
56 return paged.Items
57 }
58 return rows
59 }
60
61 // gitbay-bot is seeded by the schema: it authors dependency issues.
62 rows := list()
63 if len(rows) != 4 || rows[0].Username != "alice" || rows[1].Username != "bob" ||
64 rows[2].Username != "gitbay-bot" || rows[3].Username != "root" {
65 t.Fatalf("list: %+v", rows)
66 }
67 if rows[1].State != "disabled" || rows[0].State != "active" || !rows[3].Admin || rows[0].Admin {
68 t.Fatalf("states: %+v", rows)
69 }
70 if rows[0].LastSeen == "" {
71 t.Fatal("alice authenticated above but has no last_seen")
72 }
73 if rows[1].LastSeen != "" {
74 t.Fatalf("bob never authenticated but has last_seen %q", rows[1].LastSeen)
75 }
76 if rows := list("--state", "disabled"); len(rows) != 1 || rows[0].Username != "bob" {
77 t.Fatalf("--state disabled: %+v", rows)
78 }
79 if rows := list("--state", "admin"); len(rows) != 1 || rows[0].Username != "root" {
80 t.Fatalf("--state admin: %+v", rows)
81 }
82 if rows := list("--state", "active"); len(rows) != 3 {
83 t.Fatalf("--state active: %+v", rows)
84 }
85 if _, _, code := inst.ssh(t, adminKey, "", "admin", "user", "list", "--state", "bogus"); code != 2 {
86 t.Fatalf("bad --state accepted: exit %d", code)
87 }
88
89 // Pagination: two pages of usernames, keyset by username.
90 out, _, _ := inst.ssh(t, adminKey, "", "admin", "user", "list", "--json", "--limit", "2")
91 var env struct {
92 Data struct {
93 Items []adminUserRow `json:"items"`
94 Next string `json:"next"`
95 } `json:"data"`
96 }
97 if err := json.Unmarshal([]byte(out), &env); err != nil || len(env.Data.Items) != 2 || env.Data.Next == "" {
98 t.Fatalf("first page: %v\n%s", err, out)
99 }
100 if rows := list("--limit", "2", "--cursor", env.Data.Next); len(rows) != 2 ||
101 rows[0].Username != "gitbay-bot" || rows[1].Username != "root" {
102 t.Fatalf("second page: %+v", rows)
103 }
104
105 // Show: alice owns a repo, admins an org, has a verified email.
106 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
107 t.Fatal("repo create failed")
108 }
109 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "acme"); code != 0 {
110 t.Fatal("org create failed")
111 }
112 out, errOut, code := inst.ssh(t, adminKey, "", "admin", "user", "show", "alice", "--json")
113 if code != 0 {
114 t.Fatalf("show: exit %d\n%s", code, errOut)
115 }
116 var show struct {
117 Data struct {
118 adminUserRow
119 Keys []struct {
120 Fingerprint string `json:"fingerprint"`
121 Scope string `json:"scope"`
122 LastUsedAt string `json:"last_used_at"`
123 } `json:"keys"`
124 Emails []struct {
125 Address string `json:"address"`
126 Verified bool `json:"verified"`
127 VerifiedBy string `json:"verified_by"`
128 } `json:"emails"`
129 Orgs []struct {
130 Org string `json:"org"`
131 Role string `json:"role"`
132 } `json:"orgs"`
133 Repos int64 `json:"repos"`
134 WebSessions int64 `json:"web_sessions"`
135 } `json:"data"`
136 }
137 if err := json.Unmarshal([]byte(out), &show); err != nil {
138 t.Fatalf("show envelope: %v\n%s", err, out)
139 }
140 d := show.Data
141 if d.Username != "alice" || d.State != "active" || d.Repos != 1 || d.WebSessions != 0 {
142 t.Fatalf("show summary: %+v", d)
143 }
144 if len(d.Keys) != 1 || !strings.HasPrefix(d.Keys[0].Fingerprint, "SHA256:") || d.Keys[0].Scope != "full" || d.Keys[0].LastUsedAt == "" {
145 t.Fatalf("show keys: %+v", d.Keys)
146 }
147 if len(d.Emails) != 1 || d.Emails[0].Address != "alice@example.org" || !d.Emails[0].Verified || d.Emails[0].VerifiedBy != "admin" {
148 t.Fatalf("show emails: %+v", d.Emails)
149 }
150 if len(d.Orgs) != 1 || d.Orgs[0].Org != "acme" || d.Orgs[0].Role != "admin" {
151 t.Fatalf("show orgs: %+v", d.Orgs)
152 }
153 // A browser session counts once minted.
154 inst.login(t, aliceKey)
155 out, _, _ = inst.ssh(t, adminKey, "", "admin", "user", "show", "alice", "--json")
156 if !strings.Contains(out, `"web_sessions":1`) {
157 t.Fatalf("session not counted:\n%s", out)
158 }
159
160 if _, _, code := inst.ssh(t, adminKey, "", "admin", "user", "show", "nobody"); code != 3 {
161 t.Fatalf("unknown user: exit %d", code)
162 }
163 // Plain output carries the same facts.
164 if out, _, _ := inst.ssh(t, adminKey, "", "admin", "user", "show", "alice"); !strings.Contains(out, "alice\tactive") ||
165 !strings.Contains(out, "acme\tadmin") || !strings.Contains(out, "verified by admin") {
166 t.Fatalf("plain show:\n%s", out)
167 }
168}
internal/control/admin.go added +245
@@ -0,0 +1,245 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "strings"
8 "time"
9
10 "gitbay.org/gitbay/internal/protocol"
11 "gitbay.org/gitbay/internal/store"
12)
13
14func init() {
15 register(Command{Path: []string{"admin", "user", "list"},
16 Summary: "list accounts (instance admins)",
17 Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]",
18 ReadOnly: true, SSHOnly: true, Run: runAdminUserList})
19 register(Command{Path: []string{"admin", "user", "show"},
20 Summary: "show an account: keys, emails, orgs, tokens, sessions (instance admins)",
21 Usage: "admin user show <username>",
22 ReadOnly: true, SSHOnly: true, Run: runAdminUserShow})
23}
24
25// requireInstanceAdmin gates the admin noun. -1 means proceed.
26func requireInstanceAdmin(c *Ctx) int {
27 if !c.User.IsAdmin {
28 return c.fail(protocol.ExitDenied, "admin commands are for instance admins")
29 }
30 return -1
31}
32
33// adminUserOut is one account row, shared by list and show.
34type adminUserOut struct {
35 Username string `json:"username"`
36 State string `json:"state"` // active | pending | disabled
37 Admin bool `json:"admin"`
38 CreatedAt string `json:"created_at"`
39 LastSeen string `json:"last_seen,omitempty"`
40}
41
42func adminUserRow(u store.AdminUser) adminUserOut {
43 state := "active"
44 switch {
45 case u.Disabled:
46 state = "disabled"
47 case u.Pending:
48 state = "pending"
49 }
50 return adminUserOut{u.Username, state, u.IsAdmin, u.CreatedAt, u.LastSeen}
51}
52
53func runAdminUserList(c *Ctx, args []string) int {
54 if code := requireInstanceAdmin(c); code >= 0 {
55 return code
56 }
57 args, p, code := parsePageFlags(c, args, "admin-user", false)
58 if code >= 0 {
59 return code
60 }
61 state := ""
62 for i := 0; i < len(args); i++ {
63 switch args[i] {
64 case "--state":
65 if i+1 >= len(args) {
66 return c.fail(protocol.ExitUsage, "--state requires active|pending|disabled|admin")
67 }
68 state = args[i+1]
69 i++
70 default:
71 return c.fail(protocol.ExitUsage, "usage: admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]")
72 }
73 }
74 switch state {
75 case "", "active", "pending", "disabled", "admin":
76 default:
77 return c.fail(protocol.ExitUsage, "--state requires active|pending|disabled|admin")
78 }
79 users, err := c.Store.ListUsers(state, p.queryLimit(), p.key)
80 if err != nil {
81 return c.fail(protocol.ExitFailure, "%v", err)
82 }
83 users, next := trimPage(p, users, "admin-user", func(u store.AdminUser) string { return u.Username })
84 var ds []adminUserOut
85 for _, u := range users {
86 ds = append(ds, adminUserRow(u))
87 }
88 return c.emitPage(p, ds, next, func(w io.Writer) {
89 for _, d := range ds {
90 mark := ""
91 if d.Admin {
92 mark = "admin"
93 }
94 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.Username, d.State, mark, d.CreatedAt, d.LastSeen)
95 }
96 })
97}
98
99func runAdminUserShow(c *Ctx, args []string) int {
100 if code := requireInstanceAdmin(c); code >= 0 {
101 return code
102 }
103 if len(args) != 1 {
104 return c.fail(protocol.ExitUsage, "usage: admin user show <username>")
105 }
106 name := args[0]
107 u, err := c.Store.UserByUsername(name)
108 if errors.Is(err, store.ErrNotFound) {
109 return c.fail(protocol.ExitNotFound, "no user %q", name)
110 } else if err != nil {
111 return c.fail(protocol.ExitFailure, "%v", err)
112 }
113 row, err := c.Store.AdminUserByName(name)
114 if err != nil {
115 return c.fail(protocol.ExitFailure, "%v", err)
116 }
117
118 type keyOut struct {
119 Fingerprint string `json:"fingerprint"`
120 Algo string `json:"algo"`
121 Scope string `json:"scope"`
122 CreatedAt string `json:"created_at"`
123 LastUsedAt string `json:"last_used_at,omitempty"`
124 }
125 type emailOut struct {
126 Address string `json:"address"`
127 Verified bool `json:"verified"`
128 VerifiedBy string `json:"verified_by,omitempty"` // smtp | admin
129 Primary bool `json:"primary"`
130 }
131 type pgpOut struct {
132 Fingerprint string `json:"fingerprint"`
133 ExpiresAt *time.Time `json:"expires_at,omitempty"`
134 RevokedAt *time.Time `json:"revoked_at,omitempty"`
135 }
136 type orgOut struct {
137 Org string `json:"org"`
138 Role string `json:"role"`
139 }
140 type tokenOut struct {
141 Name string `json:"name"`
142 Scope string `json:"scope"`
143 CreatedAt string `json:"created_at"`
144 ExpiresAt *time.Time `json:"expires_at,omitempty"`
145 LastUsedAt *time.Time `json:"last_used_at,omitempty"`
146 }
147 type out struct {
148 adminUserOut
149 Keys []keyOut `json:"keys"`
150 Emails []emailOut `json:"emails"`
151 PGPKeys []pgpOut `json:"pgp_keys"`
152 Orgs []orgOut `json:"orgs"`
153 Repos int64 `json:"repos"`
154 APITokens []tokenOut `json:"api_tokens"`
155 WebSessions int64 `json:"web_sessions"`
156 }
157 d := out{adminUserOut: adminUserRow(row),
158 Keys: []keyOut{}, Emails: []emailOut{}, PGPKeys: []pgpOut{}, Orgs: []orgOut{}, APITokens: []tokenOut{}}
159
160 keys, err := c.Store.ListSSHKeys(u.ID)
161 if err != nil {
162 return c.fail(protocol.ExitFailure, "%v", err)
163 }
164 for _, k := range keys {
165 d.Keys = append(d.Keys, keyOut{k.Fingerprint, k.Algo, k.Scope, k.CreatedAt, k.LastUsedAt})
166 }
167 emails, err := c.Store.ListEmails(u.ID)
168 if err != nil {
169 return c.fail(protocol.ExitFailure, "%v", err)
170 }
171 for _, e := range emails {
172 d.Emails = append(d.Emails, emailOut{e.Address, e.Verified, e.VerifiedBy, e.Primary})
173 }
174 pgp, err := c.Store.ListPGPKeys(u.ID)
175 if err != nil {
176 return c.fail(protocol.ExitFailure, "%v", err)
177 }
178 for _, k := range pgp {
179 d.PGPKeys = append(d.PGPKeys, pgpOut{k.Fingerprint, k.ExpiresAt, k.RevokedAt})
180 }
181 orgs, err := c.Store.ListOrgsForUser(u.ID)
182 if err != nil {
183 return c.fail(protocol.ExitFailure, "%v", err)
184 }
185 for _, m := range orgs {
186 d.Orgs = append(d.Orgs, orgOut{m.Username, m.Role})
187 }
188 if d.Repos, err = c.Store.OwnedRepoCount(u.ID); err != nil {
189 return c.fail(protocol.ExitFailure, "%v", err)
190 }
191 tokens, err := c.Store.ListAPITokens(u.ID)
192 if err != nil {
193 return c.fail(protocol.ExitFailure, "%v", err)
194 }
195 for _, t := range tokens {
196 d.APITokens = append(d.APITokens, tokenOut{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt})
197 }
198 if d.WebSessions, err = c.Store.WebSessionCount(u.ID); err != nil {
199 return c.fail(protocol.ExitFailure, "%v", err)
200 }
201
202 return c.emit(d, func(w io.Writer) {
203 fmt.Fprintf(w, "%s\t%s", d.Username, d.State)
204 if d.Admin {
205 fmt.Fprint(w, "\tadmin")
206 }
207 fmt.Fprintf(w, "\ncreated\t%s\n", d.CreatedAt)
208 if d.LastSeen != "" {
209 fmt.Fprintf(w, "last seen\t%s\n", d.LastSeen)
210 }
211 fmt.Fprintf(w, "repos\t%d\nweb sessions\t%d\n", d.Repos, d.WebSessions)
212 fmt.Fprintln(w, "keys:")
213 for _, k := range d.Keys {
214 fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", k.Fingerprint, k.Algo, k.Scope, k.LastUsedAt)
215 }
216 fmt.Fprintln(w, "emails:")
217 for _, e := range d.Emails {
218 state := "unverified"
219 if e.Verified {
220 state = "verified by " + e.VerifiedBy
221 }
222 mark := ""
223 if e.Primary {
224 mark = "\tprimary"
225 }
226 fmt.Fprintf(w, " %s\t%s%s\n", e.Address, state, mark)
227 }
228 fmt.Fprintln(w, "pgp keys:")
229 for _, k := range d.PGPKeys {
230 fmt.Fprintf(w, " %s\n", k.Fingerprint)
231 }
232 fmt.Fprintln(w, "orgs:")
233 for _, o := range d.Orgs {
234 fmt.Fprintf(w, " %s\t%s\n", o.Org, o.Role)
235 }
236 fmt.Fprintln(w, "api tokens:")
237 for _, t := range d.APITokens {
238 used := ""
239 if t.LastUsedAt != nil {
240 used = t.LastUsedAt.UTC().Format(time.RFC3339)
241 }
242 fmt.Fprintf(w, " %s\t%s\t%s\n", t.Name, t.Scope, strings.TrimSpace(used))
243 }
244 })
245}
internal/store/adminusers.go added +102
@@ -0,0 +1,102 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "time"
8)
9
10// AdminUser is one account as the instance admin sees it. LastSeen is the
11// most recent authentication by any of the account's SSH keys or API
12// tokens, "" when there has been none.
13type AdminUser struct {
14 Username string
15 IsAdmin bool
16 Pending bool
17 Disabled bool
18 CreatedAt string
19 LastSeen string
20}
21
22const adminUserSelect = `SELECT u.username, u.is_admin, u.pending, u.disabled, u.created_at,
23 COALESCE((SELECT MAX(t) FROM (
24 SELECT last_used_at t FROM ssh_keys WHERE user_id = u.id
25 UNION ALL SELECT last_used_at FROM api_tokens WHERE user_id = u.id)), '')
26 FROM users u`
27
28func scanAdminUser(row interface{ Scan(...any) error }) (AdminUser, error) {
29 var u AdminUser
30 var admin, pending, disabled int
31 err := row.Scan(&u.Username, &admin, &pending, &disabled, &u.CreatedAt, &u.LastSeen)
32 u.IsAdmin = admin != 0
33 u.Pending = pending != 0
34 u.Disabled = disabled != 0
35 return u, err
36}
37
38// ListUsers returns accounts by username. state narrows the set: "" for
39// every account, active (neither pending nor disabled), pending, disabled,
40// or admin. after is the keyset cursor: usernames strictly greater than
41// it, "" from the start. limit 0 means no cap.
42func (s *Store) ListUsers(state string, limit int, after string) ([]AdminUser, error) {
43 where := "WHERE u.username > ?"
44 switch state {
45 case "":
46 case "active":
47 where += " AND u.pending = 0 AND u.disabled = 0"
48 case "pending":
49 where += " AND u.pending = 1"
50 case "disabled":
51 where += " AND u.disabled = 1"
52 case "admin":
53 where += " AND u.is_admin = 1"
54 default:
55 return nil, fmt.Errorf("unknown state %q", state)
56 }
57 q := adminUserSelect + " " + where + " ORDER BY u.username"
58 args := []any{after}
59 if limit > 0 {
60 q += " LIMIT ?"
61 args = append(args, limit)
62 }
63 rows, err := s.DB.Query(q, args...)
64 if err != nil {
65 return nil, err
66 }
67 defer rows.Close()
68 var out []AdminUser
69 for rows.Next() {
70 u, err := scanAdminUser(rows)
71 if err != nil {
72 return nil, err
73 }
74 out = append(out, u)
75 }
76 return out, rows.Err()
77}
78
79// AdminUserByName is the ListUsers row for one account.
80func (s *Store) AdminUserByName(name string) (AdminUser, error) {
81 u, err := scanAdminUser(s.DB.QueryRow(adminUserSelect+" WHERE u.username = ?", name))
82 if errors.Is(err, sql.ErrNoRows) {
83 return u, ErrNotFound
84 }
85 return u, err
86}
87
88// OwnedRepoCount counts repositories the user owns directly, not through
89// an org.
90func (s *Store) OwnedRepoCount(userID int64) (int64, error) {
91 var n int64
92 err := s.DB.QueryRow("SELECT COUNT(*) FROM repos WHERE owner_kind = 'user' AND owner_id = ?", userID).Scan(&n)
93 return n, err
94}
95
96// WebSessionCount counts the user's unexpired browser sessions.
97func (s *Store) WebSessionCount(userID int64) (int64, error) {
98 var n int64
99 err := s.DB.QueryRow("SELECT COUNT(*) FROM web_sessions WHERE user_id = ? AND expires_at > ?",
100 userID, fmtTime(time.Now())).Scan(&n)
101 return n, err
102}
internal/store/users.go +5 −2
@@ -22,6 +22,8 @@ type SSHKey struct {
2222 Algo string
2323 Blob []byte
2424 Scope string
25 CreatedAt string
26 LastUsedAt string // "" when the key has never authenticated
2527}
2628
2729// ErrDuplicateKey carries the exact user-facing message from the spec. It
@@ -254,7 +256,8 @@ func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
254256
255257func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
256258 rows, err := s.DB.Query(
257 "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE user_id = ? ORDER BY id",
259 `SELECT id, user_id, fingerprint, algo, blob, scope, created_at, COALESCE(last_used_at, '')
260 FROM ssh_keys WHERE user_id = ? ORDER BY id`,
258261 userID)
259262 if err != nil {
260263 return nil, err
@@ -263,7 +266,7 @@ func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
263266 var keys []SSHKey
264267 for rows.Next() {
265268 var k SSHKey
266 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope); err != nil {
269 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.CreatedAt, &k.LastUsedAt); err != nil {
267270 return nil, err
268271 }
269272 keys = append(keys, k)