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}