Commit d47a2ef73f
Verified · cmc
Layout: unified · split
cmd/forged/hook.go added +71
| @@ -0,0 +1,71 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 20 | func 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 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 14 | func (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 | |||
| 25 | func (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. | ||
| 30 | func 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 | |||
| 45 | func 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 | |||
| 54 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 19 | func 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. | ||
| 24 | func HooksDir(root string) string { return filepath.Join(root, "hooks") } | ||
| 25 | |||
| 26 | func 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. | ||
| 50 | func 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 | |||
| 73 | func 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 | |||
| 117 | func policyValidateRepoName(name string) error { return policy.ValidateName(name) } | ||
| 118 | |||
| 119 | func hostOf(siteURL string) string { | ||
| 120 | s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://") | ||
| 121 | return strings.TrimSuffix(s, "/") | ||
| 122 | } | ||
| 123 | |||
| 124 | func 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 | |||
| 144 | func 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 | |||
| 167 | func 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 | |||
| 200 | func 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 | |||
| 219 | func 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 | |||
| 241 | func 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 | |||
| 268 | func 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 | |||
| 282 | func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } | ||
| 283 | func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) } | ||
| 284 | |||
| 285 | func 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. | ||
| 3 | package gitutil | ||
| 4 | |||
| 5 | import ( | ||
| 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. | ||
| 16 | func 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. | ||
| 34 | func 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. | ||
| 53 | func 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). | ||
| 66 | func 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. | ||
| 5 | package hookd | ||
| 6 | |||
| 7 | import ( | ||
| 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. | ||
| 20 | const ( | ||
| 21 | EnvSocket = "FORGE_HOOK_SOCKET" | ||
| 22 | EnvRepoID = "FORGE_REPO_ID" | ||
| 23 | EnvUserID = "FORGE_USER_ID" | ||
| 24 | ) | ||
| 25 | |||
| 26 | type 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 | |||
| 33 | type 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. | ||
| 39 | func SocketPath(root string) string { return filepath.Join(root, "hook.sock") } | ||
| 40 | |||
| 41 | type Server struct { | ||
| 42 | st *store.Store | ||
| 43 | } | ||
| 44 | |||
| 45 | // Serve listens on the unix socket until the listener is closed. | ||
| 46 | func 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 | |||
| 66 | func (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 | |||
| 76 | func (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. | ||
| 96 | func 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. | ||
| 115 | func 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 @@ | |||
| 1 | package policy | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 12 | func 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. | ||
| 23 | func 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. | ||
| 31 | func 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 | |||
| 38 | func 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. | ||
| 44 | func 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). | ||
| 68 | type 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. | ||
| 78 | func 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 @@ | |||
| 1 | package policy | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | |||
| 6 | "github.com/krazywarez/forge/internal/store" | ||
| 7 | ) | ||
| 8 | |||
| 9 | var ( | ||
| 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 | |||
| 16 | func 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 | |||
| 49 | func 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 | |||
| 72 | func 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. | ||
| 212 | func (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 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "encoding/json" | ||
| 6 | "errors" | ||
| 7 | "fmt" | ||
| 8 | "strings" | ||
| 9 | ) | ||
| 10 | |||
| 11 | type 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 | |||
| 22 | type 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. | ||
| 28 | func (r Repo) Path() string { return r.OwnerName + "/" + r.Name } | ||
| 29 | |||
| 30 | func (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. | ||
| 44 | func (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 | |||
| 68 | func (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 | |||
| 77 | func (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. | ||
| 89 | func (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). | ||
| 117 | func (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 | |||
| 128 | func (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 | |||
| 136 | func (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 | |||
| 149 | type AccessEntry struct { | ||
| 150 | Username string | ||
| 151 | Role string | ||
| 152 | } | ||
| 153 | |||
| 154 | func (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 | |||
| 174 | func (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 | } | ||