internal/control/admin.go
492 lines · 16574 bytes
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "strings"
8 "time"
9
10 "gitbay.org/gitbay/internal/gitutil"
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15func init() {
16 register(Command{Path: []string{"admin", "user", "list"},
17 Summary: "list accounts (instance admins)",
18 Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]",
19 ReadOnly: true, SSHOnly: true, Run: runAdminUserList})
20 register(Command{Path: []string{"admin", "user", "show"},
21 Summary: "show an account: keys, emails, orgs, tokens, sessions (instance admins)",
22 Usage: "admin user show <username>",
23 ReadOnly: true, SSHOnly: true, Run: runAdminUserShow})
24 register(Command{Path: []string{"admin", "user", "promote"},
25 Summary: "make an account an instance admin",
26 Usage: "admin user promote <username>",
27 SSHOnly: true, Run: runAdminUserPromote})
28 register(Command{Path: []string{"admin", "user", "demote"},
29 Summary: "remove instance admin from an account (never the last one)",
30 Usage: "admin user demote <username>",
31 SSHOnly: true, Run: runAdminUserDemote})
32 register(Command{Path: []string{"admin", "runners"},
33 Summary: "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)",
34 Usage: "admin runners",
35 ReadOnly: true, SSHOnly: true, Run: runAdminRunners})
36 register(Command{Path: []string{"admin", "repo", "list"},
37 Summary: "list every repository with size and last push (instance admins)",
38 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]",
39 ReadOnly: true, SSHOnly: true, Run: runAdminRepoList})
40 register(Command{Path: []string{"admin", "repo", "archive"},
41 Summary: "archive any repository (instance admins; audited)",
42 Usage: "admin repo archive <owner/name>",
43 SSHOnly: true, Run: runAdminRepoArchive})
44 register(Command{Path: []string{"admin", "repo", "unarchive"},
45 Summary: "unarchive any repository (instance admins; audited)",
46 Usage: "admin repo unarchive <owner/name>",
47 SSHOnly: true, Run: runAdminRepoUnarchive})
48 register(Command{Path: []string{"admin", "repo", "visibility"},
49 Summary: "set any repository's visibility (instance admins; audited)",
50 Usage: "admin repo visibility <owner/name> public|private",
51 SSHOnly: true, Run: runAdminRepoVisibility})
52 register(Command{Path: []string{"admin", "repo", "delete"},
53 Summary: "delete any repository (instance admins; audited)",
54 Usage: "admin repo delete <owner/name> --yes",
55 SSHOnly: true, Run: runAdminRepoDelete})
56}
57
58// requireInstanceAdmin gates the admin noun. -1 means proceed.
59func requireInstanceAdmin(c *Ctx) int {
60 if !c.User.IsAdmin {
61 return c.fail(protocol.ExitDenied, "admin commands are for instance admins")
62 }
63 return -1
64}
65
66// adminUserOut is one account row, shared by list and show.
67type adminUserOut struct {
68 Username string `json:"username"`
69 State string `json:"state"` // active | pending | disabled
70 Admin bool `json:"admin"`
71 CreatedAt string `json:"created_at"`
72 LastSeen string `json:"last_seen,omitempty"`
73}
74
75func adminUserRow(u store.AdminUser) adminUserOut {
76 state := "active"
77 switch {
78 case u.Disabled:
79 state = "disabled"
80 case u.Pending:
81 state = "pending"
82 }
83 return adminUserOut{u.Username, state, u.IsAdmin, u.CreatedAt, u.LastSeen}
84}
85
86func runAdminUserList(c *Ctx, args []string) int {
87 if code := requireInstanceAdmin(c); code >= 0 {
88 return code
89 }
90 args, p, code := parsePageFlags(c, args, "admin-user", false)
91 if code >= 0 {
92 return code
93 }
94 f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 0,
95 Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]"})
96 if err != nil {
97 return c.fail(protocol.ExitUsage, "%v", err)
98 }
99 state := f.Value("--state")
100 switch state {
101 case "", "active", "pending", "disabled", "admin":
102 default:
103 return c.fail(protocol.ExitUsage, "--state requires active|pending|disabled|admin")
104 }
105 users, err := c.Store.ListUsers(state, p.queryLimit(), p.key)
106 if err != nil {
107 return c.fail(protocol.ExitFailure, "%v", err)
108 }
109 users, next := trimPage(p, users, "admin-user", func(u store.AdminUser) string { return u.Username })
110 var ds []adminUserOut
111 for _, u := range users {
112 ds = append(ds, adminUserRow(u))
113 }
114 return c.emitPage(p, ds, next, func(w io.Writer) {
115 for _, d := range ds {
116 mark := ""
117 if d.Admin {
118 mark = "admin"
119 }
120 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.Username, d.State, mark, d.CreatedAt, d.LastSeen)
121 }
122 })
123}
124
125func runAdminUserShow(c *Ctx, args []string) int {
126 if code := requireInstanceAdmin(c); code >= 0 {
127 return code
128 }
129 if len(args) != 1 {
130 return c.fail(protocol.ExitUsage, "usage: admin user show <username>")
131 }
132 name := args[0]
133 u, err := c.Store.UserByUsername(name)
134 if errors.Is(err, store.ErrNotFound) {
135 return c.fail(protocol.ExitNotFound, "no user %q", name)
136 } else if err != nil {
137 return c.fail(protocol.ExitFailure, "%v", err)
138 }
139 row, err := c.Store.AdminUserByName(name)
140 if err != nil {
141 return c.fail(protocol.ExitFailure, "%v", err)
142 }
143
144 type keyOut struct {
145 Fingerprint string `json:"fingerprint"`
146 Algo string `json:"algo"`
147 Scope string `json:"scope"`
148 CreatedAt string `json:"created_at"`
149 LastUsedAt string `json:"last_used_at,omitempty"`
150 }
151 type emailOut struct {
152 Address string `json:"address"`
153 Verified bool `json:"verified"`
154 VerifiedBy string `json:"verified_by,omitempty"` // smtp | admin
155 Primary bool `json:"primary"`
156 }
157 type pgpOut struct {
158 Fingerprint string `json:"fingerprint"`
159 ExpiresAt *time.Time `json:"expires_at,omitempty"`
160 RevokedAt *time.Time `json:"revoked_at,omitempty"`
161 }
162 type orgOut struct {
163 Org string `json:"org"`
164 Role string `json:"role"`
165 }
166 type tokenOut struct {
167 Name string `json:"name"`
168 Scope string `json:"scope"`
169 CreatedAt string `json:"created_at"`
170 ExpiresAt *time.Time `json:"expires_at,omitempty"`
171 LastUsedAt *time.Time `json:"last_used_at,omitempty"`
172 }
173 type out struct {
174 adminUserOut
175 Keys []keyOut `json:"keys"`
176 Emails []emailOut `json:"emails"`
177 PGPKeys []pgpOut `json:"pgp_keys"`
178 Orgs []orgOut `json:"orgs"`
179 Repos int64 `json:"repos"`
180 RepoLimit int64 `json:"repo_limit"` // 0 unlimited
181 ByteLimit int64 `json:"byte_limit"` // 0 unlimited
182 APITokens []tokenOut `json:"api_tokens"`
183 WebSessions int64 `json:"web_sessions"`
184 }
185 d := out{adminUserOut: adminUserRow(row),
186 Keys: []keyOut{}, Emails: []emailOut{}, PGPKeys: []pgpOut{}, Orgs: []orgOut{}, APITokens: []tokenOut{}}
187
188 keys, err := c.Store.ListSSHKeys(u.ID)
189 if err != nil {
190 return c.fail(protocol.ExitFailure, "%v", err)
191 }
192 for _, k := range keys {
193 d.Keys = append(d.Keys, keyOut{k.Fingerprint, k.Algo, k.Scope, k.CreatedAt, k.LastUsedAt})
194 }
195 emails, err := c.Store.ListEmails(u.ID)
196 if err != nil {
197 return c.fail(protocol.ExitFailure, "%v", err)
198 }
199 for _, e := range emails {
200 d.Emails = append(d.Emails, emailOut{e.Address, e.Verified, e.VerifiedBy, e.Primary})
201 }
202 pgp, err := c.Store.ListPGPKeys(u.ID)
203 if err != nil {
204 return c.fail(protocol.ExitFailure, "%v", err)
205 }
206 for _, k := range pgp {
207 d.PGPKeys = append(d.PGPKeys, pgpOut{k.Fingerprint, k.ExpiresAt, k.RevokedAt})
208 }
209 orgs, err := c.Store.ListOrgsForUser(u.ID)
210 if err != nil {
211 return c.fail(protocol.ExitFailure, "%v", err)
212 }
213 for _, m := range orgs {
214 d.Orgs = append(d.Orgs, orgOut{m.Username, m.Role})
215 }
216 if d.Repos, err = c.Store.OwnedRepoCount(u.ID); err != nil {
217 return c.fail(protocol.ExitFailure, "%v", err)
218 }
219 d.RepoLimit = RepoLimit(c.Store, limitsOf(c), u.ID)
220 d.ByteLimit = ByteLimit(c.Store, limitsOf(c), u.ID)
221 tokens, err := c.Store.ListAPITokens(u.ID)
222 if err != nil {
223 return c.fail(protocol.ExitFailure, "%v", err)
224 }
225 for _, t := range tokens {
226 d.APITokens = append(d.APITokens, tokenOut{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt})
227 }
228 if d.WebSessions, err = c.Store.WebSessionCount(u.ID); err != nil {
229 return c.fail(protocol.ExitFailure, "%v", err)
230 }
231
232 return c.emit(d, func(w io.Writer) {
233 fmt.Fprintf(w, "%s\t%s", d.Username, d.State)
234 if d.Admin {
235 fmt.Fprint(w, "\tadmin")
236 }
237 fmt.Fprintf(w, "\ncreated\t%s\n", d.CreatedAt)
238 if d.LastSeen != "" {
239 fmt.Fprintf(w, "last seen\t%s\n", d.LastSeen)
240 }
241 fmt.Fprintf(w, "repos\t%d\nweb sessions\t%d\n", d.Repos, d.WebSessions)
242 fmt.Fprintln(w, "keys:")
243 for _, k := range d.Keys {
244 fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", k.Fingerprint, k.Algo, k.Scope, k.LastUsedAt)
245 }
246 fmt.Fprintln(w, "emails:")
247 for _, e := range d.Emails {
248 state := "unverified"
249 if e.Verified {
250 state = "verified by " + e.VerifiedBy
251 }
252 mark := ""
253 if e.Primary {
254 mark = "\tprimary"
255 }
256 fmt.Fprintf(w, " %s\t%s%s\n", e.Address, state, mark)
257 }
258 fmt.Fprintln(w, "pgp keys:")
259 for _, k := range d.PGPKeys {
260 fmt.Fprintf(w, " %s\n", k.Fingerprint)
261 }
262 fmt.Fprintln(w, "orgs:")
263 for _, o := range d.Orgs {
264 fmt.Fprintf(w, " %s\t%s\n", o.Org, o.Role)
265 }
266 fmt.Fprintln(w, "api tokens:")
267 for _, t := range d.APITokens {
268 used := ""
269 if t.LastUsedAt != nil {
270 used = t.LastUsedAt.UTC().Format(time.RFC3339)
271 }
272 fmt.Fprintf(w, " %s\t%s\t%s\n", t.Name, t.Scope, strings.TrimSpace(used))
273 }
274 })
275}
276
277func runAdminUserPromote(c *Ctx, args []string) int { return setAdmin(c, args, true) }
278func runAdminUserDemote(c *Ctx, args []string) int { return setAdmin(c, args, false) }
279
280func setAdmin(c *Ctx, args []string, admin bool) int {
281 if code := requireInstanceAdmin(c); code >= 0 {
282 return code
283 }
284 verb := "demote"
285 if admin {
286 verb = "promote"
287 }
288 if len(args) != 1 {
289 return c.fail(protocol.ExitUsage, "usage: admin user %s <username>", verb)
290 }
291 u, err := c.Store.UserByUsername(args[0])
292 if errors.Is(err, store.ErrNotFound) {
293 return c.fail(protocol.ExitNotFound, "no user %q", args[0])
294 } else if err != nil {
295 return c.fail(protocol.ExitFailure, "%v", err)
296 }
297 if u.IsAdmin == admin {
298 return c.fail(protocol.ExitUsage, "%s is already %s", u.Username, map[bool]string{true: "an admin", false: "not an admin"}[admin])
299 }
300 if admin && (u.Pending || u.Disabled) {
301 return c.fail(protocol.ExitUsage, "%s is %s; only an active account can be an admin", u.Username,
302 map[bool]string{true: "disabled", false: "pending"}[u.Disabled])
303 }
304 if err := c.Store.SetUserAdmin(u.ID, admin); err != nil {
305 if errors.Is(err, store.ErrLastAdmin) {
306 return c.failErr(err)
307 }
308 return c.fail(protocol.ExitFailure, "%v", err)
309 }
310 c.Store.Audit(c.User.ID, "admin user."+verb+"d", map[string]any{"user": u.Username})
311 return c.emit(map[string]any{"user": u.Username, "admin": admin}, func(w io.Writer) {
312 fmt.Fprintf(w, "%sd %s\n", verb, u.Username)
313 })
314}
315
316// adminRepo loads a repository for an admin override. Instance admin
317// carries no implicit read right, so policy is not consulted; the only
318// refusal is a path that does not exist. Every caller audits what it does.
319func adminRepo(c *Ctx, path string) (store.Repo, int) {
320 if code := requireInstanceAdmin(c); code >= 0 {
321 return store.Repo{}, code
322 }
323 repo, err := c.Store.RepoByPath(path)
324 if errors.Is(err, store.ErrNotFound) {
325 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
326 } else if err != nil {
327 return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
328 }
329 return repo, -1
330}
331
332func runAdminRepoList(c *Ctx, args []string) int {
333 if code := requireInstanceAdmin(c); code >= 0 {
334 return code
335 }
336 args, p, code := parsePageFlags(c, args, "admin-repo", false)
337 if code >= 0 {
338 return code
339 }
340 f, err := parseFlags(args, flagSpec{Values: []string{"--owner", "--visibility"}, MaxPos: 0,
341 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]"})
342 if err != nil {
343 return c.fail(protocol.ExitUsage, "%v", err)
344 }
345 owner, visibility := f.Value("--owner"), f.Value("--visibility")
346 if visibility != "" && visibility != "public" && visibility != "private" {
347 return c.fail(protocol.ExitUsage, "--visibility requires public|private")
348 }
349 repos, err := c.Store.ListReposAdmin(owner, visibility, p.queryLimit(), p.key)
350 if err != nil {
351 return c.fail(protocol.ExitFailure, "%v", err)
352 }
353 repos, next := trimPage(p, repos, "admin-repo", func(r store.AdminRepo) string { return r.Path })
354 type out struct {
355 Path string `json:"path"`
356 Visibility string `json:"visibility"`
357 Archived bool `json:"archived,omitempty"`
358 CreatedAt string `json:"created_at"`
359 LastPush string `json:"last_push,omitempty"`
360 Bytes int64 `json:"bytes"`
361 }
362 var ds []out
363 for _, r := range repos {
364 size := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
365 ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size})
366 }
367 return c.emitPage(p, ds, next, func(w io.Writer) {
368 for _, d := range ds {
369 mark := ""
370 if d.Archived {
371 mark = "\t[archived]"
372 }
373 fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s%s\n", d.Path, d.Visibility, d.Bytes, d.CreatedAt, d.LastPush, mark)
374 }
375 })
376}
377
378func runAdminRepoArchive(c *Ctx, args []string) int { return adminArchive(c, args, true) }
379func runAdminRepoUnarchive(c *Ctx, args []string) int { return adminArchive(c, args, false) }
380
381func adminArchive(c *Ctx, args []string, archived bool) int {
382 verb := "archive"
383 if !archived {
384 verb = "unarchive"
385 }
386 if len(args) != 1 {
387 return c.fail(protocol.ExitUsage, "usage: admin repo %s <owner/name>", verb)
388 }
389 repo, code := adminRepo(c, args[0])
390 if code >= 0 {
391 return code
392 }
393 if code := archiveRepo(c, repo, archived); code != protocol.ExitOK {
394 return code
395 }
396 c.Store.Audit(c.User.ID, "admin repo."+verb, map[string]any{"repo": repo.Path()})
397 return protocol.ExitOK
398}
399
400func runAdminRepoVisibility(c *Ctx, args []string) int {
401 if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
402 return c.fail(protocol.ExitUsage, "usage: admin repo visibility <owner/name> public|private")
403 }
404 repo, code := adminRepo(c, args[0])
405 if code >= 0 {
406 return code
407 }
408 if code := setRepoVisibility(c, repo, args[1]); code != protocol.ExitOK {
409 return code
410 }
411 c.Store.Audit(c.User.ID, "admin repo.visibility", map[string]any{"repo": repo.Path(), "visibility": args[1]})
412 return protocol.ExitOK
413}
414
415func runAdminRepoDelete(c *Ctx, args []string) int {
416 var path string
417 var yes bool
418 for _, a := range args {
419 if a == "--yes" {
420 yes = true
421 } else if path == "" {
422 path = a
423 } else {
424 return c.fail(protocol.ExitUsage, "usage: admin repo delete <owner/name> --yes")
425 }
426 }
427 if path == "" {
428 return c.fail(protocol.ExitUsage, "usage: admin repo delete <owner/name> --yes")
429 }
430 repo, code := adminRepo(c, path)
431 if code >= 0 {
432 return code
433 }
434 if !yes {
435 return c.fail(protocol.ExitUsage, "admin repo delete is permanent; re-run with --yes")
436 }
437 if code := deleteRepo(c, repo); code != protocol.ExitOK {
438 return code
439 }
440 c.Store.Audit(c.User.ID, "admin repo.delete", map[string]any{"repo": repo.Path()})
441 return protocol.ExitOK
442}
443
444func runAdminRunners(c *Ctx, args []string) int {
445 if code := requireInstanceAdmin(c); code >= 0 {
446 return code
447 }
448 if len(args) != 0 {
449 return c.fail(protocol.ExitUsage, "usage: admin runners")
450 }
451 runners, err := c.Store.ListRunners()
452 if err != nil {
453 return c.fail(protocol.ExitFailure, "%v", err)
454 }
455 queue, err := c.Store.QueueStats()
456 if err != nil {
457 return c.fail(protocol.ExitFailure, "%v", err)
458 }
459 if runners == nil {
460 runners = []store.Runner{}
461 }
462 for i := range runners {
463 if runners[i].Scope != "" {
464 continue
465 }
466 key, err := c.Store.SSHKeyByID(runners[i].KeyID)
467 if err != nil || key.Scope != "runner" {
468 continue // an admin key with no -repos: any
469 }
470 paths, err := c.Store.RunnerRepoPaths(runners[i].KeyID)
471 if err != nil {
472 return c.fail(protocol.ExitFailure, "%v", err)
473 }
474 runners[i].Scope = strings.Join(paths, ",")
475 }
476 d := map[string]any{"queue": queue, "runners": runners}
477 return c.emit(d, func(w io.Writer) {
478 fmt.Fprintf(w, "queue: %d pending; last 24h: %d claimed, wait avg %ds max %ds, %d reaped\n",
479 queue.Pending, queue.Claimed24h, queue.ClaimWaitAvgS, queue.ClaimWaitMaxS, queue.Reaped24h)
480 for _, r := range runners {
481 scope := r.Scope
482 if scope == "" {
483 scope = "any"
484 }
485 held := "idle"
486 if r.BuildNumber != 0 {
487 held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
488 }
489 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Username, r.Fingerprint, r.LastSeen, scope, held)
490 }
491 })
492}