Commit 8f2ddee1da
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
cmd/gitbay/main.go +6
| @@ -75,6 +75,12 @@ func newRoot() *cobra.Command { | ||
| 75 | 75 | pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>", |
| 76 | 76 | passOpts{server: []string{"register"}}), |
| 77 | 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 | 84 | manCmd(root), |
| 79 | 85 | ) |
| 80 | 86 | return root |
e2e/adminusers_test.go added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/json" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | type 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 | ||
| 16 | func 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 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 14 | func 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. | |
| 26 | func 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. | |
| 34 | type 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 | ||
| 42 | func 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 | ||
| 53 | func 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 | ||
| 99 | func 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 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 13 | type AdminUser struct { | |
| 14 | Username string | |
| 15 | IsAdmin bool | |
| 16 | Pending bool | |
| 17 | Disabled bool | |
| 18 | CreatedAt string | |
| 19 | LastSeen string | |
| 20 | } | |
| 21 | ||
| 22 | const 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 | ||
| 28 | func 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. | |
| 42 | func (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. | |
| 80 | func (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. | |
| 90 | func (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. | |
| 97 | func (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 | 22 | Algo string |
| 23 | 23 | Blob []byte |
| 24 | 24 | Scope string |
| 25 | CreatedAt string | |
| 26 | LastUsedAt string // "" when the key has never authenticated | |
| 25 | 27 | } |
| 26 | 28 | |
| 27 | 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 | |
| 255 | 257 | func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { |
| 256 | 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 | 261 | userID) |
| 259 | 262 | if err != nil { |
| 260 | 263 | return nil, err |
| @@ -263,7 +266,7 @@ func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { | ||
| 263 | 266 | var keys []SSHKey |
| 264 | 267 | for rows.Next() { |
| 265 | 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 | 270 | return nil, err |
| 268 | 271 | } |
| 269 | 272 | keys = append(keys, k) |