A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 2e6467a72f

2e6467a72f457a7a9a954565516811d1b6fc3641

parent: a3fec2d918

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T21:39:58Z

Audit logging, auth throttling, pack limits, user disable

Closes #14

The audit_log table finally gets writers: Dispatch records every
successful mutating command with argv and source credential (safe by
construction — secrets travel on stdin, never argv), plus targeted
entries for registrations, admin commands, force-pushes, auth failures,
and throttle events. Read with gitbayd admin audit or the SSH-only
admin 'audit' command.

Hardening: limits.ssh_auth_rate now throttles per-IP auth failures per
minute (successes never count and clear the slate; one audit entry per
throttled window); limits.max_pack_bytes is enforced as
receive.maxInputSize on receive-pack; admin user disable/enable
suspends an account at every entry point — SSH (embedded and system),
web sessions (dropped on disable), API — without deleting anything
(migration 0018). Ctx.Source carries the key fingerprint into the log.

Not covered here, tracked in #28: per-repo quotas, key expiry warnings.
cmd/gitbayd/adminusers.go added +91
@@ -0,0 +1,91 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/spf13/cobra"
7
8 "gitbay.org/gitbay/internal/config"
9 "gitbay.org/gitbay/internal/store"
10)
11
12func withUser(use, short string, run func(st *store.Store, u store.User) error) *cobra.Command {
13 return &cobra.Command{
14 Use: use + " <username>",
15 Short: short,
16 Args: cobra.ExactArgs(1),
17 RunE: func(cmd *cobra.Command, args []string) error {
18 cfg, err := config.Load(configPath)
19 if err != nil {
20 return err
21 }
22 st, err := openStore(cfg)
23 if err != nil {
24 return err
25 }
26 defer st.Close()
27 u, err := st.UserByUsername(args[0])
28 if err != nil {
29 return fmt.Errorf("no user %q", args[0])
30 }
31 return run(st, u)
32 },
33 }
34}
35
36func adminUserDisableCmd() *cobra.Command {
37 return withUser("disable", "suspend an account: keys and sessions refused until re-enabled",
38 func(st *store.Store, u store.User) error {
39 if err := st.SetUserDisabled(u.ID, true); err != nil {
40 return err
41 }
42 st.Audit(0, "admin user.disabled", map[string]any{"user": u.Username})
43 fmt.Printf("disabled %s: SSH, web sessions, and API tokens are refused; nothing was deleted\n", u.Username)
44 return nil
45 })
46}
47
48func adminUserEnableCmd() *cobra.Command {
49 return withUser("enable", "restore a suspended account",
50 func(st *store.Store, u store.User) error {
51 if err := st.SetUserDisabled(u.ID, false); err != nil {
52 return err
53 }
54 st.Audit(0, "admin user.enabled", map[string]any{"user": u.Username})
55 fmt.Printf("enabled %s\n", u.Username)
56 return nil
57 })
58}
59
60func adminAuditCmd() *cobra.Command {
61 var limit int
62 cmd := &cobra.Command{
63 Use: "audit",
64 Short: "print the security audit log, newest first",
65 RunE: func(cmd *cobra.Command, args []string) error {
66 cfg, err := config.Load(configPath)
67 if err != nil {
68 return err
69 }
70 st, err := openStore(cfg)
71 if err != nil {
72 return err
73 }
74 defer st.Close()
75 entries, err := st.AuditEntries(limit)
76 if err != nil {
77 return err
78 }
79 for _, e := range entries {
80 actor := e.Actor
81 if actor == "" {
82 actor = "-"
83 }
84 fmt.Printf("%s\t%s\t%s\t%s\n", e.CreatedAt, actor, e.Action, e.Data)
85 }
86 return nil
87 },
88 }
89 cmd.Flags().IntVar(&limit, "limit", 100, "entries to print")
90 return cmd
91}
cmd/gitbayd/main.go +6 −1
@@ -245,7 +245,7 @@ func adminCmd() *cobra.Command {
245245 Short: "host-local administration",
246246 }
247247 userCmd := &cobra.Command{Use: "user", Short: "manage users"}
248 userCmd.AddCommand(adminUserCreateCmd())
248 userCmd.AddCommand(adminUserCreateCmd(), adminUserDisableCmd(), adminUserEnableCmd())
249249 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
250250 emailCmd.AddCommand(adminEmailVerifyCmd())
251251 admin.AddCommand(
@@ -255,6 +255,7 @@ func adminCmd() *cobra.Command {
255255 backupCmd(),
256256 gcCmd(),
257257 statsCmd(),
258 adminAuditCmd(),
258259 )
259260 return admin
260261 }
@@ -299,6 +300,7 @@ func adminInviteCmd() *cobra.Command {
299300 if err := mail.Send(cfg, email, "your invite to "+host, body); err != nil {
300301 return fmt.Errorf("invite stored but mail failed: %w (code: %s)", err, code)
301302 }
303 st.Audit(0, "admin invite.issued", map[string]any{"email": email})
302304 fmt.Printf("invite emailed to %s\n", email)
303305 } else {
304306 fmt.Printf("invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code)
@@ -360,6 +362,7 @@ func adminUserCreateCmd() *cobra.Command {
360362 }
361363 fmt.Println("key", fp)
362364 }
365 st.Audit(0, "admin user.created", map[string]any{"user": username})
363366 fmt.Println("created user", username)
364367 return nil
365368 },
@@ -391,8 +394,10 @@ func adminEmailVerifyCmd() *cobra.Command {
391394 return fmt.Errorf("user %s: %w", args[0], err)
392395 }
393396 if err := st.VerifyEmail(u.ID, args[1], "admin"); err != nil {
397 st.Audit(0, "admin email.verify_failed", map[string]any{"user": args[0], "email": args[1]})
394398 return fmt.Errorf("no address %s on user %s", args[1], args[0])
395399 }
400 st.Audit(0, "admin email.verified", map[string]any{"user": args[0], "email": args[1]})
396401 fmt.Println("verified", args[1])
397402 return nil
398403 },
cmd/gitbayd/system.go +1 −1
@@ -93,7 +93,7 @@ func shellCmd() *cobra.Command {
9393 fmt.Fprintf(os.Stderr, "gitbay control plane: interactive shells are not available.\nTry: ssh <host> help\n")
9494 os.Exit(protocol.ExitUsage)
9595 }
96 code := sshd.Exec(cfg, st, user, key.Scope, cmdline, os.Stdin, os.Stdout, os.Stderr)
96 code := sshd.Exec(cfg, st, user, key.Scope, key.Fingerprint, cmdline, os.Stdin, os.Stdout, os.Stderr)
9797 st.Close()
9898 os.Exit(code)
9999 return nil
docs/admin.org +20
@@ -127,6 +127,26 @@ gitbayd admin invite --email b@example.org # mails a code; prints it if no
127127 records which. Verified emails are what make commit signatures
128128 meaningful — an unverified address never produces a =verified= badge.
129129
130* Audit and account control
131
132The audit log is the security feed (events are the product feed): every
133successful mutating command with its argv and source credential (SSH key
134fingerprint or API), registrations, admin actions, force-pushes, and
135auth failures/throttling. Secrets never appear — they travel on stdin,
136never in argv.
137
138#+begin_src sh
139gitbayd admin audit [--limit n] # host-local
140ssh git@<host> audit [--limit n] # instance admins, SSH only
141gitbayd admin user disable <name> # suspend: SSH, web, API all refused;
142gitbayd admin user enable <name> # sessions dropped, nothing deleted
143#+end_src
144
145=limits.ssh_auth_rate= (10) throttles per-IP authentication *failures*
146per minute — successful auths never count and clear the slate.
147=limits.max_pack_bytes= is enforced as =receive.maxInputSize= on every
148push.
149
130150 * Maintenance
131151
132152 #+begin_src sh
e2e/audit_test.go added +97
@@ -0,0 +1,97 @@
1package e2e
2
3import (
4 "crypto/rand"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11func TestAuditAndHardening(t *testing.T) {
12 inst := startInstanceWith(t, "[limits]\nssh_auth_rate = 3\nmax_pack_bytes = 2000\n")
13 adminKey := inst.newKey(t, "root")
14 aliceKey := inst.newKey(t, "alice")
15 bobKey := inst.newKey(t, "bob")
16 inst.admin(t, "admin", "user", "create", "root", "--key", adminKey+".pub", "--admin")
17 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
18 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
19
20 // Mutating commands land in the audit log with source fingerprints;
21 // reads do not. Admin-only over SSH; host admin command works too.
22 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
23 t.Fatal("repo create failed")
24 }
25 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
26 t.Fatal("grant failed")
27 }
28 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "list"); code != 0 {
29 t.Fatal("repo list failed")
30 }
31 if _, _, code := inst.ssh(t, aliceKey, "", "audit"); code != 4 {
32 t.Fatal("non-admin read the audit log")
33 }
34 out, _, code := inst.ssh(t, adminKey, "", "audit", "--json")
35 if code != 0 || !strings.Contains(out, "cmd repo create") ||
36 !strings.Contains(out, "cmd repo access grant") ||
37 !strings.Contains(out, `SHA256:`) || // key fingerprint as source
38 !strings.Contains(out, "admin user.created") {
39 t.Fatalf("audit content: %s", out)
40 }
41 if strings.Contains(out, "cmd repo list") {
42 t.Fatal("read-only command audited")
43 }
44 if out := inst.admin(t, "admin", "audit", "--limit", "5"); !strings.Contains(out, "cmd repo") {
45 t.Fatalf("host audit: %s", out)
46 }
47
48 // Disable: everything refused, sessions dropped, nothing deleted.
49 inst.admin(t, "admin", "user", "disable", "bob")
50 if _, errOut, code := inst.ssh(t, bobKey, "", "whoami"); code != 4 || !strings.Contains(errOut, "disabled") {
51 t.Fatalf("disabled ssh: exit %d, %s", code, errOut)
52 }
53 inst.admin(t, "admin", "user", "enable", "bob")
54 if _, _, code := inst.ssh(t, bobKey, "", "whoami"); code != 0 {
55 t.Fatal("re-enabled user still refused")
56 }
57
58 // max_pack_bytes: an oversized push is refused by receive-pack.
59 work := t.TempDir()
60 env := inst.gitEnv(aliceKey)
61 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
62 dir := filepath.Join(work, "w")
63 big := make([]byte, 200_000)
64 rand.Read(big) // incompressible: the pack must exceed max_pack_bytes
65 os.WriteFile(filepath.Join(dir, "big.bin"), big, 0o644)
66 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
67 mustGit(t, dir, env, "add", ".")
68 mustGit(t, dir, env, "commit", "-q", "-m", "big")
69 if out, code := gitRun(t, dir, env, "push", "origin", "main"); code == 0 || !strings.Contains(out, "max") {
70 t.Fatalf("oversized push accepted: exit %d\n%s", code, out)
71 }
72 // A normal-sized push still works.
73 mustGit(t, dir, env, "rm", "-q", "big.bin")
74 os.WriteFile(filepath.Join(dir, "small.txt"), []byte("ok\n"), 0o644)
75 mustGit(t, dir, env, "add", ".")
76 mustGit(t, dir, env, "commit", "-q", "--amend", "-m", "small")
77 mustGit(t, dir, env, "push", "-q", "origin", "main")
78
79 // Auth rate limit, LAST because it locks out this whole IP: a burst
80 // of unknown-key failures throttles further auth — even a valid key
81 // — until the window passes. (Registration is closed, so unknown
82 // keys fail auth.) The audit is read host-locally: SSH is locked.
83 strangerKey := inst.newKey(t, "stranger")
84 for i := 0; i < 5; i++ {
85 inst.ssh(t, strangerKey, "", "whoami")
86 }
87 if _, _, code := inst.ssh(t, adminKey, "", "whoami"); code == 0 {
88 t.Fatal("valid key not throttled after failure burst")
89 }
90 auditOut := inst.admin(t, "admin", "audit")
91 if !strings.Contains(auditOut, "auth.failed") || !strings.Contains(auditOut, "auth.throttled") {
92 t.Fatalf("burst not audited:\n%s", auditOut)
93 }
94 if strings.Count(auditOut, "auth.throttled") != 1 {
95 t.Fatal("throttle audited more than once per window")
96 }
97}
internal/control/audit.go added +44
@@ -0,0 +1,44 @@
1package control
2
3import (
4 "fmt"
5 "io"
6 "strconv"
7
8 "gitbay.org/gitbay/internal/protocol"
9)
10
11func init() {
12 register(Command{Path: []string{"audit"},
13 Summary: "instance audit log (admins): audit [--limit <n>]", ReadOnly: true, SSHOnly: true, Run: runAudit})
14}
15
16func runAudit(c *Ctx, args []string) int {
17 if !c.User.IsAdmin {
18 return c.fail(protocol.ExitDenied, "the audit log is for instance admins")
19 }
20 limit := 100
21 for i := 0; i < len(args); i++ {
22 if args[i] == "--limit" && i+1 < len(args) {
23 if n, err := strconv.Atoi(args[i+1]); err == nil && n > 0 && n <= 10000 {
24 limit = n
25 }
26 i++
27 } else {
28 return c.fail(protocol.ExitUsage, "usage: audit [--limit <n>]")
29 }
30 }
31 entries, err := c.Store.AuditEntries(limit)
32 if err != nil {
33 return c.fail(protocol.ExitFailure, "%v", err)
34 }
35 return c.emit(entries, func(w io.Writer) {
36 for _, e := range entries {
37 actor := e.Actor
38 if actor == "" {
39 actor = "-"
40 }
41 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.CreatedAt, actor, e.Action, e.Data)
42 }
43 })
44}
internal/control/control.go +14 −1
@@ -30,6 +30,9 @@ type Ctx struct {
3030 ViaAPI bool
3131 // ReadOnly is set for read-scoped API tokens.
3232 ReadOnly bool
33 // Source identifies the credential behind this session for the audit
34 // log: an SSH key fingerprint, or "api" for token requests.
35 Source string
3336 }
3437
3538 type Command struct {
@@ -100,7 +103,17 @@ func Dispatch(c *Ctx, argv []string) int {
100103 if !cmd.ReadsStdin {
101104 c.Stdin = emptyReader{}
102105 }
103 return cmd.Run(c, args)
106 code := cmd.Run(c, args)
107 // Every successful mutating command lands in the audit log. Argv is
108 // safe to record by construction: secrets travel on stdin, never as
109 // arguments.
110 if code == protocol.ExitOK && !cmd.ReadOnly {
111 c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{
112 "argv": args,
113 "source": c.Source,
114 })
115 }
116 return code
104117 }
105118
106119 // pendingAllowed lists what an unverified self-registered account may do.
internal/control/register.go +2
@@ -161,6 +161,7 @@ func RegisterAccount(cfg config.Config, st *store.Store, pub ssh.PublicKey, user
161161 }
162162 return "", err.Error(), protocol.ExitUsage
163163 }
164 st.Audit(0, "auth.registered", map[string]any{"user": username, "mode": "invite", "fingerprint": fp})
164165 return fmt.Sprintf("welcome, %s — your account is active\n", username), "", protocol.ExitOK
165166
166167 case "open":
@@ -174,6 +175,7 @@ func RegisterAccount(cfg config.Config, st *store.Store, pub ssh.PublicKey, user
174175 if err := sendVerification(cfg, st, uid, email); err != nil {
175176 return "", "sending verification mail: " + err.Error(), protocol.ExitFailure
176177 }
178 st.Audit(uid, "auth.registered", map[string]any{"user": username, "mode": "open", "fingerprint": fp})
177179 return fmt.Sprintf(
178180 "account %s created. A verification code was sent to %s.\nActivate with:\n\n ssh git@%s email verify <code>\n",
179181 username, email, siteHost(cfg)), "", protocol.ExitOK
internal/gitutil/gitutil.go +7 −3
@@ -31,12 +31,16 @@ func InitBare(path, defaultBranch, hooksPath string) error {
3131
3232 // Transport streams one git transport service (upload-pack, receive-pack,
3333 // upload-archive). extraEnv entries are appended to the process environment;
34// hooks read the GITBAY_* variables from it.
35func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string) error {
34// hooks read the GITBAY_* variables from it. maxPack caps incoming pack
35// bytes on receive-pack (0 = unlimited).
36func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64) error {
3637 var args []string
3738 switch service {
3839 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
39 args = []string{strings.TrimPrefix(service, "git-"), repoPath}
40 if service == "git-receive-pack" && maxPack > 0 {
41 args = []string{"-c", fmt.Sprintf("receive.maxInputSize=%d", maxPack)}
42 }
43 args = append(args, strings.TrimPrefix(service, "git-"), repoPath)
4044 default:
4145 return fmt.Errorf("unknown service %q", service)
4246 }
internal/hookd/hookd.go +4
@@ -185,6 +185,10 @@ func (s *Server) postReceive(req Request) {
185185 }
186186 // Any branch/tag update schedules the push mirrors.
187187 s.st.MarkMirrorsDirty(req.RepoID, "push")
188 if u.IsForce {
189 s.st.Audit(req.UserID, "push.forced", map[string]any{
190 "repo": req.RepoID, "ref": u.Ref, "old": u.Old, "new": u.New})
191 }
188192 mrs, err := s.st.OpenMRsBySource(req.RepoID, branch)
189193 if err != nil {
190194 slog.Error("post-receive: listing MRs", "err", err)
internal/httpd/api.go +1
@@ -50,6 +50,7 @@ func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) {
5050 var stdout, stderr bytes.Buffer
5151 ctx := &control.Ctx{
5252 User: user,
53 Source: "api",
5354 Scope: "full", // key scopes are an SSH concept; token scope is below
5455 Store: s.st,
5556 Cfg: s.cfg,
internal/sshd/ratelimit.go added +91
@@ -0,0 +1,91 @@
1package sshd
2
3import (
4 "net"
5 "sync"
6 "time"
7)
8
9// rateLimiter throttles per-IP authentication FAILURES: successful auths
10// never count (a busy CLI makes many connections per minute) and clear the
11// IP's slate. limits.ssh_auth_rate failures per window lock the IP out
12// until the window passes.
13type rateLimiter struct {
14 mu sync.Mutex
15 limit int
16 window time.Duration
17 seen map[string]*ipWindow
18}
19
20type ipWindow struct {
21 start time.Time
22 count int
23 audited bool
24}
25
26func newRateLimiter(limit int, window time.Duration) *rateLimiter {
27 return &rateLimiter{limit: limit, window: window, seen: map[string]*ipWindow{}}
28}
29
30// allow reports whether ip may attempt authentication at all.
31func (r *rateLimiter) allow(ip string) bool {
32 if r.limit <= 0 {
33 return true
34 }
35 r.mu.Lock()
36 defer r.mu.Unlock()
37 now := time.Now()
38 if len(r.seen) > 4096 {
39 for k, w := range r.seen {
40 if now.Sub(w.start) > r.window {
41 delete(r.seen, k)
42 }
43 }
44 }
45 w := r.seen[ip]
46 if w == nil || now.Sub(w.start) > r.window {
47 delete(r.seen, ip)
48 return true
49 }
50 return w.count < r.limit
51}
52
53// fail records an authentication failure for ip.
54func (r *rateLimiter) fail(ip string) {
55 r.mu.Lock()
56 defer r.mu.Unlock()
57 now := time.Now()
58 w := r.seen[ip]
59 if w == nil || now.Sub(w.start) > r.window {
60 r.seen[ip] = &ipWindow{start: now, count: 1}
61 return
62 }
63 w.count++
64}
65
66// success clears the IP's failure slate.
67func (r *rateLimiter) success(ip string) {
68 r.mu.Lock()
69 defer r.mu.Unlock()
70 delete(r.seen, ip)
71}
72
73// firstThrottle reports true exactly once per throttled window, so the
74// audit log records a burst rather than every rejected attempt.
75func (r *rateLimiter) firstThrottle(ip string) bool {
76 r.mu.Lock()
77 defer r.mu.Unlock()
78 w := r.seen[ip]
79 if w == nil || w.audited {
80 return false
81 }
82 w.audited = true
83 return true
84}
85
86func remoteIP(addr net.Addr) string {
87 if host, _, err := net.SplitHostPort(addr.String()); err == nil {
88 return host
89 }
90 return addr.String()
91}
internal/sshd/sshd.go +27 −8
@@ -15,6 +15,7 @@ import (
1515 "os"
1616 "path/filepath"
1717 "strconv"
18 "time"
1819
1920 "golang.org/x/crypto/ssh"
2021
@@ -28,13 +29,14 @@ import (
2829 )
2930
3031 type Server struct {
31 cfg config.Config
32 st *store.Store
33 sshCfg *ssh.ServerConfig
32 cfg config.Config
33 st *store.Store
34 sshCfg *ssh.ServerConfig
35 authLimiter *rateLimiter
3436 }
3537
3638 func New(cfg config.Config, st *store.Store) (*Server, error) {
37 s := &Server{cfg: cfg, st: st}
39 s := &Server{cfg: cfg, st: st, authLimiter: newRateLimiter(cfg.Limits.SSHAuthRate, time.Minute)}
3840
3941 sc := &ssh.ServerConfig{
4042 PublicKeyCallback: s.authenticate,
@@ -99,7 +101,15 @@ func generateHostKey(path string) error {
99101 // username is ignored; identity comes from the key alone. When registration
100102 // is open or invite-based, unknown keys are admitted to run exactly one
101103 // command: register.
102func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) {
104func (s *Server) authenticate(meta ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) {
105 ip := remoteIP(meta.RemoteAddr())
106 if !s.authLimiter.allow(ip) {
107 // One audit entry per throttled window, not per rejected attempt.
108 if s.authLimiter.firstThrottle(ip) {
109 s.st.Audit(0, "auth.throttled", map[string]any{"ip": ip, "rate": s.cfg.Limits.SSHAuthRate})
110 }
111 return nil, fmt.Errorf("too many authentication attempts; try again shortly")
112 }
103113 fp := ssh.FingerprintSHA256(pub)
104114 key, err := s.st.SSHKeyByFingerprint(fp)
105115 if err != nil {
@@ -108,11 +118,15 @@ func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permi
108118 "anon-key": base64.StdEncoding.EncodeToString(pub.Marshal()),
109119 }}, nil
110120 }
121 s.authLimiter.fail(ip)
122 s.st.Audit(0, "auth.failed", map[string]any{"ip": ip, "fingerprint": fp})
111123 return nil, fmt.Errorf("unknown key %s", fp)
112124 }
125 s.authLimiter.success(ip)
113126 return &ssh.Permissions{Extensions: map[string]string{
114127 "user-id": strconv.FormatInt(key.UserID, 10),
115128 "key-id": strconv.FormatInt(key.ID, 10),
129 "key-fp": fp,
116130 "scope": key.Scope,
117131 }}, nil
118132 }
@@ -196,7 +210,7 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string)
196210 return protocol.ExitDenied
197211 }
198212 _ = s.st.TouchSSHKey(keyID)
199 return Exec(s.cfg, s.st, user, ext["scope"], cmdline, ch, ch, ch.Stderr())
213 return Exec(s.cfg, s.st, user, ext["scope"], ext["key-fp"], cmdline, ch, ch, ch.Stderr())
200214 }
201215
202216 // runAnonymous handles a session from an unregistered key: the register
@@ -226,8 +240,12 @@ func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int {
226240 // Exec runs one SSH exec command line for an authenticated key. It is the
227241 // single dispatch path shared by the embedded listener and the system-sshd
228242 // forced command (gitbayd shell).
229func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline string,
243func Exec(cfg config.Config, st *store.Store, user store.User, scope, source, cmdline string,
230244 stdin io.Reader, stdout, stderr io.Writer) int {
245 if user.Disabled {
246 fmt.Fprintln(stderr, "this account is disabled; contact the instance admin")
247 return protocol.ExitDenied
248 }
231249 argv, err := protocol.Tokenize(cmdline)
232250 if err != nil {
233251 fmt.Fprintf(stderr, "cannot parse command: %v\n", err)
@@ -246,6 +264,7 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline st
246264 ctx := &control.Ctx{
247265 User: user,
248266 Scope: scope,
267 Source: source,
249268 Store: st,
250269 Cfg: cfg,
251270 Stdin: stdin,
@@ -315,7 +334,7 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
315334 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
316335 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
317336 }
318 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env); err != nil {
337 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, cfg.Limits.MaxPackBytes); err != nil {
319338 return protocol.ExitFailure
320339 }
321340 return protocol.ExitOK
internal/store/audit.go added +47
@@ -0,0 +1,47 @@
1package store
2
3import "encoding/json"
4
5// Audit appends to the security feed. Events are the product feed; this
6// records who did what, from where, for an operator. actorID 0 means the
7// host admin (gitbayd admin commands) or an unauthenticated source.
8func (s *Store) Audit(actorID int64, action string, data map[string]any) {
9 var actor any
10 if actorID != 0 {
11 actor = actorID
12 }
13 raw, err := json.Marshal(data)
14 if err != nil {
15 raw = []byte("{}")
16 }
17 s.DB.Exec("INSERT INTO audit_log (actor_id, action, data_json) VALUES (?, ?, ?)",
18 actor, action, string(raw))
19}
20
21type AuditEntry struct {
22 ID int64 `json:"id"`
23 Actor string `json:"actor,omitempty"`
24 Action string `json:"action"`
25 Data string `json:"data"`
26 CreatedAt string `json:"created_at"`
27}
28
29func (s *Store) AuditEntries(limit int) ([]AuditEntry, error) {
30 rows, err := s.DB.Query(`
31 SELECT a.id, COALESCE(u.username, ''), a.action, a.data_json, a.created_at
32 FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id
33 ORDER BY a.id DESC LIMIT ?`, limit)
34 if err != nil {
35 return nil, err
36 }
37 defer rows.Close()
38 var out []AuditEntry
39 for rows.Next() {
40 var e AuditEntry
41 if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Data, &e.CreatedAt); err != nil {
42 return nil, err
43 }
44 out = append(out, e)
45 }
46 return out, rows.Err()
47}
internal/store/migrations/0018_user_disable.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE users DROP COLUMN disabled;
internal/store/migrations/0018_user_disable.up.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE users ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
internal/store/users.go +30 −6
@@ -12,6 +12,7 @@ type User struct {
1212 Username string
1313 IsAdmin bool
1414 Pending bool // self-registered, email not yet verified
15 Disabled bool // administratively suspended
1516 }
1617
1718 type SSHKey struct {
@@ -47,27 +48,50 @@ func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) {
4748
4849 func (s *Store) UserByUsername(name string) (User, error) {
4950 var u User
50 var admin, pending int
51 err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE username = ?", name).
52 Scan(&u.ID, &u.Username, &admin, &pending)
51 var admin, pending, disabled int
52 err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled FROM users WHERE username = ?", name).
53 Scan(&u.ID, &u.Username, &admin, &pending, &disabled)
5354 if errors.Is(err, sql.ErrNoRows) {
5455 return u, ErrNotFound
5556 }
5657 u.IsAdmin = admin != 0
5758 u.Pending = pending != 0
59 u.Disabled = disabled != 0
5860 return u, err
5961 }
6062
63// SetUserDisabled suspends or restores an account. Disabling also drops
64// the user's web sessions; their keys and tokens stay registered but are
65// refused at every entry point until re-enabled.
66func (s *Store) SetUserDisabled(userID int64, disabled bool) error {
67 v := 0
68 if disabled {
69 v = 1
70 }
71 res, err := s.DB.Exec("UPDATE users SET disabled = ? WHERE id = ?", v, userID)
72 if err != nil {
73 return err
74 }
75 if n, _ := res.RowsAffected(); n == 0 {
76 return ErrNotFound
77 }
78 if disabled {
79 _, err = s.DB.Exec("DELETE FROM web_sessions WHERE user_id = ?", userID)
80 }
81 return err
82}
83
6184 func (s *Store) UserByID(id int64) (User, error) {
6285 var u User
63 var admin, pending int
64 err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE id = ?", id).
65 Scan(&u.ID, &u.Username, &admin, &pending)
86 var admin, pending, disabled int
87 err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled FROM users WHERE id = ?", id).
88 Scan(&u.ID, &u.Username, &admin, &pending, &disabled)
6689 if errors.Is(err, sql.ErrNoRows) {
6790 return u, ErrNotFound
6891 }
6992 u.IsAdmin = admin != 0
7093 u.Pending = pending != 0
94 u.Disabled = disabled != 0
7195 return u, err
7296 }
7397