Commit d47a2ef73f

d47a2ef73f46cfbc3083c56e041d79130ae9eb71

parent: c0b0f1fca2

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 22:31 UTC

M2: repositories, git transport over SSH, ref policy hooks

- repo create/list/show/delete, access grant/revoke/list, settings
  protect/unprotect as control commands
- git upload-pack/receive-pack/upload-archive streamed over the SSH
  channel after access and scope checks; private repos answer
  'repository not found' identically to nonexistence
- shared core.hooksPath directory regenerated at startup; pre-receive
  computes git facts in the hook process (quarantine-safe) and asks the
  daemon for policy over a unix socket
- ref policy: protected branches refuse force-push and deletion;
  refs/merge-requests/* unpushable by clients
- access policy matrix and deploy-key scope checks with unit tests
- e2e: full M2 scenario with real git (private denial, read grant,
  read-only push denial, protect/force/delete/unprotect)

Layout: unified · split

cmd/forged/hook.go added +71
@@ -0,0 +1,71 @@
1package main
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "strconv"
8 "strings"
9
10 "github.com/spf13/cobra"
11
12 "github.com/krazywarez/forge/internal/gitutil"
13 "github.com/krazywarez/forge/internal/hookd"
14 "github.com/krazywarez/forge/internal/policy"
15)
16
17// hookCmd runs inside a git hook. It computes git facts here — the hook
18// process inherits git's quarantine environment, so incoming objects are
19// visible — and asks the daemon for a policy decision over the unix socket.
20func hookCmd() *cobra.Command {
21 return &cobra.Command{
22 Use: "hook <pre-receive|post-receive>",
23 Hidden: true,
24 Args: cobra.ExactArgs(1),
25 RunE: func(cmd *cobra.Command, args []string) error {
26 sock := os.Getenv(hookd.EnvSocket)
27 repoID, err1 := strconv.ParseInt(os.Getenv(hookd.EnvRepoID), 10, 64)
28 userID, err2 := strconv.ParseInt(os.Getenv(hookd.EnvUserID), 10, 64)
29 if sock == "" || err1 != nil || err2 != nil {
30 return fmt.Errorf("missing FORGE_* environment; this command only runs as a git hook")
31 }
32
33 var updates []policy.RefUpdate
34 scanner := bufio.NewScanner(os.Stdin)
35 for scanner.Scan() {
36 fields := strings.Fields(scanner.Text())
37 if len(fields) != 3 {
38 continue
39 }
40 u := policy.RefUpdate{Old: fields[0], New: fields[1], Ref: fields[2]}
41 u.IsDelete = gitutil.ZeroSHA(u.New)
42 if !u.IsDelete && !gitutil.ZeroSHA(u.Old) {
43 anc, err := gitutil.IsAncestor(".", u.Old, u.New)
44 if err != nil {
45 return fmt.Errorf("checking ancestry for %s: %w", u.Ref, err)
46 }
47 u.IsForce = !anc
48 }
49 updates = append(updates, u)
50 }
51 if err := scanner.Err(); err != nil {
52 return err
53 }
54
55 resp, err := hookd.Ask(sock, hookd.Request{
56 Hook: args[0],
57 RepoID: repoID,
58 UserID: userID,
59 Updates: updates,
60 })
61 if err != nil {
62 return fmt.Errorf("forge daemon unreachable: %w", err)
63 }
64 if !resp.Allow {
65 fmt.Fprintln(os.Stderr, resp.Message)
66 os.Exit(1)
67 }
68 return nil
69 },
70 }
71}
cmd/forged/main.go +19
@@ -14,6 +14,8 @@ import (
14 "golang.org/x/crypto/ssh" 14 "golang.org/x/crypto/ssh"
15 15
16 "github.com/krazywarez/forge/internal/config" 16 "github.com/krazywarez/forge/internal/config"
17 "github.com/krazywarez/forge/internal/control"
18 "github.com/krazywarez/forge/internal/hookd"
17 "github.com/krazywarez/forge/internal/policy" 19 "github.com/krazywarez/forge/internal/policy"
18 "github.com/krazywarez/forge/internal/sshd" 20 "github.com/krazywarez/forge/internal/sshd"
19 "github.com/krazywarez/forge/internal/store" 21 "github.com/krazywarez/forge/internal/store"
@@ -47,6 +49,7 @@ func main() {
47 serveCmd(), 49 serveCmd(),
48 migrateCmd(), 50 migrateCmd(),
49 adminCmd(), 51 adminCmd(),
52 hookCmd(),
50 ) 53 )
51 54
52 if err := root.Execute(); err != nil { 55 if err := root.Execute(); err != nil {
@@ -96,6 +99,22 @@ func serveCmd() *cobra.Command {
96 if cfg.SSH.Mode != "embedded" { 99 if cfg.SSH.Mode != "embedded" {
97 return fmt.Errorf("ssh.mode = %q not implemented (M9)", cfg.SSH.Mode) 100 return fmt.Errorf("ssh.mode = %q not implemented (M9)", cfg.SSH.Mode)
98 } 101 }
102
103 // Regenerate hook scripts so a moved binary self-heals, then
104 // start the hook policy socket.
105 self, err := os.Executable()
106 if err != nil {
107 return err
108 }
109 if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil {
110 return err
111 }
112 stopHookd, err := hookd.Serve(cfg.Server.Root, st)
113 if err != nil {
114 return err
115 }
116 defer stopHookd()
117
99 srv, err := sshd.New(cfg, st) 118 srv, err := sshd.New(cfg, st)
100 if err != nil { 119 if err != nil {
101 return err 120 return err
e2e/git_test.go added +156
@@ -0,0 +1,156 @@
1package e2e
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12// gitEnv returns the environment for running the git client against the
13// instance with the given key.
14func (i *instance) gitEnv(key string) []string {
15 sshCmd := fmt.Sprintf(
16 "ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes",
17 key, filepath.Join(i.sshDir, "known_hosts"))
18 return append(os.Environ(),
19 "GIT_SSH_COMMAND="+sshCmd,
20 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
21 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
22 )
23}
24
25func (i *instance) sshURL(repo string) string {
26 return fmt.Sprintf("ssh://git@127.0.0.1:%d/%s.git", i.port, repo)
27}
28
29// git runs a git command; returns combined output and exit code.
30func gitRun(t *testing.T, dir string, env []string, args ...string) (string, int) {
31 t.Helper()
32 cmd := exec.Command("git", args...)
33 cmd.Dir = dir
34 cmd.Env = env
35 out, err := cmd.CombinedOutput()
36 code := 0
37 if ee, ok := err.(*exec.ExitError); ok {
38 code = ee.ExitCode()
39 } else if err != nil {
40 t.Fatalf("git %v: %v", args, err)
41 }
42 return string(out), code
43}
44
45func mustGit(t *testing.T, dir string, env []string, args ...string) string {
46 t.Helper()
47 out, code := gitRun(t, dir, env, args...)
48 if code != 0 {
49 t.Fatalf("git %v failed (%d):\n%s", args, code, out)
50 }
51 return out
52}
53
54func TestGitOverSSH(t *testing.T) {
55 inst := startInstance(t)
56
57 aliceKey := inst.newKey(t, "alice")
58 bobKey := inst.newKey(t, "bob")
59 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
60 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
61
62 // Alice creates a private repo over bare ssh.
63 _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/proj", "--private")
64 if code != 0 {
65 t.Fatalf("repo create: exit %d, %s", code, errOut)
66 }
67
68 // Alice clones (empty), commits, pushes.
69 work := t.TempDir()
70 aliceEnv := inst.gitEnv(aliceKey)
71 mustGit(t, work, aliceEnv, "clone", inst.sshURL("alice/proj"), "proj")
72 dir := filepath.Join(work, "proj")
73 if err := os.WriteFile(filepath.Join(dir, "README"), []byte("hello\n"), 0o644); err != nil {
74 t.Fatal(err)
75 }
76 mustGit(t, dir, aliceEnv, "checkout", "-q", "-b", "main")
77 mustGit(t, dir, aliceEnv, "add", "README")
78 mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "init")
79 mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
80
81 // Bob is denied clone of the private repo, indistinguishable from
82 // nonexistence.
83 bobEnv := inst.gitEnv(bobKey)
84 out, code := gitRun(t, t.TempDir(), bobEnv, "clone", inst.sshURL("alice/proj"), "proj")
85 if code == 0 {
86 t.Fatal("bob cloned a private repo without access")
87 }
88 if !strings.Contains(out, "repository not found") {
89 t.Fatalf("denial should read as not-found, got:\n%s", out)
90 }
91
92 // Alice grants bob read; clone succeeds; push is denied.
93 _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/proj", "bob", "read")
94 if code != 0 {
95 t.Fatalf("access grant: exit %d, %s", code, errOut)
96 }
97 bobWork := t.TempDir()
98 mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/proj"), "proj")
99 bobDir := filepath.Join(bobWork, "proj")
100 if err := os.WriteFile(filepath.Join(bobDir, "x"), []byte("x\n"), 0o644); err != nil {
101 t.Fatal(err)
102 }
103 mustGit(t, bobDir, bobEnv, "add", "x")
104 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "bob")
105 out, code = gitRun(t, bobDir, bobEnv, "push", "origin", "main")
106 if code == 0 {
107 t.Fatal("bob pushed with read-only access")
108 }
109 if !strings.Contains(out, "write access to alice/proj denied") {
110 t.Fatalf("push denial message:\n%s", out)
111 }
112
113 // Alice protects main: force-push and deletion are refused by the hook,
114 // normal pushes still work.
115 _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "protect", "alice/proj", "main")
116 if code != 0 {
117 t.Fatalf("protect: exit %d, %s", code, errOut)
118 }
119
120 mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "second")
121 mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
122
123 mustGit(t, dir, aliceEnv, "reset", "-q", "--hard", "HEAD~1")
124 mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "rewritten")
125 out, code = gitRun(t, dir, aliceEnv, "push", "--force", "origin", "main")
126 if code == 0 {
127 t.Fatal("force-push to protected branch succeeded")
128 }
129 if !strings.Contains(out, "force-push refused") {
130 t.Fatalf("force-push denial message:\n%s", out)
131 }
132
133 out, code = gitRun(t, dir, aliceEnv, "push", "origin", ":main")
134 if code == 0 {
135 t.Fatal("deletion of protected branch succeeded")
136 }
137 if !strings.Contains(out, "deletion refused") {
138 t.Fatalf("deletion denial message:\n%s", out)
139 }
140
141 // refs/merge-requests/* is unpushable even by the owner.
142 out, code = gitRun(t, dir, aliceEnv, "push", "origin", "HEAD:refs/merge-requests/1/head")
143 if code == 0 {
144 t.Fatal("client pushed into refs/merge-requests/*")
145 }
146 if !strings.Contains(out, "server-owned") {
147 t.Fatalf("mr-ref denial message:\n%s", out)
148 }
149
150 // Unprotect: force-push now goes through.
151 _, _, code = inst.ssh(t, aliceKey, "", "repo", "settings", "unprotect", "alice/proj", "main")
152 if code != 0 {
153 t.Fatal("unprotect failed")
154 }
155 mustGit(t, dir, aliceEnv, "push", "-q", "--force", "origin", "main")
156}
internal/control/repo.go added +311
@@ -0,0 +1,311 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11
12 "github.com/krazywarez/forge/internal/gitutil"
13 "github.com/krazywarez/forge/internal/policy"
14 "github.com/krazywarez/forge/internal/protocol"
15 "github.com/krazywarez/forge/internal/store"
16)
17
18// RepoDir returns the on-disk path for a repository.
19func RepoDir(root, owner, name string) string {
20 return filepath.Join(root, "repos", owner, name+".git")
21}
22
23// HooksDir is the shared core.hooksPath directory.
24func HooksDir(root string) string { return filepath.Join(root, "hooks") }
25
26func init() {
27 register(Command{Path: []string{"repo", "create"},
28 Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate})
29 register(Command{Path: []string{"repo", "list"},
30 Summary: "list repositories you own or can access", Run: runRepoList})
31 register(Command{Path: []string{"repo", "show"},
32 Summary: "show repository details: repo show <owner/name>", Run: runRepoShow})
33 register(Command{Path: []string{"repo", "delete"},
34 Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete})
35 register(Command{Path: []string{"repo", "access", "grant"},
36 Summary: "grant access: repo access grant <owner/name> <user> read|write|admin", Run: runAccessGrant})
37 register(Command{Path: []string{"repo", "access", "revoke"},
38 Summary: "revoke access: repo access revoke <owner/name> <user>", Run: runAccessRevoke})
39 register(Command{Path: []string{"repo", "access", "list"},
40 Summary: "list access grants: repo access list <owner/name>", Run: runAccessList})
41 register(Command{Path: []string{"repo", "settings", "show"},
42 Summary: "show settings: repo settings show <owner/name>", Run: runSettingsShow})
43 register(Command{Path: []string{"repo", "settings", "protect"},
44 Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect})
45 register(Command{Path: []string{"repo", "settings", "unprotect"},
46 Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
47}
48
49// resolveRepo loads a repo and checks the given permission for c.User.
50func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
51 repo, err := c.Store.RepoByPath(path)
52 if err != nil {
53 if errors.Is(err, store.ErrNotFound) {
54 // Same message whether it doesn't exist or is invisible.
55 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
56 }
57 return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
58 }
59 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
60 if err != nil {
61 return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
62 }
63 if !check(c.User, repo, grant) {
64 if !policy.CanRead(c.User, repo, grant) {
65 // Invisible repos 404, per the enumeration rule.
66 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
67 }
68 return repo, c.fail(protocol.ExitDenied, "permission denied on %s", path)
69 }
70 return repo, -1
71}
72
73func runRepoCreate(c *Ctx, args []string) int {
74 visibility := "public"
75 var path string
76 for _, a := range args {
77 switch a {
78 case "--private":
79 visibility = "private"
80 default:
81 if path != "" {
82 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
83 }
84 path = a
85 }
86 }
87 owner, name, ok := strings.Cut(path, "/")
88 if !ok {
89 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
90 }
91 if owner != c.User.Username {
92 return c.fail(protocol.ExitDenied, "cannot create repositories under %q (orgs not yet supported)", owner)
93 }
94 if err := policyValidateRepoName(name); err != nil {
95 return c.fail(protocol.ExitUsage, "%v", err)
96 }
97 id, err := c.Store.CreateRepo("user", c.User.ID, name, visibility)
98 if err != nil {
99 return c.fail(protocol.ExitFailure, "%v", err)
100 }
101 dir := RepoDir(c.Cfg.Server.Root, owner, name)
102 if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
103 c.Store.DeleteRepo(id)
104 return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
105 }
106 type out struct {
107 Path string `json:"path"`
108 Visibility string `json:"visibility"`
109 SSHURL string `json:"ssh_url"`
110 }
111 d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
112 return c.emit(d, func(w io.Writer) {
113 fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
114 })
115}
116
117func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
118
119func hostOf(siteURL string) string {
120 s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
121 return strings.TrimSuffix(s, "/")
122}
123
124func runRepoList(c *Ctx, args []string) int {
125 repos, err := c.Store.ListReposForUser(c.User.ID)
126 if err != nil {
127 return c.fail(protocol.ExitFailure, "%v", err)
128 }
129 type out struct {
130 Path string `json:"path"`
131 Visibility string `json:"visibility"`
132 }
133 var ds []out
134 for _, r := range repos {
135 ds = append(ds, out{r.Path(), r.Visibility})
136 }
137 return c.emit(ds, func(w io.Writer) {
138 for _, d := range ds {
139 fmt.Fprintf(w, "%s\t%s\n", d.Path, d.Visibility)
140 }
141 })
142}
143
144func runRepoShow(c *Ctx, args []string) int {
145 if len(args) != 1 {
146 return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>")
147 }
148 repo, code := resolveRepo(c, args[0], policy.CanRead)
149 if code >= 0 {
150 return code
151 }
152 type out struct {
153 Path string `json:"path"`
154 Visibility string `json:"visibility"`
155 DefaultBranch string `json:"default_branch"`
156 ProtectedBranches []string `json:"protected_branches,omitempty"`
157 }
158 d := out{repo.Path(), repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches}
159 return c.emit(d, func(w io.Writer) {
160 fmt.Fprintf(w, "%s\t%s\tdefault: %s\n", d.Path, d.Visibility, d.DefaultBranch)
161 if len(d.ProtectedBranches) > 0 {
162 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
163 }
164 })
165}
166
167func runRepoDelete(c *Ctx, args []string) int {
168 var path string
169 var yes bool
170 for _, a := range args {
171 if a == "--yes" {
172 yes = true
173 } else if path == "" {
174 path = a
175 } else {
176 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
177 }
178 }
179 if path == "" {
180 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
181 }
182 repo, code := resolveRepo(c, path, policy.CanAdmin)
183 if code >= 0 {
184 return code
185 }
186 if !yes {
187 return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
188 }
189 if err := c.Store.DeleteRepo(repo.ID); err != nil {
190 return c.fail(protocol.ExitFailure, "%v", err)
191 }
192 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
193 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
194 }
195 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
196 fmt.Fprintf(w, "deleted %s\n", repo.Path())
197 })
198}
199
200func runAccessGrant(c *Ctx, args []string) int {
201 if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
202 return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
203 }
204 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
205 if code >= 0 {
206 return code
207 }
208 target, err := c.Store.UserByUsername(args[1])
209 if err != nil {
210 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
211 }
212 if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
213 return c.fail(protocol.ExitFailure, "%v", err)
214 }
215 return c.emit(map[string]string{"granted": args[2], "user": target.Username},
216 func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
217}
218
219func runAccessRevoke(c *Ctx, args []string) int {
220 if len(args) != 2 {
221 return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
222 }
223 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
224 if code >= 0 {
225 return code
226 }
227 target, err := c.Store.UserByUsername(args[1])
228 if err != nil {
229 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
230 }
231 if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
232 if errors.Is(err, store.ErrNotFound) {
233 return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
234 }
235 return c.fail(protocol.ExitFailure, "%v", err)
236 }
237 return c.emit(map[string]string{"revoked": target.Username},
238 func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
239}
240
241func runAccessList(c *Ctx, args []string) int {
242 if len(args) != 1 {
243 return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
244 }
245 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
246 if code >= 0 {
247 return code
248 }
249 entries, err := c.Store.ListAccess(repo.ID)
250 if err != nil {
251 return c.fail(protocol.ExitFailure, "%v", err)
252 }
253 type out struct {
254 User string `json:"user"`
255 Role string `json:"role"`
256 }
257 var ds []out
258 for _, e := range entries {
259 ds = append(ds, out{e.Username, e.Role})
260 }
261 return c.emit(ds, func(w io.Writer) {
262 for _, d := range ds {
263 fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
264 }
265 })
266}
267
268func runSettingsShow(c *Ctx, args []string) int {
269 if len(args) != 1 {
270 return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
271 }
272 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
273 if code >= 0 {
274 return code
275 }
276 return c.emit(repo.Settings, func(w io.Writer) {
277 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\n",
278 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits)
279 })
280}
281
282func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
283func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
284
285func setProtect(c *Ctx, args []string, protect bool) int {
286 if len(args) != 2 {
287 return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
288 }
289 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
290 if code >= 0 {
291 return code
292 }
293 branch := args[1]
294 s := repo.Settings
295 has := slices.Contains(s.ProtectedBranches, branch)
296 if protect && !has {
297 s.ProtectedBranches = append(s.ProtectedBranches, branch)
298 slices.Sort(s.ProtectedBranches)
299 }
300 if !protect && has {
301 s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
302 }
303 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
304 return c.fail(protocol.ExitFailure, "%v", err)
305 }
306 verb := "protected"
307 if !protect {
308 verb = "unprotected"
309 }
310 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
311}
internal/gitutil/gitutil.go added +76
@@ -0,0 +1,76 @@
1// Package gitutil wraps the system git binary. All repository access goes
2// through git subprocesses; there is no in-process git implementation.
3package gitutil
4
5import (
6 "fmt"
7 "io"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12)
13
14// InitBare creates a bare repository with the shared hooks directory wired
15// via core.hooksPath.
16func InitBare(path, defaultBranch, hooksPath string) error {
17 if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
18 return err
19 }
20 cmd := exec.Command("git", "init", "--bare", "--initial-branch="+defaultBranch, path)
21 if out, err := cmd.CombinedOutput(); err != nil {
22 return fmt.Errorf("git init: %v\n%s", err, out)
23 }
24 cmd = exec.Command("git", "-C", path, "config", "core.hooksPath", hooksPath)
25 if out, err := cmd.CombinedOutput(); err != nil {
26 return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
27 }
28 return nil
29}
30
31// Transport streams one git transport service (upload-pack, receive-pack,
32// upload-archive) over rw. extraEnv entries are appended to the daemon's
33// environment; hooks read the FORGE_* variables from it.
34func Transport(service, repoPath string, rw io.ReadWriter, errW io.Writer, extraEnv []string) error {
35 var args []string
36 switch service {
37 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
38 args = []string{strings.TrimPrefix(service, "git-"), repoPath}
39 default:
40 return fmt.Errorf("unknown service %q", service)
41 }
42 cmd := exec.Command("git", args...)
43 cmd.Env = append(os.Environ(), extraEnv...)
44 cmd.Stdin = rw
45 cmd.Stdout = rw
46 cmd.Stderr = errW
47 return cmd.Run()
48}
49
50// IsAncestor reports whether old is an ancestor of new in the repository at
51// dir. It must run with the caller's environment intact so that quarantined
52// objects during pre-receive remain visible.
53func IsAncestor(dir, old, new string) (bool, error) {
54 cmd := exec.Command("git", "-C", dir, "merge-base", "--is-ancestor", old, new)
55 err := cmd.Run()
56 if err == nil {
57 return true, nil
58 }
59 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
60 return false, nil
61 }
62 return false, err
63}
64
65// ZeroSHA reports whether s is an all-zero object id (SHA-1 or SHA-256).
66func ZeroSHA(s string) bool {
67 if len(s) != 40 && len(s) != 64 {
68 return false
69 }
70 for i := 0; i < len(s); i++ {
71 if s[i] != '0' {
72 return false
73 }
74 }
75 return true
76}
internal/hookd/hookd.go added +126
@@ -0,0 +1,126 @@
1// Package hookd is the unix-socket bridge between git hooks and the daemon.
2// The hook process (forged in hook mode) computes git facts — it inherits
3// git's quarantine environment, which the daemon does not see — and sends
4// them here; the daemon answers with a pure policy decision.
5package hookd
6
7import (
8 "encoding/json"
9 "fmt"
10 "net"
11 "os"
12 "path/filepath"
13
14 "github.com/krazywarez/forge/internal/policy"
15 "github.com/krazywarez/forge/internal/store"
16)
17
18// Env variable names passed to git transport subprocesses and inherited by
19// hooks.
20const (
21 EnvSocket = "FORGE_HOOK_SOCKET"
22 EnvRepoID = "FORGE_REPO_ID"
23 EnvUserID = "FORGE_USER_ID"
24)
25
26type Request struct {
27 Hook string `json:"hook"` // pre-receive | post-receive
28 RepoID int64 `json:"repo_id"`
29 UserID int64 `json:"user_id"`
30 Updates []policy.RefUpdate `json:"updates"`
31}
32
33type Response struct {
34 Allow bool `json:"allow"`
35 Message string `json:"message,omitempty"`
36}
37
38// SocketPath returns the hook socket location under the server root.
39func SocketPath(root string) string { return filepath.Join(root, "hook.sock") }
40
41type Server struct {
42 st *store.Store
43}
44
45// Serve listens on the unix socket until the listener is closed.
46func Serve(root string, st *store.Store) (func() error, error) {
47 path := SocketPath(root)
48 os.Remove(path)
49 ln, err := net.Listen("unix", path)
50 if err != nil {
51 return nil, err
52 }
53 s := &Server{st: st}
54 go func() {
55 for {
56 conn, err := ln.Accept()
57 if err != nil {
58 return
59 }
60 go s.handle(conn)
61 }
62 }()
63 return ln.Close, nil
64}
65
66func (s *Server) handle(conn net.Conn) {
67 defer conn.Close()
68 var req Request
69 if err := json.NewDecoder(conn).Decode(&req); err != nil {
70 json.NewEncoder(conn).Encode(Response{Allow: false, Message: "bad hook request"})
71 return
72 }
73 json.NewEncoder(conn).Encode(s.decide(req))
74}
75
76func (s *Server) decide(req Request) Response {
77 switch req.Hook {
78 case "pre-receive":
79 repo, err := s.st.RepoByID(req.RepoID)
80 if err != nil {
81 return Response{Allow: false, Message: "unknown repository"}
82 }
83 if msg := policy.CheckPush(repo, req.Updates); msg != "" {
84 return Response{Allow: false, Message: msg}
85 }
86 return Response{Allow: true}
87 case "post-receive":
88 // Event recording and signature verification enqueue land in M4.
89 return Response{Allow: true}
90 default:
91 return Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)}
92 }
93}
94
95// Ask sends one request from the hook process to the daemon.
96func Ask(socketPath string, req Request) (Response, error) {
97 conn, err := net.Dial("unix", socketPath)
98 if err != nil {
99 return Response{}, err
100 }
101 defer conn.Close()
102 if err := json.NewEncoder(conn).Encode(req); err != nil {
103 return Response{}, err
104 }
105 var resp Response
106 if err := json.NewDecoder(conn).Decode(&resp); err != nil {
107 return Response{}, err
108 }
109 return resp, nil
110}
111
112// WriteHookScripts (re)generates the shared hooks directory. Called at
113// daemon startup so a moved binary self-heals; every repo points here via
114// core.hooksPath.
115func WriteHookScripts(hooksDir, forgedPath string) error {
116 if err := os.MkdirAll(hooksDir, 0o755); err != nil {
117 return err
118 }
119 for _, hook := range []string{"pre-receive", "post-receive"} {
120 script := fmt.Sprintf("#!/bin/sh\nexec %q hook %s\n", forgedPath, hook)
121 if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(script), 0o755); err != nil {
122 return err
123 }
124 }
125 return nil
126}
internal/policy/access.go added +98
@@ -0,0 +1,98 @@
1package policy
2
3import (
4 "strings"
5
6 "github.com/krazywarez/forge/internal/store"
7)
8
9// CanRead reports whether user may read repo over an authenticated channel.
10// Public repos are readable by any authenticated user; private repos require
11// ownership or an explicit grant.
12func CanRead(user store.User, repo store.Repo, grant string) bool {
13 if isOwner(user, repo) {
14 return true
15 }
16 if repo.Visibility == "public" {
17 return true
18 }
19 return grant == "read" || grant == "write" || grant == "admin"
20}
21
22// CanWrite reports whether user may push to repo.
23func CanWrite(user store.User, repo store.Repo, grant string) bool {
24 if isOwner(user, repo) {
25 return true
26 }
27 return grant == "write" || grant == "admin"
28}
29
30// CanAdmin reports whether user may change repo settings and access.
31func CanAdmin(user store.User, repo store.Repo, grant string) bool {
32 if isOwner(user, repo) {
33 return true
34 }
35 return grant == "admin"
36}
37
38func isOwner(user store.User, repo store.Repo) bool {
39 return repo.OwnerKind == "user" && repo.OwnerID == user.ID
40}
41
42// ScopeAllowsGit reports whether an SSH key scope permits the requested git
43// transport on repoPath ("owner/name"). write=true for receive-pack.
44func ScopeAllowsGit(scope, repoPath string, write bool) bool {
45 switch scope {
46 case "full", "git":
47 return true
48 }
49 rest, ok := strings.CutPrefix(scope, "deploy:")
50 if !ok {
51 return false
52 }
53 target, mode, ok := strings.Cut(rest, ":")
54 if !ok || target != repoPath {
55 return false
56 }
57 switch mode {
58 case "rw":
59 return true
60 case "ro":
61 return !write
62 }
63 return false
64}
65
66// RefUpdate is one proposed ref change, with git facts computed by the hook
67// process (which can see quarantined objects; the daemon cannot).
68type RefUpdate struct {
69 Ref string `json:"ref"`
70 Old string `json:"old"`
71 New string `json:"new"`
72 IsDelete bool `json:"is_delete"`
73 IsForce bool `json:"is_force"`
74}
75
76// CheckPush applies ref policy for a push by a user with write access
77// already established. It returns a denial message, or "" to allow.
78func CheckPush(repo store.Repo, updates []RefUpdate) string {
79 protected := map[string]bool{}
80 for _, b := range repo.Settings.ProtectedBranches {
81 protected["refs/heads/"+b] = true
82 }
83 for _, u := range updates {
84 if strings.HasPrefix(u.Ref, "refs/merge-requests/") {
85 return "refs/merge-requests/* is server-owned and cannot be pushed"
86 }
87 if protected[u.Ref] {
88 branch := strings.TrimPrefix(u.Ref, "refs/heads/")
89 if u.IsDelete {
90 return "branch " + branch + " is protected: deletion refused"
91 }
92 if u.IsForce {
93 return "branch " + branch + " is protected: force-push refused"
94 }
95 }
96 }
97 return ""
98}
internal/policy/access_test.go added +95
@@ -0,0 +1,95 @@
1package policy
2
3import (
4 "testing"
5
6 "github.com/krazywarez/forge/internal/store"
7)
8
9var (
10 owner = store.User{ID: 1, Username: "alice"}
11 stranger = store.User{ID: 2, Username: "bob"}
12 priv = store.Repo{ID: 10, OwnerKind: "user", OwnerID: 1, OwnerName: "alice", Name: "p", Visibility: "private"}
13 pub = store.Repo{ID: 11, OwnerKind: "user", OwnerID: 1, OwnerName: "alice", Name: "q", Visibility: "public"}
14)
15
16func TestAccessMatrix(t *testing.T) {
17 cases := []struct {
18 name string
19 user store.User
20 repo store.Repo
21 grant string
22 read bool
23 write bool
24 admin bool
25 }{
26 {"owner private", owner, priv, "", true, true, true},
27 {"stranger private no grant", stranger, priv, "", false, false, false},
28 {"stranger private read", stranger, priv, "read", true, false, false},
29 {"stranger private write", stranger, priv, "write", true, true, false},
30 {"stranger private admin", stranger, priv, "admin", true, true, true},
31 {"stranger public no grant", stranger, pub, "", true, false, false},
32 {"stranger public write", stranger, pub, "write", true, true, false},
33 }
34 for _, tc := range cases {
35 t.Run(tc.name, func(t *testing.T) {
36 if got := CanRead(tc.user, tc.repo, tc.grant); got != tc.read {
37 t.Errorf("CanRead = %v, want %v", got, tc.read)
38 }
39 if got := CanWrite(tc.user, tc.repo, tc.grant); got != tc.write {
40 t.Errorf("CanWrite = %v, want %v", got, tc.write)
41 }
42 if got := CanAdmin(tc.user, tc.repo, tc.grant); got != tc.admin {
43 t.Errorf("CanAdmin = %v, want %v", got, tc.admin)
44 }
45 })
46 }
47}
48
49func TestScopeAllowsGit(t *testing.T) {
50 cases := []struct {
51 scope string
52 repo string
53 write bool
54 want bool
55 }{
56 {"full", "a/b", true, true},
57 {"git", "a/b", true, true},
58 {"deploy:a/b:ro", "a/b", false, true},
59 {"deploy:a/b:ro", "a/b", true, false},
60 {"deploy:a/b:rw", "a/b", true, true},
61 {"deploy:a/b:rw", "a/c", false, false}, // wrong repo
62 {"deploy:a/b", "a/b", false, false}, // malformed
63 {"", "a/b", false, false},
64 }
65 for _, tc := range cases {
66 if got := ScopeAllowsGit(tc.scope, tc.repo, tc.write); got != tc.want {
67 t.Errorf("ScopeAllowsGit(%q, %q, write=%v) = %v, want %v", tc.scope, tc.repo, tc.write, got, tc.want)
68 }
69 }
70}
71
72func TestCheckPush(t *testing.T) {
73 repo := store.Repo{Settings: store.RepoSettings{ProtectedBranches: []string{"main"}}}
74 cases := []struct {
75 name string
76 updates []RefUpdate
77 denied bool
78 }{
79 {"normal push to protected", []RefUpdate{{Ref: "refs/heads/main"}}, false},
80 {"force to protected", []RefUpdate{{Ref: "refs/heads/main", IsForce: true}}, true},
81 {"delete protected", []RefUpdate{{Ref: "refs/heads/main", IsDelete: true}}, true},
82 {"force to unprotected", []RefUpdate{{Ref: "refs/heads/dev", IsForce: true}}, false},
83 {"delete unprotected", []RefUpdate{{Ref: "refs/heads/dev", IsDelete: true}}, false},
84 {"mr namespace", []RefUpdate{{Ref: "refs/merge-requests/1/head"}}, true},
85 {"tag alongside protected", []RefUpdate{{Ref: "refs/tags/v1"}, {Ref: "refs/heads/main"}}, false},
86 }
87 for _, tc := range cases {
88 t.Run(tc.name, func(t *testing.T) {
89 msg := CheckPush(repo, tc.updates)
90 if (msg != "") != tc.denied {
91 t.Errorf("CheckPush = %q, denied should be %v", msg, tc.denied)
92 }
93 })
94 }
95}
internal/sshd/sshd.go +54 −9
@@ -13,12 +13,14 @@ import (
13 "os" 13 "os"
14 "path/filepath" 14 "path/filepath"
15 "strconv" 15 "strconv"
16 "strings"
17 16
18 "golang.org/x/crypto/ssh" 17 "golang.org/x/crypto/ssh"
19 18
20 "github.com/krazywarez/forge/internal/config" 19 "github.com/krazywarez/forge/internal/config"
21 "github.com/krazywarez/forge/internal/control" 20 "github.com/krazywarez/forge/internal/control"
21 "github.com/krazywarez/forge/internal/gitutil"
22 "github.com/krazywarez/forge/internal/hookd"
23 "github.com/krazywarez/forge/internal/policy"
22 "github.com/krazywarez/forge/internal/protocol" 24 "github.com/krazywarez/forge/internal/protocol"
23 "github.com/krazywarez/forge/internal/store" 25 "github.com/krazywarez/forge/internal/store"
24) 26)
@@ -183,19 +185,17 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string)
183 } 185 }
184 _ = s.st.TouchSSHKey(keyID) 186 _ = s.st.TouchSSHKey(keyID)
185 187
186 if name, _, ok := strings.Cut(cmdline, " "); ok || name != "" {
187 switch name {
188 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
189 fmt.Fprintln(ch.Stderr(), "git transport not implemented (M2)")
190 return protocol.ExitFailure
191 }
192 }
193
194 argv, err := protocol.Tokenize(cmdline) 188 argv, err := protocol.Tokenize(cmdline)
195 if err != nil { 189 if err != nil {
196 fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err) 190 fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err)
197 return protocol.ExitUsage 191 return protocol.ExitUsage
198 } 192 }
193 if len(argv) > 0 {
194 switch argv[0] {
195 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
196 return s.runGit(ch, user, ext["scope"], argv)
197 }
198 }
199 ctx := &control.Ctx{ 199 ctx := &control.Ctx{
200 User: user, 200 User: user,
201 Scope: ext["scope"], 201 Scope: ext["scope"],
@@ -207,3 +207,48 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string)
207 } 207 }
208 return control.Dispatch(ctx, argv) 208 return control.Dispatch(ctx, argv)
209} 209}
210
211// runGit streams a git transport service after access checks.
212func (s *Server) runGit(ch ssh.Channel, user store.User, scope string, argv []string) int {
213 service := argv[0]
214 if len(argv) != 2 {
215 fmt.Fprintf(ch.Stderr(), "usage: %s <path>\n", service)
216 return protocol.ExitUsage
217 }
218 write := service == "git-receive-pack"
219
220 repo, err := s.st.RepoByPath(argv[1])
221 if err != nil {
222 fmt.Fprintln(ch.Stderr(), "repository not found")
223 return protocol.ExitNotFound
224 }
225 grant, err := s.st.AccessRole(repo.ID, user.ID)
226 if err != nil {
227 fmt.Fprintln(ch.Stderr(), "internal error")
228 return protocol.ExitFailure
229 }
230 if !policy.CanRead(user, repo, grant) {
231 // Same answer as nonexistence: private repos must not be enumerable.
232 fmt.Fprintln(ch.Stderr(), "repository not found")
233 return protocol.ExitNotFound
234 }
235 if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
236 fmt.Fprintf(ch.Stderr(), "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path())
237 return protocol.ExitDenied
238 }
239 if write && !policy.CanWrite(user, repo, grant) {
240 fmt.Fprintf(ch.Stderr(), "write access to %s denied\n", repo.Path())
241 return protocol.ExitDenied
242 }
243
244 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
245 env := []string{
246 hookd.EnvSocket + "=" + hookd.SocketPath(s.cfg.Server.Root),
247 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
248 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
249 }
250 if err := gitutil.Transport(service, dir, ch, ch.Stderr(), env); err != nil {
251 return protocol.ExitFailure
252 }
253 return protocol.ExitOK
254}
internal/store/repos.go added +192
@@ -0,0 +1,192 @@
1package store
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9)
10
11type Repo struct {
12 ID int64
13 OwnerKind string // user | org
14 OwnerID int64
15 OwnerName string // resolved for display and disk paths
16 Name string
17 Visibility string // public | private
18 DefaultBranch string
19 Settings RepoSettings
20}
21
22type RepoSettings struct {
23 ProtectedBranches []string `json:"protected_branches,omitempty"`
24 RequireSignedCommits bool `json:"require_signed_commits,omitempty"`
25}
26
27// Path returns the canonical owner/name form.
28func (r Repo) Path() string { return r.OwnerName + "/" + r.Name }
29
30func (s *Store) CreateRepo(ownerKind string, ownerID int64, name, visibility string) (int64, error) {
31 res, err := s.DB.Exec(
32 "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES (?, ?, ?, ?)",
33 ownerKind, ownerID, name, visibility)
34 if err != nil {
35 if isUniqueErr(err) {
36 return 0, fmt.Errorf("repository %q already exists", name)
37 }
38 return 0, err
39 }
40 return res.LastInsertId()
41}
42
43// RepoByPath resolves "owner/name". Only user owners exist until orgs land.
44func (s *Store) RepoByPath(path string) (Repo, error) {
45 owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/")
46 if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
47 return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound)
48 }
49 var r Repo
50 var settingsJSON string
51 err := s.DB.QueryRow(`
52 SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
53 FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
54 WHERE u.username = ? AND r.name = ?`, owner, name).
55 Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON)
56 if errors.Is(err, sql.ErrNoRows) {
57 return Repo{}, ErrNotFound
58 }
59 if err != nil {
60 return Repo{}, err
61 }
62 if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
63 return Repo{}, fmt.Errorf("repo %d settings: %w", r.ID, err)
64 }
65 return r, nil
66}
67
68func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error {
69 raw, err := json.Marshal(settings)
70 if err != nil {
71 return err
72 }
73 _, err = s.DB.Exec("UPDATE repos SET settings_json = ? WHERE id = ?", string(raw), repoID)
74 return err
75}
76
77func (s *Store) DeleteRepo(repoID int64) error {
78 res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID)
79 if err != nil {
80 return err
81 }
82 if n, _ := res.RowsAffected(); n == 0 {
83 return ErrNotFound
84 }
85 return nil
86}
87
88// ListReposForUser returns repos the user owns or has an explicit grant on.
89func (s *Store) ListReposForUser(userID int64) ([]Repo, error) {
90 rows, err := s.DB.Query(`
91 SELECT DISTINCT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
92 FROM repos r
93 JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
94 LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?
95 WHERE r.owner_id = ? OR a.subject_id IS NOT NULL
96 ORDER BY u.username, r.name`, userID, userID)
97 if err != nil {
98 return nil, err
99 }
100 defer rows.Close()
101 var out []Repo
102 for rows.Next() {
103 var r Repo
104 var settingsJSON string
105 if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil {
106 return nil, err
107 }
108 if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
109 return nil, err
110 }
111 out = append(out, r)
112 }
113 return out, rows.Err()
114}
115
116// AccessRole returns the explicit grant for userID on repoID ("" if none).
117func (s *Store) AccessRole(repoID, userID int64) (string, error) {
118 var role string
119 err := s.DB.QueryRow(
120 "SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
121 repoID, userID).Scan(&role)
122 if errors.Is(err, sql.ErrNoRows) {
123 return "", nil
124 }
125 return role, err
126}
127
128func (s *Store) GrantAccess(repoID, userID int64, role string) error {
129 _, err := s.DB.Exec(`
130 INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'user', ?, ?)
131 ON CONFLICT (repo_id, subject_kind, subject_id) DO UPDATE SET role = excluded.role`,
132 repoID, userID, role)
133 return err
134}
135
136func (s *Store) RevokeAccess(repoID, userID int64) error {
137 res, err := s.DB.Exec(
138 "DELETE FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
139 repoID, userID)
140 if err != nil {
141 return err
142 }
143 if n, _ := res.RowsAffected(); n == 0 {
144 return ErrNotFound
145 }
146 return nil
147}
148
149type AccessEntry struct {
150 Username string
151 Role string
152}
153
154func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) {
155 rows, err := s.DB.Query(`
156 SELECT u.username, a.role FROM repo_access a
157 JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id
158 WHERE a.repo_id = ? ORDER BY u.username`, repoID)
159 if err != nil {
160 return nil, err
161 }
162 defer rows.Close()
163 var out []AccessEntry
164 for rows.Next() {
165 var e AccessEntry
166 if err := rows.Scan(&e.Username, &e.Role); err != nil {
167 return nil, err
168 }
169 out = append(out, e)
170 }
171 return out, rows.Err()
172}
173
174func (s *Store) RepoByID(id int64) (Repo, error) {
175 var r Repo
176 var settingsJSON string
177 err := s.DB.QueryRow(`
178 SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
179 FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
180 WHERE r.id = ?`, id).
181 Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON)
182 if errors.Is(err, sql.ErrNoRows) {
183 return Repo{}, ErrNotFound
184 }
185 if err != nil {
186 return Repo{}, err
187 }
188 if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
189 return Repo{}, err
190 }
191 return r, nil
192}