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