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