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}