Commit 8352b440f4

8352b440f440ceff1adee0ee2c587a9103e260c5

parent: 85a6c5a74e

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-02 01:18 UTC

gitbayd admin dispatches into the registry

The host binary reimplemented user create/disable/enable/delete, email
verify, invite, stats and audit on its own, so the SSH surface and the
host surface could drift, and invite and stats had no SSH twin at all.

Those commands now live in the registry under admin, SSH-only and
admin-gated. gitbayd admin runs them through control.Dispatch as the
host: an admin context with no account behind it, so the dispatcher's
audit row carries source "host" where an SSH session carries the key
fingerprint. Flags pass through untouched; the wrapper only pulls the
root's --config out, since disabling cobra's parsing drops the persistent
flag too. admin user create keeps --key <path> on the host by feeding
the file to the command's stdin, which is what --key - means over SSH.

Left host-local: backup, gc and the one-shot backfills, which are
filesystem work with no session equivalent.

Closes #72

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}