internal/control/admin.go

4bfcb3893d5ad29d5c6ba0725fd2291d20003e30
gitbay/internal/control/admin.go history · blame · raw

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