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 {
81 pass("show", "show an account: <username>", passOpts{server: []string{"admin", "user", "show"}}), 81 pass("show", "show an account: <username>", passOpts{server: []string{"admin", "user", "show"}}),
82 pass("promote", "make an account an instance admin: <username>", passOpts{server: []string{"admin", "user", "promote"}}), 82 pass("promote", "make an account an instance admin: <username>", passOpts{server: []string{"admin", "user", "promote"}}),
83 pass("demote", "remove instance admin (never the last one): <username>", passOpts{server: []string{"admin", "user", "demote"}}), 83 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"}}),
84 ), 88 ),
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"}}),
85 group("repo", "any repository, for moderation (audited)", 94 group("repo", "any repository, for moderation (audited)",
86 pass("list", "every repository with size and last push: [--owner o] [--visibility v] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "repo", "list"}}), 95 pass("list", "every repository with size and last push: [--owner o] [--visibility v] [--limit n] [--cursor c]", passOpts{server: []string{"admin", "repo", "list"}}),
87 pass("archive", "archive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "archive"}}), 96 pass("archive", "archive a repository: <owner/name>", passOpts{server: []string{"admin", "repo", "archive"}}),
@@ -209,7 +218,7 @@ func isEmptyReader(r io.Reader) bool {
209// usesStdin reports whether the arguments request stdin content. 218// usesStdin reports whether the arguments request stdin content.
210func usesStdin(args []string) bool { 219func usesStdin(args []string) bool {
211 for i, a := range args { 220 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] == "-" {
213 return true 222 return true
214 } 223 }
215 if a == "--token-stdin" { 224 if a == "--token-stdin" {
cmd/gitbayd/adminusers.go −126
@@ -8,101 +8,8 @@ import (
8 "gitbay.org/gitbay/internal/config" 8 "gitbay.org/gitbay/internal/config"
9 "gitbay.org/gitbay/internal/control" 9 "gitbay.org/gitbay/internal/control"
10 "gitbay.org/gitbay/internal/gitutil" 10 "gitbay.org/gitbay/internal/gitutil"
11 "gitbay.org/gitbay/internal/store"
12) 11)
13 12
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
106// adminMigrateCommitRefsCmd is a one-shot backfill: legacy commit-reference 13// adminMigrateCommitRefsCmd is a one-shot backfill: legacy commit-reference
107// comments (author-attributed, bare sha) become system messages with a 14// comments (author-attributed, bare sha) become system messages with a
108// linked sha. Idempotent. 15// linked sha. Idempotent.
@@ -178,36 +85,3 @@ func adminBackfillActivityCmd() *cobra.Command {
178 cmd.Flags().IntVar(&perRepo, "per-repo", 20000, "max commits walked per repository") 85 cmd.Flags().IntVar(&perRepo, "per-repo", 20000, "max commits walked per repository")
179 return cmd 86 return cmd
180} 87}
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
5import ( 5import (
6 "context" 6 "context"
7 "fmt" 7 "fmt"
8 "io"
8 "log/slog" 9 "log/slog"
9 "net" 10 "net"
10 "net/http" 11 "net/http"
@@ -16,7 +17,6 @@ import (
16 17
17 "github.com/spf13/cobra" 18 "github.com/spf13/cobra"
18 "golang.org/x/crypto/acme/autocert" 19 "golang.org/x/crypto/acme/autocert"
19 "golang.org/x/crypto/ssh"
20 20
21 "gitbay.org/gitbay/internal/buildinfo" 21 "gitbay.org/gitbay/internal/buildinfo"
22 "gitbay.org/gitbay/internal/ci" 22 "gitbay.org/gitbay/internal/ci"
@@ -26,10 +26,8 @@ import (
26 "gitbay.org/gitbay/internal/gitd" 26 "gitbay.org/gitbay/internal/gitd"
27 "gitbay.org/gitbay/internal/hookd" 27 "gitbay.org/gitbay/internal/hookd"
28 "gitbay.org/gitbay/internal/httpd" 28 "gitbay.org/gitbay/internal/httpd"
29 "gitbay.org/gitbay/internal/mail"
30 "gitbay.org/gitbay/internal/mirror" 29 "gitbay.org/gitbay/internal/mirror"
31 "gitbay.org/gitbay/internal/notify" 30 "gitbay.org/gitbay/internal/notify"
32 "gitbay.org/gitbay/internal/policy"
33 "gitbay.org/gitbay/internal/sshd" 31 "gitbay.org/gitbay/internal/sshd"
34 "gitbay.org/gitbay/internal/store" 32 "gitbay.org/gitbay/internal/store"
35 "gitbay.org/gitbay/internal/webhook" 33 "gitbay.org/gitbay/internal/webhook"
@@ -309,164 +307,137 @@ func adminCmd() *cobra.Command {
309 Short: "host-local administration", 307 Short: "host-local administration",
310 } 308 }
311 userCmd := &cobra.Command{Use: "user", Short: "manage users"} 309 userCmd := &cobra.Command{Use: "user", Short: "manage users"}
312 userCmd.AddCommand(adminUserCreateCmd(), adminUserDisableCmd(), adminUserEnableCmd(), adminUserDeleteCmd(), 310 userCmd.AddCommand(
313 adminUserPromoteCmd(), adminUserDemoteCmd()) 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 )
314 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"} 320 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 )
316 admin.AddCommand( 330 admin.AddCommand(
317 userCmd, 331 userCmd,
318 emailCmd, 332 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"),
320 backupCmd(), 337 backupCmd(),
321 gcCmd(), 338 gcCmd(),
322 statsCmd(),
323 adminAuditCmd(),
324 adminMigrateCommitRefsCmd(), 339 adminMigrateCommitRefsCmd(),
325 adminBackfillActivityCmd(), 340 adminBackfillActivityCmd(),
326 ) 341 )
327 return admin 342 return admin
328} 343}
329 344
330func adminInviteCmd() *cobra.Command { 345// hostCmd runs a registry command as the host itself: an admin context
331 var email string 346// with no account behind it, so audit rows carry no actor and the source
332 cmd := &cobra.Command{ 347// "host". Arguments pass through untouched; the registry owns the flags,
333 Use: "invite", 348// which is what keeps this surface and an admin's SSH session from
334 Short: "issue a registration invite and email its code", 349// drifting.
350func hostCmd(use, short string, path ...string) *cobra.Command {
351 return &cobra.Command{
352 Use: use,
353 Short: short,
354 DisableFlagParsing: true,
335 RunE: func(cmd *cobra.Command, args []string) error { 355 RunE: func(cmd *cobra.Command, args []string) error {
336 if email == "" { 356 for _, a := range args {
337 return fmt.Errorf("--email is required") 357 if a == "--help" || a == "-h" {
338 } 358 return cmd.Help()
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)
369 } 359 }
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)
374 } 360 }
375 return nil 361 return runAsHost(path, args, os.Stdin)
376 }, 362 },
377 } 363 }
378 cmd.Flags().StringVar(&email, "email", "", "address to invite (the account's verified email)")
379 return cmd
380} 364}
381 365
382func adminUserCreateCmd() *cobra.Command { 366// hostArgs pulls the root's --config out of args: with flag parsing off,
383 var keyPath, email string 367// cobra hands the persistent flag through untouched.
384 var verified, isAdmin bool 368func hostArgs(args []string) []string {
385 cmd := &cobra.Command{ 369 var rest []string
386 Use: "create <username>", 370 for i := 0; i < len(args); i++ {
387 Short: "create a user (host-local bootstrap; the only path in closed mode)", 371 switch {
388 Args: cobra.ExactArgs(1), 372 case args[i] == "--config" && i+1 < len(args):
389 RunE: func(cmd *cobra.Command, args []string) error { 373 configPath = args[i+1]
390 username := args[0] 374 i++
391 if err := policy.ValidateOwnerName(username); err != nil { 375 case strings.HasPrefix(args[i], "--config="):
392 return err 376 configPath = strings.TrimPrefix(args[i], "--config=")
393 } 377 default:
394 cfg, err := config.Load(configPath) 378 rest = append(rest, args[i])
395 if err != nil { 379 }
396 return err 380 }
397 } 381 return rest
398 st, err := openStore(cfg) 382}
399 if err != nil {
400 return err
401 }
402 defer st.Close()
403 383
404 uid, err := st.CreateUser(username, isAdmin) 384func runAsHost(path, args []string, stdin io.Reader) error {
405 if err != nil { 385 args = hostArgs(args)
406 return err 386 cfg, err := config.Load(configPath)
407 } 387 if err != nil {
408 if email != "" { 388 return err
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 },
436 } 389 }
437 cmd.Flags().StringVar(&keyPath, "key", "", "path to an SSH public key to register") 390 st, err := openStore(cfg)
438 cmd.Flags().StringVar(&email, "email", "", "primary email address") 391 if err != nil {
439 cmd.Flags().BoolVar(&verified, "verified", false, "mark the email verified (admin assertion)") 392 return err
440 cmd.Flags().BoolVar(&isAdmin, "admin", false, "grant instance admin") 393 }
441 return cmd 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
442} 410}
443 411
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 {
445 return &cobra.Command{ 415 return &cobra.Command{
446 Use: "verify <username> <address>", 416 Use: "create <username> [--admin] [--email <address> [--verified]] [--key <file>]",
447 Short: "mark an email verified by admin assertion", 417 Short: "create a user (host-local bootstrap; the only path in closed mode)",
448 Args: cobra.ExactArgs(2), 418 DisableFlagParsing: true,
449 RunE: func(cmd *cobra.Command, args []string) error { 419 RunE: func(cmd *cobra.Command, args []string) error {
450 cfg, err := config.Load(configPath) 420 var stdin io.Reader = os.Stdin
451 if err != nil { 421 var rest []string
452 return err 422 args = hostArgs(args)
453 } 423 for i := 0; i < len(args); i++ {
454 st, err := openStore(cfg) 424 switch {
455 if err != nil { 425 case args[i] == "--help" || args[i] == "-h":
456 return err 426 return cmd.Help()
457 } 427 case args[i] == "--key" && i+1 < len(args) && args[i+1] != "-":
458 defer st.Close() 428 f, err := os.Open(args[i+1])
459 u, err := st.UserByUsername(args[0]) 429 if err != nil {
460 if err != nil { 430 return err
461 return fmt.Errorf("user %s: %w", args[0], err) 431 }
462 } 432 defer f.Close()
463 if err := st.VerifyEmail(u.ID, args[1], "admin"); err != nil { 433 stdin = f
464 st.Audit(0, "admin email.verify_failed", map[string]any{"user": args[0], "email": args[1]}) 434 rest = append(rest, "--key", "-")
465 return fmt.Errorf("no address %s on user %s", args[1], args[0]) 435 i++
436 default:
437 rest = append(rest, args[i])
438 }
466 } 439 }
467 st.Audit(0, "admin email.verified", map[string]any{"user": args[0], "email": args[1]}) 440 return runAsHost([]string{"admin", "user", "create"}, rest, stdin)
468 fmt.Println("verified", args[1])
469 return nil
470 }, 441 },
471 } 442 }
472} 443}
cmd/gitbayd/maint.go −63
@@ -1,11 +1,9 @@
1package main 1package main
2 2
3import ( 3import (
4 "encoding/json"
5 "fmt" 4 "fmt"
6 "os" 5 "os"
7 "os/exec" 6 "os/exec"
8 "text/tabwriter"
9 7
10 "github.com/spf13/cobra" 8 "github.com/spf13/cobra"
11 9
@@ -68,67 +66,6 @@ func gcCmd() *cobra.Command {
68 return cmd 66 return cmd
69} 67}
70 68
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
132func human(b int64) string { 69func human(b int64) string {
133 switch { 70 switch {
134 case b >= 1<<30: 71 case b >= 1<<30:
e2e/adminusers_test.go +68
@@ -331,3 +331,71 @@ func TestAdminRepoModeration(t *testing.T) {
331 } 331 }
332 } 332 }
333} 333}
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 {
306 } 306 }
307 return c.fail(protocol.ExitFailure, "%v", err) 307 return c.fail(protocol.ExitFailure, "%v", err)
308 } 308 }
309 c.Store.Audit(c.User.ID, "admin user."+verb+"d", map[string]any{"user": u.Username})
309 return c.emit(map[string]any{"user": u.Username, "admin": admin}, func(w io.Writer) { 310 return c.emit(map[string]any{"user": u.Username, "admin": admin}, func(w io.Writer) {
310 fmt.Fprintf(w, "%sd %s\n", verb, u.Username) 311 fmt.Fprintf(w, "%sd %s\n", verb, u.Username)
311 }) 312 })
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}