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 | 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 @@ | |||
| 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 | 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 | ||
| 255 | func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { | 257 | func (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) |