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