internal/control/admin.go

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