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 | 14 | "golang.org/x/crypto/ssh" |
| 15 | 15 | |
| 16 | 16 | "github.com/krazywarez/forge/internal/config" |
| 17 | "github.com/krazywarez/forge/internal/control" | |
| 18 | "github.com/krazywarez/forge/internal/hookd" | |
| 17 | 19 | "github.com/krazywarez/forge/internal/policy" |
| 18 | 20 | "github.com/krazywarez/forge/internal/sshd" |
| 19 | 21 | "github.com/krazywarez/forge/internal/store" |
| @@ -47,6 +49,7 @@ func main() { | ||
| 47 | 49 | serveCmd(), |
| 48 | 50 | migrateCmd(), |
| 49 | 51 | adminCmd(), |
| 52 | hookCmd(), | |
| 50 | 53 | ) |
| 51 | 54 | |
| 52 | 55 | if err := root.Execute(); err != nil { |
| @@ -96,6 +99,22 @@ func serveCmd() *cobra.Command { | ||
| 96 | 99 | if cfg.SSH.Mode != "embedded" { |
| 97 | 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 | 118 | srv, err := sshd.New(cfg, st) |
| 100 | 119 | if err != nil { |
| 101 | 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 | 13 | "os" |
| 14 | 14 | "path/filepath" |
| 15 | 15 | "strconv" |
| 16 | "strings" | |
| 17 | 16 | |
| 18 | 17 | "golang.org/x/crypto/ssh" |
| 19 | 18 | |
| 20 | 19 | "github.com/krazywarez/forge/internal/config" |
| 21 | 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 | 24 | "github.com/krazywarez/forge/internal/protocol" |
| 23 | 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 | 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 | 188 | argv, err := protocol.Tokenize(cmdline) |
| 195 | 189 | if err != nil { |
| 196 | 190 | fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err) |
| 197 | 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 | 199 | ctx := &control.Ctx{ |
| 200 | 200 | User: user, |
| 201 | 201 | Scope: ext["scope"], |
| @@ -207,3 +207,48 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) | ||
| 207 | 207 | } |
| 208 | 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 | } | |