internal/control/admin.go

c72da83f1a127ce5d5310bddea7240344b0dc11f
gitbay/internal/control/admin.go history · blame · raw

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}