internal/control/admin.go
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}