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}