internal/control/admin.go

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