internal/control/admin.go

3f4afd2210627e46fd0cea1f5831857af71638f6
gitbay/internal/control/admin.go history · blame · raw

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}