admin: list and show accounts over SSH !150

merged merged by cmc on 2026-09-02 00:51 UTC · krz/gitbay:admin-user-list into main

5 files changed, +526 −2

Layout: unified · split

cmd/gitbay/main.go +6
@@ -75,6 +75,12 @@ func newRoot() *cobra.Command {
75 pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>", 75 pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>",
76 passOpts{server: []string{"register"}}), 76 passOpts{server: []string{"register"}}),
77 pass("audit", "instance audit log (admins): [--limit <n>]", passOpts{server: []string{"audit"}}), 77 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 ),
78 manCmd(root), 84 manCmd(root),
79 ) 85 )
80 return root 86 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 {
22 Algo string 22 Algo string
23 Blob []byte 23 Blob []byte
24 Scope string 24 Scope string
25 CreatedAt string
26 LastUsedAt string // "" when the key has never authenticated
25} 27}
26 28
27// ErrDuplicateKey carries the exact user-facing message from the spec. It 29// ErrDuplicateKey carries the exact user-facing message from the spec. It
@@ -254,7 +256,8 @@ func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
254 256
255func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { 257func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
256 rows, err := s.DB.Query( 258 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`,
258 userID) 261 userID)
259 if err != nil { 262 if err != nil {
260 return nil, err 263 return nil, err
@@ -263,7 +266,7 @@ func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
263 var keys []SSHKey 266 var keys []SSHKey
264 for rows.Next() { 267 for rows.Next() {
265 var k SSHKey 268 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 {
267 return nil, err 270 return nil, err
268 } 271 }
269 keys = append(keys, k) 272 keys = append(keys, k)