gitbayd admin dispatches into the registry !153

merged merged by cmc on 2026-09-02 01:32 UTC · krz/gitbay:admin-host into main

7 files changed, +522 −325

Layout: unified · split

cmd/gitbay/main.go +10 −1
@@ -81,7 +81,16 @@ func newRoot() *cobra.Command {
8181 pass("show", "show an account: <username>", passOpts{server: []string{"admin", "user", "show"}}),
8282 pass("promote", "make an account an instance admin: <username>", passOpts{server: []string{"admin", "user", "promote"}}),
8383 pass("demote", "remove instance admin (never the last one): <username>", passOpts{server: []string{"admin", "user", "demote"}}),
84 pass("create", "create an account: <username> [--admin] [--email a [--verified]] [--key -] < key.pub", passOpts{server: []string{"admin", "user", "create"}, stdinOK: true}),
85 pass("disable", "suspend an account: <username>", passOpts{server: []string{"admin", "user", "disable"}}),
86 pass("enable", "restore a suspended account: <username>", passOpts{server: []string{"admin", "user", "enable"}}),
87 pass("delete", "delete an account that anchors nothing: <username> --yes", passOpts{server: []string{"admin", "user", "delete"}}),
8488 ),
89 group("email", "addresses on any account",
90 pass("verify", "mark an address verified by admin assertion: <username> <address>", passOpts{server: []string{"admin", "email", "verify"}}),
91 ),
92 pass("invite", "issue a registration invite and mail its code: --email <address>", passOpts{server: []string{"admin", "invite"}}),
93 pass("stats", "instance statistics: counts and per-repository disk usage", passOpts{server: []string{"admin", "stats"}}),
8594 group("repo", "any repository, for moderation (audited)",
8695 pass("list", "every repository with size and last push: [--owner o] [--visibility v] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "repo", "list"}}),
8796 pass("archive", "archive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "archive"}}),
@@ -209,7 +218,7 @@ func isEmptyReader(r io.Reader) bool {
209218// usesStdin reports whether the arguments request stdin content.
210219func usesStdin(args []string) bool {
211220 for i, a := range args {
212 if a == "--file" && i+1 < len(args) && args[i+1] == "-" {
221 if (a == "--file" || a == "--key") && i+1 < len(args) && args[i+1] == "-" {
213222 return true
214223 }
215224 if a == "--token-stdin" {
cmd/gitbayd/adminusers.go −126
@@ -8,101 +8,8 @@ import (
88 "gitbay.org/gitbay/internal/config"
99 "gitbay.org/gitbay/internal/control"
1010 "gitbay.org/gitbay/internal/gitutil"
11 "gitbay.org/gitbay/internal/store"
1211)
1312
14func withUser(use, short string, run func(st *store.Store, u store.User) error) *cobra.Command {
15 return &cobra.Command{
16 Use: use + " <username>",
17 Short: short,
18 Args: cobra.ExactArgs(1),
19 RunE: func(cmd *cobra.Command, args []string) error {
20 cfg, err := config.Load(configPath)
21 if err != nil {
22 return err
23 }
24 st, err := openStore(cfg)
25 if err != nil {
26 return err
27 }
28 defer st.Close()
29 u, err := st.UserByUsername(args[0])
30 if err != nil {
31 return fmt.Errorf("no user %q", args[0])
32 }
33 return run(st, u)
34 },
35 }
36}
37
38func adminUserDisableCmd() *cobra.Command {
39 return withUser("disable", "suspend an account: keys and sessions refused until re-enabled",
40 func(st *store.Store, u store.User) error {
41 if err := st.SetUserDisabled(u.ID, true); err != nil {
42 return err
43 }
44 st.Audit(0, "admin user.disabled", map[string]any{"user": u.Username})
45 fmt.Printf("disabled %s: SSH, web sessions, and API tokens are refused; nothing was deleted\n", u.Username)
46 return nil
47 })
48}
49
50func adminUserDeleteCmd() *cobra.Command {
51 var yes bool
52 cmd := withUser("delete", "delete an account that anchors nothing (keys, emails, and sessions go with it)",
53 func(st *store.Store, u store.User) error {
54 if !yes {
55 return fmt.Errorf("deletion is permanent; pass --yes")
56 }
57 if err := st.DeleteUser(u.ID); err != nil {
58 return err
59 }
60 st.Audit(0, "admin user.deleted", map[string]any{"user": u.Username})
61 fmt.Printf("deleted %s\n", u.Username)
62 return nil
63 })
64 cmd.Flags().BoolVar(&yes, "yes", false, "confirm permanent deletion")
65 return cmd
66}
67
68func adminUserEnableCmd() *cobra.Command {
69 return withUser("enable", "restore a suspended account",
70 func(st *store.Store, u store.User) error {
71 if err := st.SetUserDisabled(u.ID, false); err != nil {
72 return err
73 }
74 st.Audit(0, "admin user.enabled", map[string]any{"user": u.Username})
75 fmt.Printf("enabled %s\n", u.Username)
76 return nil
77 })
78}
79
80// adminUserPromoteCmd is the recovery path when no admin key is reachable:
81// it needs the host, not an admin session.
82func adminUserPromoteCmd() *cobra.Command {
83 return withUser("promote", "make an account an instance admin",
84 func(st *store.Store, u store.User) error {
85 if err := st.SetUserAdmin(u.ID, true); err != nil {
86 return err
87 }
88 st.Audit(0, "admin user.promoted", map[string]any{"user": u.Username})
89 fmt.Printf("promoted %s\n", u.Username)
90 return nil
91 })
92}
93
94func adminUserDemoteCmd() *cobra.Command {
95 return withUser("demote", "remove instance admin from an account (never the last one)",
96 func(st *store.Store, u store.User) error {
97 if err := st.SetUserAdmin(u.ID, false); err != nil {
98 return err
99 }
100 st.Audit(0, "admin user.demoted", map[string]any{"user": u.Username})
101 fmt.Printf("demoted %s\n", u.Username)
102 return nil
103 })
104}
105
10613// adminMigrateCommitRefsCmd is a one-shot backfill: legacy commit-reference
10714// comments (author-attributed, bare sha) become system messages with a
10815// linked sha. Idempotent.
@@ -178,36 +85,3 @@ func adminBackfillActivityCmd() *cobra.Command {
17885 cmd.Flags().IntVar(&perRepo, "per-repo", 20000, "max commits walked per repository")
17986 return cmd
18087}
181
182func adminAuditCmd() *cobra.Command {
183 var limit int
184 cmd := &cobra.Command{
185 Use: "audit",
186 Short: "print the security audit log, newest first",
187 RunE: func(cmd *cobra.Command, args []string) error {
188 cfg, err := config.Load(configPath)
189 if err != nil {
190 return err
191 }
192 st, err := openStore(cfg)
193 if err != nil {
194 return err
195 }
196 defer st.Close()
197 entries, err := st.AuditEntries(limit)
198 if err != nil {
199 return err
200 }
201 for _, e := range entries {
202 actor := e.Actor
203 if actor == "" {
204 actor = "-"
205 }
206 fmt.Printf("%s\t%s\t%s\t%s\n", e.CreatedAt, actor, e.Action, e.Data)
207 }
208 return nil
209 },
210 }
211 cmd.Flags().IntVar(&limit, "limit", 100, "entries to print")
212 return cmd
213}
cmd/gitbayd/main.go +106 −135
@@ -5,6 +5,7 @@ package main
55import (
66 "context"
77 "fmt"
8 "io"
89 "log/slog"
910 "net"
1011 "net/http"
@@ -16,7 +17,6 @@ import (
1617
1718 "github.com/spf13/cobra"
1819 "golang.org/x/crypto/acme/autocert"
19 "golang.org/x/crypto/ssh"
2020
2121 "gitbay.org/gitbay/internal/buildinfo"
2222 "gitbay.org/gitbay/internal/ci"
@@ -26,10 +26,8 @@ import (
2626 "gitbay.org/gitbay/internal/gitd"
2727 "gitbay.org/gitbay/internal/hookd"
2828 "gitbay.org/gitbay/internal/httpd"
29 "gitbay.org/gitbay/internal/mail"
3029 "gitbay.org/gitbay/internal/mirror"
3130 "gitbay.org/gitbay/internal/notify"
32 "gitbay.org/gitbay/internal/policy"
3331 "gitbay.org/gitbay/internal/sshd"
3432 "gitbay.org/gitbay/internal/store"
3533 "gitbay.org/gitbay/internal/webhook"
@@ -309,164 +307,137 @@ func adminCmd() *cobra.Command {
309307 Short: "host-local administration",
310308 }
311309 userCmd := &cobra.Command{Use: "user", Short: "manage users"}
312 userCmd.AddCommand(adminUserCreateCmd(), adminUserDisableCmd(), adminUserEnableCmd(), adminUserDeleteCmd(),
313 adminUserPromoteCmd(), adminUserDemoteCmd())
310 userCmd.AddCommand(
311 hostUserCreateCmd(),
312 hostCmd("list [--state active|pending|disabled|admin] [--limit n] [--cursor c]", "list accounts", "admin", "user", "list"),
313 hostCmd("show <username>", "show an account: keys, emails, orgs, tokens, sessions", "admin", "user", "show"),
314 hostCmd("disable <username>", "suspend an account: keys and sessions refused until re-enabled", "admin", "user", "disable"),
315 hostCmd("enable <username>", "restore a suspended account", "admin", "user", "enable"),
316 hostCmd("delete <username> --yes", "delete an account that anchors nothing (keys, emails, and sessions go with it)", "admin", "user", "delete"),
317 hostCmd("promote <username>", "make an account an instance admin", "admin", "user", "promote"),
318 hostCmd("demote <username>", "remove instance admin from an account (never the last one)", "admin", "user", "demote"),
319 )
314320 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
315 emailCmd.AddCommand(adminEmailVerifyCmd())
321 emailCmd.AddCommand(hostCmd("verify <username> <address>", "mark an email verified by admin assertion", "admin", "email", "verify"))
322 repoCmd := &cobra.Command{Use: "repo", Short: "any repository, for moderation (audited)"}
323 repoCmd.AddCommand(
324 hostCmd("list [--owner o] [--visibility public|private] [--limit n] [--cursor c]", "every repository with size and last push", "admin", "repo", "list"),
325 hostCmd("archive <owner/name>", "archive a repository", "admin", "repo", "archive"),
326 hostCmd("unarchive <owner/name>", "unarchive a repository", "admin", "repo", "unarchive"),
327 hostCmd("visibility <owner/name> public|private", "set a repository's visibility", "admin", "repo", "visibility"),
328 hostCmd("delete <owner/name> --yes", "delete a repository", "admin", "repo", "delete"),
329 )
316330 admin.AddCommand(
317331 userCmd,
318332 emailCmd,
319 adminInviteCmd(),
333 repoCmd,
334 hostCmd("invite --email <address>", "issue a registration invite and email its code", "admin", "invite"),
335 hostCmd("stats [--json]", "instance statistics: counts and per-repository disk usage", "admin", "stats"),
336 hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit"),
320337 backupCmd(),
321338 gcCmd(),
322 statsCmd(),
323 adminAuditCmd(),
324339 adminMigrateCommitRefsCmd(),
325340 adminBackfillActivityCmd(),
326341 )
327342 return admin
328343}
329344
330func adminInviteCmd() *cobra.Command {
331 var email string
332 cmd := &cobra.Command{
333 Use: "invite",
334 Short: "issue a registration invite and email its code",
345// hostCmd runs a registry command as the host itself: an admin context
346// with no account behind it, so audit rows carry no actor and the source
347// "host". Arguments pass through untouched; the registry owns the flags,
348// which is what keeps this surface and an admin's SSH session from
349// drifting.
350func hostCmd(use, short string, path ...string) *cobra.Command {
351 return &cobra.Command{
352 Use: use,
353 Short: short,
354 DisableFlagParsing: true,
335355 RunE: func(cmd *cobra.Command, args []string) error {
336 if email == "" {
337 return fmt.Errorf("--email is required")
338 }
339 cfg, err := config.Load(configPath)
340 if err != nil {
341 return err
342 }
343 st, err := openStore(cfg)
344 if err != nil {
345 return err
346 }
347 defer st.Close()
348
349 if used, err := st.EmailInUse(email); err != nil {
350 return err
351 } else if used {
352 return fmt.Errorf("%s already belongs to an account; invites are for new users", email)
353 }
354 code, hash, err := store.NewToken()
355 if err != nil {
356 return err
357 }
358 if err := st.CreateInvite(hash, email); err != nil {
359 return err
360 }
361 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://"), "/")
362 body := fmt.Sprintf(
363 "You have been invited to %s.\n\nCreate your account by running (with the SSH key you want to use):\n\n"+
364 " ssh git@%s register --username <name> --invite %s\n\n"+
365 "The invite is single-use and tied to this address.\n", host, host, code)
366 if cfg.Mail.SMTPHost != "" {
367 if err := mail.Send(cfg, email, "your invite to "+host, body); err != nil {
368 return fmt.Errorf("invite stored but mail failed: %w (code: %s)", err, code)
356 for _, a := range args {
357 if a == "--help" || a == "-h" {
358 return cmd.Help()
369359 }
370 st.Audit(0, "admin invite.issued", map[string]any{"email": email})
371 fmt.Printf("invite emailed to %s\n", email)
372 } else {
373 fmt.Printf("invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code)
374360 }
375 return nil
361 return runAsHost(path, args, os.Stdin)
376362 },
377363 }
378 cmd.Flags().StringVar(&email, "email", "", "address to invite (the account's verified email)")
379 return cmd
380364}
381365
382func adminUserCreateCmd() *cobra.Command {
383 var keyPath, email string
384 var verified, isAdmin bool
385 cmd := &cobra.Command{
386 Use: "create <username>",
387 Short: "create a user (host-local bootstrap; the only path in closed mode)",
388 Args: cobra.ExactArgs(1),
389 RunE: func(cmd *cobra.Command, args []string) error {
390 username := args[0]
391 if err := policy.ValidateOwnerName(username); err != nil {
392 return err
393 }
394 cfg, err := config.Load(configPath)
395 if err != nil {
396 return err
397 }
398 st, err := openStore(cfg)
399 if err != nil {
400 return err
401 }
402 defer st.Close()
366// hostArgs pulls the root's --config out of args: with flag parsing off,
367// cobra hands the persistent flag through untouched.
368func hostArgs(args []string) []string {
369 var rest []string
370 for i := 0; i < len(args); i++ {
371 switch {
372 case args[i] == "--config" && i+1 < len(args):
373 configPath = args[i+1]
374 i++
375 case strings.HasPrefix(args[i], "--config="):
376 configPath = strings.TrimPrefix(args[i], "--config=")
377 default:
378 rest = append(rest, args[i])
379 }
380 }
381 return rest
382}
403383
404 uid, err := st.CreateUser(username, isAdmin)
405 if err != nil {
406 return err
407 }
408 if email != "" {
409 verifiedBy := ""
410 if verified {
411 verifiedBy = "admin"
412 }
413 if err := st.AddEmail(uid, email, verifiedBy, true); err != nil {
414 return err
415 }
416 }
417 if keyPath != "" {
418 raw, err := os.ReadFile(keyPath)
419 if err != nil {
420 return err
421 }
422 pub, _, _, _, err := ssh.ParseAuthorizedKey(raw)
423 if err != nil {
424 return fmt.Errorf("%s: not a public key in authorized_keys format: %w", keyPath, err)
425 }
426 fp := ssh.FingerprintSHA256(pub)
427 if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil {
428 return err
429 }
430 fmt.Println("key", fp)
431 }
432 st.Audit(0, "admin user.created", map[string]any{"user": username})
433 fmt.Println("created user", username)
434 return nil
435 },
384func runAsHost(path, args []string, stdin io.Reader) error {
385 args = hostArgs(args)
386 cfg, err := config.Load(configPath)
387 if err != nil {
388 return err
436389 }
437 cmd.Flags().StringVar(&keyPath, "key", "", "path to an SSH public key to register")
438 cmd.Flags().StringVar(&email, "email", "", "primary email address")
439 cmd.Flags().BoolVar(&verified, "verified", false, "mark the email verified (admin assertion)")
440 cmd.Flags().BoolVar(&isAdmin, "admin", false, "grant instance admin")
441 return cmd
390 st, err := openStore(cfg)
391 if err != nil {
392 return err
393 }
394 c := &control.Ctx{
395 User: store.User{Username: "host", IsAdmin: true},
396 Scope: "full",
397 Store: st,
398 Cfg: cfg,
399 Stdin: stdin,
400 Stdout: os.Stdout,
401 Stderr: os.Stderr,
402 Source: "host",
403 }
404 code := control.Dispatch(c, append(append([]string{}, path...), args...))
405 st.Close()
406 if code != 0 {
407 os.Exit(code)
408 }
409 return nil
442410}
443411
444func adminEmailVerifyCmd() *cobra.Command {
412// hostUserCreateCmd keeps --key <path>, which the registry command cannot
413// take (no file paths over SSH): the file becomes the command's stdin.
414func hostUserCreateCmd() *cobra.Command {
445415 return &cobra.Command{
446 Use: "verify <username> <address>",
447 Short: "mark an email verified by admin assertion",
448 Args: cobra.ExactArgs(2),
416 Use: "create <username> [--admin] [--email <address> [--verified]] [--key <file>]",
417 Short: "create a user (host-local bootstrap; the only path in closed mode)",
418 DisableFlagParsing: true,
449419 RunE: func(cmd *cobra.Command, args []string) error {
450 cfg, err := config.Load(configPath)
451 if err != nil {
452 return err
453 }
454 st, err := openStore(cfg)
455 if err != nil {
456 return err
457 }
458 defer st.Close()
459 u, err := st.UserByUsername(args[0])
460 if err != nil {
461 return fmt.Errorf("user %s: %w", args[0], err)
462 }
463 if err := st.VerifyEmail(u.ID, args[1], "admin"); err != nil {
464 st.Audit(0, "admin email.verify_failed", map[string]any{"user": args[0], "email": args[1]})
465 return fmt.Errorf("no address %s on user %s", args[1], args[0])
420 var stdin io.Reader = os.Stdin
421 var rest []string
422 args = hostArgs(args)
423 for i := 0; i < len(args); i++ {
424 switch {
425 case args[i] == "--help" || args[i] == "-h":
426 return cmd.Help()
427 case args[i] == "--key" && i+1 < len(args) && args[i+1] != "-":
428 f, err := os.Open(args[i+1])
429 if err != nil {
430 return err
431 }
432 defer f.Close()
433 stdin = f
434 rest = append(rest, "--key", "-")
435 i++
436 default:
437 rest = append(rest, args[i])
438 }
466439 }
467 st.Audit(0, "admin email.verified", map[string]any{"user": args[0], "email": args[1]})
468 fmt.Println("verified", args[1])
469 return nil
440 return runAsHost([]string{"admin", "user", "create"}, rest, stdin)
470441 },
471442 }
472443}
cmd/gitbayd/maint.go −63
@@ -1,11 +1,9 @@
11package main
22
33import (
4 "encoding/json"
54 "fmt"
65 "os"
76 "os/exec"
8 "text/tabwriter"
97
108 "github.com/spf13/cobra"
119
@@ -68,67 +66,6 @@ func gcCmd() *cobra.Command {
6866 return cmd
6967}
7068
71func statsCmd() *cobra.Command {
72 var asJSON bool
73 cmd := &cobra.Command{
74 Use: "stats",
75 Short: "instance statistics: counts and per-repository disk usage",
76 RunE: func(cmd *cobra.Command, args []string) error {
77 cfg, err := config.Load(configPath)
78 if err != nil {
79 return err
80 }
81 st, err := openStore(cfg)
82 if err != nil {
83 return err
84 }
85 defer st.Close()
86
87 counts, err := st.InstanceCounts()
88 if err != nil {
89 return err
90 }
91 repos, err := st.ListAllRepos()
92 if err != nil {
93 return err
94 }
95 type repoDisk struct {
96 Path string `json:"path"`
97 Bytes int64 `json:"bytes"`
98 }
99 var disks []repoDisk
100 var totalDisk int64
101 for _, r := range repos {
102 b := gitutil.DirSize(control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name))
103 disks = append(disks, repoDisk{r.Path(), b})
104 totalDisk += b
105 }
106 var dbBytes int64
107 if fi, err := os.Stat(cfg.Server.Root + "/gitbay.db"); err == nil {
108 dbBytes = fi.Size()
109 }
110
111 if asJSON {
112 return json.NewEncoder(os.Stdout).Encode(map[string]any{
113 "counts": counts, "db_bytes": dbBytes,
114 "repo_bytes": totalDisk, "repos": disks,
115 })
116 }
117 fmt.Printf("users %d · orgs %d · repos %d · issues %d (%d open) · MRs %d (%d open)\n",
118 counts.Users, counts.Orgs, counts.Repos,
119 counts.Issues, counts.OpenIssues, counts.MRs, counts.OpenMRs)
120 fmt.Printf("database %s · repositories %s\n\n", human(dbBytes), human(totalDisk))
121 w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
122 for _, d := range disks {
123 fmt.Fprintf(w, "%s\t%s\n", d.Path, human(d.Bytes))
124 }
125 return w.Flush()
126 },
127 }
128 cmd.Flags().BoolVar(&asJSON, "json", false, "machine-readable output")
129 return cmd
130}
131
13269func human(b int64) string {
13370 switch {
13471 case b >= 1<<30:
e2e/adminusers_test.go +68
@@ -331,3 +331,71 @@ func TestAdminRepoModeration(t *testing.T) {
331331 }
332332 }
333333}
334
335// The host-local admin commands dispatch into the registry, so the same
336// commands work in an admin's SSH session and audit rows say which path
337// ran them.
338func TestAdminHostAndSSHAreOneSurface(t *testing.T) {
339 inst := startInstance(t)
340 rootKey := inst.newKey(t, "root")
341 aliceKey := inst.newKey(t, "alice")
342 carolKey := inst.newKey(t, "carol")
343 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin")
344 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
345
346 pub, err := os.ReadFile(carolKey + ".pub")
347 if err != nil {
348 t.Fatal(err)
349 }
350 out, errOut, code := inst.ssh(t, rootKey, string(pub), "admin", "user", "create", "carol",
351 "--email", "carol@example.test", "--verified", "--key", "-")
352 if code != 0 || !strings.Contains(out, "created user carol") || !strings.Contains(out, "key SHA256:") {
353 t.Fatalf("ssh user create: exit %d\n%s%s", code, out, errOut)
354 }
355 if _, _, code := inst.ssh(t, carolKey, "", "whoami"); code != 0 {
356 t.Fatal("created account cannot authenticate")
357 }
358 if _, errOut, code := inst.ssh(t, rootKey, "", "admin", "user", "create", "alice"); code != 2 || !strings.Contains(errOut, "taken") {
359 t.Fatalf("duplicate create: exit %d %s", code, errOut)
360 }
361 for _, args := range [][]string{{"admin", "stats"}, {"admin", "user", "disable", "carol"}, {"admin", "invite", "--email", "x@example.test"}} {
362 if _, _, code := inst.ssh(t, aliceKey, "", args...); code != 4 {
363 t.Fatalf("non-admin ran %v: exit %d", args, code)
364 }
365 }
366 if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "disable", "carol"); code != 0 {
367 t.Fatal("ssh disable failed")
368 }
369 if _, _, code := inst.ssh(t, carolKey, "", "whoami"); code != 4 {
370 t.Fatal("disabled account still authenticates")
371 }
372 inst.admin(t, "admin", "user", "enable", "carol")
373 if _, _, code := inst.ssh(t, carolKey, "", "whoami"); code != 0 {
374 t.Fatal("host enable did not take")
375 }
376 if out, _, code := inst.ssh(t, rootKey, "", "admin", "stats", "--json"); code != 0 || !strings.Contains(out, `"users":`) {
377 t.Fatalf("ssh stats: exit %d\n%s", code, out)
378 }
379 // No SMTP: the invite code comes back on stdout instead of by mail.
380 if out, _, code := inst.ssh(t, rootKey, "", "admin", "invite", "--email", "dave@example.test", "--json"); code != 0 || !strings.Contains(out, `"code":"`) {
381 t.Fatalf("ssh invite: exit %d\n%s", code, out)
382 }
383 if _, errOut, code := inst.ssh(t, rootKey, "", "admin", "email", "verify", "carol", "nope@example.test"); code != 3 || !strings.Contains(errOut, "no address") {
384 t.Fatalf("verify unknown address: exit %d %s", code, errOut)
385 }
386 if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "delete", "carol", "--yes"); code != 0 {
387 t.Fatal("ssh delete failed")
388 }
389 if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "delete", "root", "--yes"); code != 2 {
390 t.Fatal("deleted own account")
391 }
392
393 // Both paths audit under the same action names; the row says which
394 // credential ran it.
395 audit := inst.admin(t, "admin", "audit")
396 for _, want := range []string{`"source":"host"`, `"source":"SHA256:`, "admin user.created", "admin user.disabled", "admin user.enabled", "admin user.deleted"} {
397 if !strings.Contains(audit, want) {
398 t.Fatalf("audit lacks %q:\n%s", want, audit)
399 }
400 }
401}
internal/control/admin.go +1
@@ -306,6 +306,7 @@ func setAdmin(c *Ctx, args []string, admin bool) int {
306306 }
307307 return c.fail(protocol.ExitFailure, "%v", err)
308308 }
309 c.Store.Audit(c.User.ID, "admin user."+verb+"d", map[string]any{"user": u.Username})
309310 return c.emit(map[string]any{"user": u.Username, "admin": admin}, func(w io.Writer) {
310311 fmt.Fprintf(w, "%sd %s\n", verb, u.Username)
311312 })
internal/control/adminhost.go added +337
@@ -0,0 +1,337 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "os"
8
9 "golang.org/x/crypto/ssh"
10
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/mail"
13 "gitbay.org/gitbay/internal/policy"
14 "gitbay.org/gitbay/internal/protocol"
15 "gitbay.org/gitbay/internal/store"
16)
17
18// The account, email, invite and stats commands gitbayd admin used to
19// implement on its own. They live here so the host binary and an admin
20// session run the same code; gitbayd admin dispatches into these.
21
22func init() {
23 register(Command{Path: []string{"admin", "user", "create"},
24 Summary: "create an account, optionally with a key and a verified address (instance admins)",
25 Usage: "admin user create <username> [--admin] [--email <address> [--verified]] [--key -] < key.pub",
26 ReadsStdin: true, SSHOnly: true, Run: runAdminUserCreate})
27 register(Command{Path: []string{"admin", "user", "disable"},
28 Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled",
29 Usage: "admin user disable <username>",
30 SSHOnly: true, Run: runAdminUserDisable})
31 register(Command{Path: []string{"admin", "user", "enable"},
32 Summary: "restore a suspended account",
33 Usage: "admin user enable <username>",
34 SSHOnly: true, Run: runAdminUserEnable})
35 register(Command{Path: []string{"admin", "user", "delete"},
36 Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)",
37 Usage: "admin user delete <username> --yes",
38 SSHOnly: true, Run: runAdminUserDelete})
39 register(Command{Path: []string{"admin", "email", "verify"},
40 Summary: "mark an address verified by admin assertion",
41 Usage: "admin email verify <username> <address>",
42 SSHOnly: true, Run: runAdminEmailVerify})
43 register(Command{Path: []string{"admin", "invite"},
44 Summary: "issue a registration invite and mail its code",
45 Usage: "admin invite --email <address>",
46 SSHOnly: true, Run: runAdminInvite})
47 register(Command{Path: []string{"admin", "stats"},
48 Summary: "instance statistics: counts and per-repository disk usage",
49 Usage: "admin stats",
50 ReadOnly: true, SSHOnly: true, Run: runAdminStats})
51}
52
53func runAdminUserCreate(c *Ctx, args []string) int {
54 if code := requireInstanceAdmin(c); code >= 0 {
55 return code
56 }
57 const usage = "usage: admin user create <username> [--admin] [--email <address> [--verified]] [--key -] < key.pub"
58 var username, email string
59 var isAdmin, verified, withKey bool
60 for i := 0; i < len(args); i++ {
61 switch args[i] {
62 case "--admin":
63 isAdmin = true
64 case "--verified":
65 verified = true
66 case "--email":
67 if i+1 >= len(args) {
68 return c.fail(protocol.ExitUsage, "--email requires a value")
69 }
70 email = args[i+1]
71 i++
72 case "--key":
73 if i+1 >= len(args) || args[i+1] != "-" {
74 return c.fail(protocol.ExitUsage, "--key only supports - (the public key on stdin)")
75 }
76 withKey = true
77 i++
78 default:
79 if username != "" || len(args[i]) == 0 || args[i][0] == '-' {
80 return c.fail(protocol.ExitUsage, usage)
81 }
82 username = args[i]
83 }
84 }
85 if username == "" || (verified && email == "") {
86 return c.fail(protocol.ExitUsage, usage)
87 }
88 if err := policy.ValidateOwnerName(username); err != nil {
89 return c.fail(protocol.ExitUsage, "%v", err)
90 }
91 // Parse the key before creating anything, so a bad key leaves no
92 // half-made account behind.
93 var pub ssh.PublicKey
94 if withKey {
95 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
96 if err != nil {
97 return c.fail(protocol.ExitFailure, "reading key: %v", err)
98 }
99 if pub, _, _, _, err = ssh.ParseAuthorizedKey(raw); err != nil {
100 return c.fail(protocol.ExitUsage, "not a public key in authorized_keys format: %v", err)
101 }
102 }
103 uid, err := c.Store.CreateUser(username, isAdmin)
104 if err != nil {
105 return c.fail(protocol.ExitUsage, "%v", err)
106 }
107 if email != "" {
108 by := ""
109 if verified {
110 by = "admin"
111 }
112 if err := c.Store.AddEmail(uid, email, by, true); err != nil {
113 return c.fail(protocol.ExitUsage, "%v", err)
114 }
115 }
116 fp := ""
117 if pub != nil {
118 fp = ssh.FingerprintSHA256(pub)
119 if err := c.Store.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil {
120 return c.fail(protocol.ExitUsage, "%v", err)
121 }
122 }
123 c.Store.Audit(c.User.ID, "admin user.created", map[string]any{"user": username})
124 type out struct {
125 User string `json:"user"`
126 Admin bool `json:"admin,omitempty"`
127 Fingerprint string `json:"fingerprint,omitempty"`
128 }
129 return c.emit(out{username, isAdmin, fp}, func(w io.Writer) {
130 if fp != "" {
131 fmt.Fprintln(w, "key", fp)
132 }
133 fmt.Fprintln(w, "created user", username)
134 })
135}
136
137// adminUserArg resolves the single username argument of an admin command.
138func adminUserArg(c *Ctx, args []string, usage string) (store.User, int) {
139 if code := requireInstanceAdmin(c); code >= 0 {
140 return store.User{}, code
141 }
142 if len(args) != 1 {
143 return store.User{}, c.fail(protocol.ExitUsage, "usage: %s", usage)
144 }
145 u, err := c.Store.UserByUsername(args[0])
146 if errors.Is(err, store.ErrNotFound) {
147 return u, c.fail(protocol.ExitNotFound, "no user %q", args[0])
148 } else if err != nil {
149 return u, c.fail(protocol.ExitFailure, "%v", err)
150 }
151 return u, -1
152}
153
154func runAdminUserDisable(c *Ctx, args []string) int {
155 u, code := adminUserArg(c, args, "admin user disable <username>")
156 if code >= 0 {
157 return code
158 }
159 if err := c.Store.SetUserDisabled(u.ID, true); err != nil {
160 return c.fail(protocol.ExitFailure, "%v", err)
161 }
162 c.Store.Audit(c.User.ID, "admin user.disabled", map[string]any{"user": u.Username})
163 return c.emit(map[string]any{"user": u.Username, "disabled": true}, func(w io.Writer) {
164 fmt.Fprintf(w, "disabled %s: SSH, web sessions, and API tokens are refused; nothing was deleted\n", u.Username)
165 })
166}
167
168func runAdminUserEnable(c *Ctx, args []string) int {
169 u, code := adminUserArg(c, args, "admin user enable <username>")
170 if code >= 0 {
171 return code
172 }
173 if err := c.Store.SetUserDisabled(u.ID, false); err != nil {
174 return c.fail(protocol.ExitFailure, "%v", err)
175 }
176 c.Store.Audit(c.User.ID, "admin user.enabled", map[string]any{"user": u.Username})
177 return c.emit(map[string]any{"user": u.Username, "disabled": false}, func(w io.Writer) {
178 fmt.Fprintf(w, "enabled %s\n", u.Username)
179 })
180}
181
182func runAdminUserDelete(c *Ctx, args []string) int {
183 var rest []string
184 var yes bool
185 for _, a := range args {
186 if a == "--yes" {
187 yes = true
188 } else {
189 rest = append(rest, a)
190 }
191 }
192 u, code := adminUserArg(c, rest, "admin user delete <username> --yes")
193 if code >= 0 {
194 return code
195 }
196 if !yes {
197 return c.fail(protocol.ExitUsage, "deletion is permanent; pass --yes")
198 }
199 if u.ID == c.User.ID {
200 return c.fail(protocol.ExitUsage, "that is your own account")
201 }
202 if err := c.Store.DeleteUser(u.ID); err != nil {
203 return c.fail(protocol.ExitUsage, "%v", err)
204 }
205 c.Store.Audit(c.User.ID, "admin user.deleted", map[string]any{"user": u.Username})
206 return c.emit(map[string]string{"deleted": u.Username}, func(w io.Writer) {
207 fmt.Fprintf(w, "deleted %s\n", u.Username)
208 })
209}
210
211func runAdminEmailVerify(c *Ctx, args []string) int {
212 if code := requireInstanceAdmin(c); code >= 0 {
213 return code
214 }
215 if len(args) != 2 {
216 return c.fail(protocol.ExitUsage, "usage: admin email verify <username> <address>")
217 }
218 u, err := c.Store.UserByUsername(args[0])
219 if errors.Is(err, store.ErrNotFound) {
220 return c.fail(protocol.ExitNotFound, "no user %q", args[0])
221 } else if err != nil {
222 return c.fail(protocol.ExitFailure, "%v", err)
223 }
224 if err := c.Store.VerifyEmail(u.ID, args[1], "admin"); err != nil {
225 c.Store.Audit(c.User.ID, "admin email.verify_failed", map[string]any{"user": args[0], "email": args[1]})
226 return c.fail(protocol.ExitNotFound, "no address %s on user %s", args[1], args[0])
227 }
228 c.Store.Audit(c.User.ID, "admin email.verified", map[string]any{"user": args[0], "email": args[1]})
229 return c.emit(map[string]string{"user": args[0], "verified": args[1]}, func(w io.Writer) {
230 fmt.Fprintln(w, "verified", args[1])
231 })
232}
233
234func runAdminInvite(c *Ctx, args []string) int {
235 if code := requireInstanceAdmin(c); code >= 0 {
236 return code
237 }
238 email := ""
239 if len(args) == 2 && args[0] == "--email" {
240 email = args[1]
241 }
242 if email == "" {
243 return c.fail(protocol.ExitUsage, "usage: admin invite --email <address>")
244 }
245 if used, err := c.Store.EmailInUse(email); err != nil {
246 return c.fail(protocol.ExitFailure, "%v", err)
247 } else if used {
248 return c.fail(protocol.ExitUsage, "%s already belongs to an account; invites are for new users", email)
249 }
250 code, hash, err := store.NewToken()
251 if err != nil {
252 return c.fail(protocol.ExitFailure, "%v", err)
253 }
254 if err := c.Store.CreateInvite(hash, email); err != nil {
255 return c.fail(protocol.ExitFailure, "%v", err)
256 }
257 host := siteHost(c.Cfg)
258 body := fmt.Sprintf(
259 "You have been invited to %s.\n\nCreate your account by running (with the SSH key you want to use):\n\n"+
260 " ssh git@%s register --username <name> --invite %s\n\n"+
261 "The invite is single-use and tied to this address.\n", host, host, code)
262 type out struct {
263 Email string `json:"email"`
264 Mailed bool `json:"mailed"`
265 Code string `json:"code,omitempty"` // only when it could not be mailed
266 }
267 if c.Cfg.Mail.SMTPHost != "" {
268 if err := mail.Send(c.Cfg, email, "your invite to "+host, body); err != nil {
269 return c.fail(protocol.ExitFailure, "invite stored but mail failed: %v (code: %s)", err, code)
270 }
271 c.Store.Audit(c.User.ID, "admin invite.issued", map[string]any{"email": email})
272 return c.emit(out{Email: email, Mailed: true}, func(w io.Writer) {
273 fmt.Fprintf(w, "invite emailed to %s\n", email)
274 })
275 }
276 return c.emit(out{Email: email, Code: code}, func(w io.Writer) {
277 fmt.Fprintf(w, "invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code)
278 })
279}
280
281func runAdminStats(c *Ctx, args []string) int {
282 if code := requireInstanceAdmin(c); code >= 0 {
283 return code
284 }
285 if len(args) != 0 {
286 return c.fail(protocol.ExitUsage, "usage: admin stats")
287 }
288 counts, err := c.Store.InstanceCounts()
289 if err != nil {
290 return c.fail(protocol.ExitFailure, "%v", err)
291 }
292 repos, err := c.Store.ListAllRepos()
293 if err != nil {
294 return c.fail(protocol.ExitFailure, "%v", err)
295 }
296 type repoDisk struct {
297 Path string `json:"path"`
298 Bytes int64 `json:"bytes"`
299 }
300 type out struct {
301 Counts store.Counts `json:"counts"`
302 DBBytes int64 `json:"db_bytes"`
303 RepoBytes int64 `json:"repo_bytes"`
304 Repos []repoDisk `json:"repos"`
305 }
306 d := out{Counts: counts, Repos: []repoDisk{}}
307 for _, r := range repos {
308 b := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
309 d.Repos = append(d.Repos, repoDisk{r.Path(), b})
310 d.RepoBytes += b
311 }
312 if fi, err := os.Stat(c.Cfg.Server.Root + "/gitbay.db"); err == nil {
313 d.DBBytes = fi.Size()
314 }
315 return c.emit(d, func(w io.Writer) {
316 fmt.Fprintf(w, "users %d · orgs %d · repos %d · issues %d (%d open) · MRs %d (%d open)\n",
317 counts.Users, counts.Orgs, counts.Repos,
318 counts.Issues, counts.OpenIssues, counts.MRs, counts.OpenMRs)
319 fmt.Fprintf(w, "database %s · repositories %s\n\n", humanBytes(d.DBBytes), humanBytes(d.RepoBytes))
320 for _, r := range d.Repos {
321 fmt.Fprintf(w, "%s\t%s\n", r.Path, humanBytes(r.Bytes))
322 }
323 })
324}
325
326func humanBytes(b int64) string {
327 switch {
328 case b >= 1<<30:
329 return fmt.Sprintf("%.1f GiB", float64(b)/(1<<30))
330 case b >= 1<<20:
331 return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20))
332 case b >= 1<<10:
333 return fmt.Sprintf("%.1f KiB", float64(b)/(1<<10))
334 default:
335 return fmt.Sprintf("%d B", b)
336 }
337}