internal/control/admin.go

6821a6f76082b1e10ff899ff51021b11695c4ad6
gitbay/internal/control/admin.go history · blame · raw

485 lines · 15984 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		RepoLimit   int64      `json:"repo_limit"` // 0 unlimited
188		ByteLimit   int64      `json:"byte_limit"` // 0 unlimited
189		APITokens   []tokenOut `json:"api_tokens"`
190		WebSessions int64      `json:"web_sessions"`
191	}
192	d := out{adminUserOut: adminUserRow(row),
193		Keys: []keyOut{}, Emails: []emailOut{}, PGPKeys: []pgpOut{}, Orgs: []orgOut{}, APITokens: []tokenOut{}}
194
195	keys, err := c.Store.ListSSHKeys(u.ID)
196	if err != nil {
197		return c.fail(protocol.ExitFailure, "%v", err)
198	}
199	for _, k := range keys {
200		d.Keys = append(d.Keys, keyOut{k.Fingerprint, k.Algo, k.Scope, k.CreatedAt, k.LastUsedAt})
201	}
202	emails, err := c.Store.ListEmails(u.ID)
203	if err != nil {
204		return c.fail(protocol.ExitFailure, "%v", err)
205	}
206	for _, e := range emails {
207		d.Emails = append(d.Emails, emailOut{e.Address, e.Verified, e.VerifiedBy, e.Primary})
208	}
209	pgp, err := c.Store.ListPGPKeys(u.ID)
210	if err != nil {
211		return c.fail(protocol.ExitFailure, "%v", err)
212	}
213	for _, k := range pgp {
214		d.PGPKeys = append(d.PGPKeys, pgpOut{k.Fingerprint, k.ExpiresAt, k.RevokedAt})
215	}
216	orgs, err := c.Store.ListOrgsForUser(u.ID)
217	if err != nil {
218		return c.fail(protocol.ExitFailure, "%v", err)
219	}
220	for _, m := range orgs {
221		d.Orgs = append(d.Orgs, orgOut{m.Username, m.Role})
222	}
223	if d.Repos, err = c.Store.OwnedRepoCount(u.ID); err != nil {
224		return c.fail(protocol.ExitFailure, "%v", err)
225	}
226	d.RepoLimit = RepoLimit(c.Store, limitsOf(c), u.ID)
227	d.ByteLimit = ByteLimit(c.Store, limitsOf(c), u.ID)
228	tokens, err := c.Store.ListAPITokens(u.ID)
229	if err != nil {
230		return c.fail(protocol.ExitFailure, "%v", err)
231	}
232	for _, t := range tokens {
233		d.APITokens = append(d.APITokens, tokenOut{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt})
234	}
235	if d.WebSessions, err = c.Store.WebSessionCount(u.ID); err != nil {
236		return c.fail(protocol.ExitFailure, "%v", err)
237	}
238
239	return c.emit(d, func(w io.Writer) {
240		fmt.Fprintf(w, "%s\t%s", d.Username, d.State)
241		if d.Admin {
242			fmt.Fprint(w, "\tadmin")
243		}
244		fmt.Fprintf(w, "\ncreated\t%s\n", d.CreatedAt)
245		if d.LastSeen != "" {
246			fmt.Fprintf(w, "last seen\t%s\n", d.LastSeen)
247		}
248		fmt.Fprintf(w, "repos\t%d\nweb sessions\t%d\n", d.Repos, d.WebSessions)
249		fmt.Fprintln(w, "keys:")
250		for _, k := range d.Keys {
251			fmt.Fprintf(w, "  %s\t%s\t%s\t%s\n", k.Fingerprint, k.Algo, k.Scope, k.LastUsedAt)
252		}
253		fmt.Fprintln(w, "emails:")
254		for _, e := range d.Emails {
255			state := "unverified"
256			if e.Verified {
257				state = "verified by " + e.VerifiedBy
258			}
259			mark := ""
260			if e.Primary {
261				mark = "\tprimary"
262			}
263			fmt.Fprintf(w, "  %s\t%s%s\n", e.Address, state, mark)
264		}
265		fmt.Fprintln(w, "pgp keys:")
266		for _, k := range d.PGPKeys {
267			fmt.Fprintf(w, "  %s\n", k.Fingerprint)
268		}
269		fmt.Fprintln(w, "orgs:")
270		for _, o := range d.Orgs {
271			fmt.Fprintf(w, "  %s\t%s\n", o.Org, o.Role)
272		}
273		fmt.Fprintln(w, "api tokens:")
274		for _, t := range d.APITokens {
275			used := ""
276			if t.LastUsedAt != nil {
277				used = t.LastUsedAt.UTC().Format(time.RFC3339)
278			}
279			fmt.Fprintf(w, "  %s\t%s\t%s\n", t.Name, t.Scope, strings.TrimSpace(used))
280		}
281	})
282}
283
284func runAdminUserPromote(c *Ctx, args []string) int { return setAdmin(c, args, true) }
285func runAdminUserDemote(c *Ctx, args []string) int  { return setAdmin(c, args, false) }
286
287func setAdmin(c *Ctx, args []string, admin bool) int {
288	if code := requireInstanceAdmin(c); code >= 0 {
289		return code
290	}
291	verb := "demote"
292	if admin {
293		verb = "promote"
294	}
295	if len(args) != 1 {
296		return c.fail(protocol.ExitUsage, "usage: admin user %s <username>", verb)
297	}
298	u, err := c.Store.UserByUsername(args[0])
299	if errors.Is(err, store.ErrNotFound) {
300		return c.fail(protocol.ExitNotFound, "no user %q", args[0])
301	} else if err != nil {
302		return c.fail(protocol.ExitFailure, "%v", err)
303	}
304	if u.IsAdmin == admin {
305		return c.fail(protocol.ExitUsage, "%s is already %s", u.Username, map[bool]string{true: "an admin", false: "not an admin"}[admin])
306	}
307	if admin && (u.Pending || u.Disabled) {
308		return c.fail(protocol.ExitUsage, "%s is %s; only an active account can be an admin", u.Username,
309			map[bool]string{true: "disabled", false: "pending"}[u.Disabled])
310	}
311	if err := c.Store.SetUserAdmin(u.ID, admin); err != nil {
312		if errors.Is(err, store.ErrLastAdmin) {
313			return c.fail(protocol.ExitUsage, "%v", err)
314		}
315		return c.fail(protocol.ExitFailure, "%v", err)
316	}
317	c.Store.Audit(c.User.ID, "admin user."+verb+"d", map[string]any{"user": u.Username})
318	return c.emit(map[string]any{"user": u.Username, "admin": admin}, func(w io.Writer) {
319		fmt.Fprintf(w, "%sd %s\n", verb, u.Username)
320	})
321}
322
323// adminRepo loads a repository for an admin override. Instance admin
324// carries no implicit read right, so policy is not consulted; the only
325// refusal is a path that does not exist. Every caller audits what it does.
326func adminRepo(c *Ctx, path string) (store.Repo, int) {
327	if code := requireInstanceAdmin(c); code >= 0 {
328		return store.Repo{}, code
329	}
330	repo, err := c.Store.RepoByPath(path)
331	if errors.Is(err, store.ErrNotFound) {
332		return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
333	} else if err != nil {
334		return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
335	}
336	return repo, -1
337}
338
339func runAdminRepoList(c *Ctx, args []string) int {
340	if code := requireInstanceAdmin(c); code >= 0 {
341		return code
342	}
343	args, p, code := parsePageFlags(c, args, "admin-repo", false)
344	if code >= 0 {
345		return code
346	}
347	var owner, visibility string
348	for i := 0; i < len(args); i++ {
349		switch args[i] {
350		case "--owner":
351			if i+1 >= len(args) {
352				return c.fail(protocol.ExitUsage, "--owner requires a value")
353			}
354			owner = args[i+1]
355			i++
356		case "--visibility":
357			if i+1 >= len(args) || (args[i+1] != "public" && args[i+1] != "private") {
358				return c.fail(protocol.ExitUsage, "--visibility requires public|private")
359			}
360			visibility = args[i+1]
361			i++
362		default:
363			return c.fail(protocol.ExitUsage, "usage: admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]")
364		}
365	}
366	repos, err := c.Store.ListReposAdmin(owner, visibility, p.queryLimit(), p.key)
367	if err != nil {
368		return c.fail(protocol.ExitFailure, "%v", err)
369	}
370	repos, next := trimPage(p, repos, "admin-repo", func(r store.AdminRepo) string { return r.Path })
371	type out struct {
372		Path       string `json:"path"`
373		Visibility string `json:"visibility"`
374		Archived   bool   `json:"archived,omitempty"`
375		CreatedAt  string `json:"created_at"`
376		LastPush   string `json:"last_push,omitempty"`
377		Bytes      int64  `json:"bytes"`
378	}
379	var ds []out
380	for _, r := range repos {
381		size := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
382		ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size})
383	}
384	return c.emitPage(p, ds, next, func(w io.Writer) {
385		for _, d := range ds {
386			mark := ""
387			if d.Archived {
388				mark = "\t[archived]"
389			}
390			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)
391		}
392	})
393}
394
395func runAdminRepoArchive(c *Ctx, args []string) int   { return adminArchive(c, args, true) }
396func runAdminRepoUnarchive(c *Ctx, args []string) int { return adminArchive(c, args, false) }
397
398func adminArchive(c *Ctx, args []string, archived bool) int {
399	verb := "archive"
400	if !archived {
401		verb = "unarchive"
402	}
403	if len(args) != 1 {
404		return c.fail(protocol.ExitUsage, "usage: admin repo %s <owner/name>", verb)
405	}
406	repo, code := adminRepo(c, args[0])
407	if code >= 0 {
408		return code
409	}
410	if code := archiveRepo(c, repo, archived); code != protocol.ExitOK {
411		return code
412	}
413	c.Store.Audit(c.User.ID, "admin repo."+verb, map[string]any{"repo": repo.Path()})
414	return protocol.ExitOK
415}
416
417func runAdminRepoVisibility(c *Ctx, args []string) int {
418	if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
419		return c.fail(protocol.ExitUsage, "usage: admin repo visibility <owner/name> public|private")
420	}
421	repo, code := adminRepo(c, args[0])
422	if code >= 0 {
423		return code
424	}
425	if code := setRepoVisibility(c, repo, args[1]); code != protocol.ExitOK {
426		return code
427	}
428	c.Store.Audit(c.User.ID, "admin repo.visibility", map[string]any{"repo": repo.Path(), "visibility": args[1]})
429	return protocol.ExitOK
430}
431
432func runAdminRepoDelete(c *Ctx, args []string) int {
433	var path string
434	var yes bool
435	for _, a := range args {
436		if a == "--yes" {
437			yes = true
438		} else if path == "" {
439			path = a
440		} else {
441			return c.fail(protocol.ExitUsage, "usage: admin repo delete <owner/name> --yes")
442		}
443	}
444	if path == "" {
445		return c.fail(protocol.ExitUsage, "usage: admin repo delete <owner/name> --yes")
446	}
447	repo, code := adminRepo(c, path)
448	if code >= 0 {
449		return code
450	}
451	if !yes {
452		return c.fail(protocol.ExitUsage, "admin repo delete is permanent; re-run with --yes")
453	}
454	if code := deleteRepo(c, repo); code != protocol.ExitOK {
455		return code
456	}
457	c.Store.Audit(c.User.ID, "admin repo.delete", map[string]any{"repo": repo.Path()})
458	return protocol.ExitOK
459}
460
461func runAdminRunners(c *Ctx, args []string) int {
462	if code := requireInstanceAdmin(c); code >= 0 {
463		return code
464	}
465	if len(args) != 0 {
466		return c.fail(protocol.ExitUsage, "usage: admin runners")
467	}
468	runners, err := c.Store.ListRunners()
469	if err != nil {
470		return c.fail(protocol.ExitFailure, "%v", err)
471	}
472	return c.emit(runners, func(w io.Writer) {
473		for _, r := range runners {
474			scope := r.Scope
475			if scope == "" {
476				scope = "any"
477			}
478			held := "idle"
479			if r.BuildNumber != 0 {
480				held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
481			}
482			fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Username, r.LastSeen, scope, held)
483		}
484	})
485}