A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 8e41115b91

8e41115b914e6a2472704424ffb01c854a54e029

parent: e3ed3de7c8

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T15:44:18Z

deploy keys (#22)

Repository-bound CI keys: repo deploy-key add/list/remove (repo
admin; --rw for push). Authorization is the scope binding alone —
deploy:<repo_id>:ro|rw — never inherited from whoever registered the
key; the ID binding (instead of the originally sketched owner/name)
survives renames and transfers. Wrong-repo access reads as not-found,
control commands stay denied, removal severs access immediately.
Closes the gap where the policy layer understood deploy scopes no
command could grant.
cmd/gitbay/main.go +5
@@ -214,6 +214,11 @@ func repoCmd() *cobra.Command {
214214 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
215215 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone),
216216 importCmd(),
217 group("deploy-key", "repository-bound CI keys",
218 pass("add", "bind a key: [--rw] < key.pub", passOpts{server: []string{"repo", "deploy-key", "add"}, needsRepo: true, stdinOK: true}),
219 pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}),
220 pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}),
221 ),
217222 group("access", "manage access grants",
218223 pass("grant", "grant access: ... <user> read|write|admin", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}),
219224 pass("revoke", "revoke access: ... <user>", passOpts{server: []string{"repo", "access", "revoke"}, needsRepo: true}),
e2e/deploykey_test.go added +103
@@ -0,0 +1,103 @@
1package e2e
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11func TestDeployKeys(t *testing.T) {
12 inst := startInstance(t)
13 aliceKey := inst.newKey(t, "alice")
14 bobKey := inst.newKey(t, "bob")
15 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
16 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
17
18 // Private repo with content.
19 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app", "--private"); code != 0 {
20 t.Fatalf("repo create: %s", errOut)
21 }
22 work := t.TempDir()
23 env := inst.gitEnv(aliceKey)
24 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
25 dir := filepath.Join(work, "w")
26 os.WriteFile(filepath.Join(dir, "app.txt"), []byte("v1\n"), 0o644)
27 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
28 mustGit(t, dir, env, "add", ".")
29 mustGit(t, dir, env, "commit", "-q", "-m", "init")
30 mustGit(t, dir, env, "push", "-q", "origin", "main")
31
32 // Bind a read-only deploy key; non-admins cannot.
33 roKey := inst.newKey(t, "ci-ro")
34 roPub, _ := os.ReadFile(roKey + ".pub")
35 if _, _, code := inst.ssh(t, bobKey, string(roPub), "repo", "deploy-key", "add", "alice/app"); code != 3 {
36 t.Fatalf("non-admin deploy-key add on private repo: want not-found parity")
37 }
38 out, errOut, code := inst.ssh(t, aliceKey, string(roPub), "repo", "deploy-key", "add", "alice/app", "--json")
39 if code != 0 {
40 t.Fatalf("deploy-key add: %s", errOut)
41 }
42 var env2 struct {
43 Data struct {
44 Fingerprint string `json:"fingerprint"`
45 } `json:"data"`
46 }
47 json.Unmarshal([]byte(out), &env2)
48 roFP := env2.Data.Fingerprint
49
50 // The ro key clones the private repo but cannot push, cannot touch any
51 // other repo, and cannot run control commands.
52 roEnv := inst.gitEnv(roKey)
53 roWork := t.TempDir()
54 mustGit(t, roWork, roEnv, "clone", inst.sshURL("alice/app"), "w")
55 roDir := filepath.Join(roWork, "w")
56 mustGit(t, roDir, roEnv, "commit", "-q", "--allow-empty", "-m", "try")
57 if out, code := gitRun(t, roDir, roEnv, "push", "origin", "main"); code == 0 {
58 t.Fatalf("ro deploy key pushed:\n%s", out)
59 }
60 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/other", "--private"); code != 0 {
61 t.Fatal("other repo failed")
62 }
63 if out, code := gitRun(t, t.TempDir(), roEnv, "clone", inst.sshURL("alice/other")); code == 0 || !strings.Contains(out, "repository not found") {
64 t.Fatalf("deploy key crossed repos: %d\n%s", code, out)
65 }
66 if _, _, code := inst.ssh(t, roKey, "", "whoami"); code != 4 {
67 t.Fatal("deploy key ran a control command")
68 }
69
70 // An rw key pushes.
71 rwKey := inst.newKey(t, "ci-rw")
72 rwPub, _ := os.ReadFile(rwKey + ".pub")
73 if _, errOut, code = inst.ssh(t, aliceKey, string(rwPub), "repo", "deploy-key", "add", "alice/app", "--rw"); code != 0 {
74 t.Fatalf("rw add: %s", errOut)
75 }
76 rwEnv := inst.gitEnv(rwKey)
77 rwWork := t.TempDir()
78 mustGit(t, rwWork, rwEnv, "clone", inst.sshURL("alice/app"), "w")
79 rwDir := filepath.Join(rwWork, "w")
80 mustGit(t, rwDir, rwEnv, "commit", "-q", "--allow-empty", "-m", "ci push")
81 mustGit(t, rwDir, rwEnv, "push", "-q", "origin", "main")
82
83 // The binding survives an owner rename (keys bind to the repo ID).
84 if _, errOut, code = inst.ssh(t, aliceKey, "", "org", "create", "moved"); code != 0 {
85 t.Fatalf("org create: %s", errOut)
86 }
87 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "transfer", "alice/app", "moved"); code != 0 {
88 t.Fatalf("transfer: %s", errOut)
89 }
90 mustGit(t, t.TempDir(), roEnv, "clone", inst.sshURL("moved/app"))
91
92 // List shows both with modes; removal severs access immediately.
93 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "deploy-key", "list", "moved/app")
94 if !strings.Contains(out, "ro") || !strings.Contains(out, "rw") {
95 t.Fatalf("deploy-key list: %s", out)
96 }
97 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "deploy-key", "remove", "moved/app", roFP); code != 0 {
98 t.Fatalf("remove: %s", errOut)
99 }
100 if out, code := gitRun(t, t.TempDir(), roEnv, "clone", inst.sshURL("moved/app")); code == 0 {
101 t.Fatalf("removed deploy key still works:\n%s", out)
102 }
103}
internal/control/deploykey.go added +116
@@ -0,0 +1,116 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7
8 "golang.org/x/crypto/ssh"
9
10 "gitbay.org/gitbay/internal/policy"
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15func init() {
16 register(Command{Path: []string{"repo", "deploy-key", "add"},
17 Summary: "bind a read-only (or --rw) key to one repository: repo deploy-key add <owner/name> [--rw] < key.pub",
18 ReadsStdin: true, Run: runDeployKeyAdd})
19 register(Command{Path: []string{"repo", "deploy-key", "list"},
20 Summary: "list deploy keys: repo deploy-key list <owner/name>", ReadOnly: true, Run: runDeployKeyList})
21 register(Command{Path: []string{"repo", "deploy-key", "remove"},
22 Summary: "remove a deploy key: repo deploy-key remove <owner/name> <fingerprint>", Run: runDeployKeyRemove})
23}
24
25func runDeployKeyAdd(c *Ctx, args []string) int {
26 mode := "ro"
27 var path string
28 for _, a := range args {
29 switch a {
30 case "--rw":
31 mode = "rw"
32 default:
33 if path != "" {
34 return c.fail(protocol.ExitUsage, "usage: repo deploy-key add <owner/name> [--rw] < key.pub")
35 }
36 path = a
37 }
38 }
39 if path == "" {
40 return c.fail(protocol.ExitUsage, "usage: repo deploy-key add <owner/name> [--rw] < key.pub")
41 }
42 repo, code := resolveRepo(c, path, policy.CanAdmin)
43 if code >= 0 {
44 return code
45 }
46 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
47 if err != nil {
48 return c.fail(protocol.ExitFailure, "reading key: %v", err)
49 }
50 pub, _, _, _, err := ssh.ParseAuthorizedKey(raw)
51 if err != nil {
52 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
53 }
54 fp := ssh.FingerprintSHA256(pub)
55 scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode)
56 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil {
57 if errors.Is(err, store.ErrDuplicateKey) {
58 return c.fail(protocol.ExitUsage, "%v", err)
59 }
60 return c.fail(protocol.ExitFailure, "%v", err)
61 }
62 return c.emit(map[string]string{"fingerprint": fp, "mode": mode}, func(w io.Writer) {
63 fmt.Fprintf(w, "deploy key %s (%s) bound to %s\n", fp, mode, repo.Path())
64 })
65}
66
67func runDeployKeyList(c *Ctx, args []string) int {
68 if len(args) != 1 {
69 return c.fail(protocol.ExitUsage, "usage: repo deploy-key list <owner/name>")
70 }
71 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
72 if code >= 0 {
73 return code
74 }
75 keys, err := c.Store.ListDeployKeys(repo.ID)
76 if err != nil {
77 return c.fail(protocol.ExitFailure, "%v", err)
78 }
79 type out struct {
80 Fingerprint string `json:"fingerprint"`
81 Algo string `json:"algo"`
82 Mode string `json:"mode"`
83 }
84 var ds []out
85 for _, k := range keys {
86 mode := "ro"
87 if policy.DeployScopeAllows(k.Scope, repo.ID, true) {
88 mode = "rw"
89 }
90 ds = append(ds, out{k.Fingerprint, k.Algo, mode})
91 }
92 return c.emit(ds, func(w io.Writer) {
93 for _, d := range ds {
94 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Fingerprint, d.Algo, d.Mode)
95 }
96 })
97}
98
99func runDeployKeyRemove(c *Ctx, args []string) int {
100 if len(args) != 2 {
101 return c.fail(protocol.ExitUsage, "usage: repo deploy-key remove <owner/name> <fingerprint>")
102 }
103 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
104 if code >= 0 {
105 return code
106 }
107 if err := c.Store.RemoveDeployKey(repo.ID, args[1]); err != nil {
108 if errors.Is(err, store.ErrNotFound) {
109 return c.fail(protocol.ExitNotFound, "no deploy key %s on %s", args[1], repo.Path())
110 }
111 return c.fail(protocol.ExitFailure, "%v", err)
112 }
113 return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
114 fmt.Fprintf(w, "removed deploy key %s from %s\n", args[1], repo.Path())
115 })
116}
internal/policy/access.go +15 −4
@@ -1,6 +1,7 @@
11 package policy
22
33 import (
4 "strconv"
45 "strings"
56
67 "gitbay.org/gitbay/internal/store"
@@ -39,19 +40,26 @@ func isOwner(user store.User, repo store.Repo) bool {
3940 return repo.OwnerKind == "user" && repo.OwnerID == user.ID
4041 }
4142
42// ScopeAllowsGit reports whether an SSH key scope permits the requested git
43// transport on repoPath ("owner/name"). write=true for receive-pack.
43// ScopeAllowsGit reports whether an account-scoped SSH key permits git
44// transport at all. Deploy scopes are decided by DeployScopeAllows instead.
4445 func ScopeAllowsGit(scope, repoPath string, write bool) bool {
4546 switch scope {
4647 case "full", "git":
4748 return true
4849 }
50 return false
51}
52
53// DeployScopeAllows authorizes a deploy key purely by its scope: the key is
54// bound to a repository ID (rename- and transfer-proof), grants nothing
55// anywhere else, and never inherits the access of whoever registered it.
56func DeployScopeAllows(scope string, repoID int64, write bool) bool {
4957 rest, ok := strings.CutPrefix(scope, "deploy:")
5058 if !ok {
5159 return false
5260 }
53 target, mode, ok := strings.Cut(rest, ":")
54 if !ok || target != repoPath {
61 idStr, mode, ok := strings.Cut(rest, ":")
62 if !ok || idStr != strconv.FormatInt(repoID, 10) {
5563 return false
5664 }
5765 switch mode {
@@ -63,6 +71,9 @@ func ScopeAllowsGit(scope, repoPath string, write bool) bool {
6371 return false
6472 }
6573
74// IsDeployScope reports whether a key scope is a deploy binding.
75func IsDeployScope(scope string) bool { return strings.HasPrefix(scope, "deploy:") }
76
6677 // RefUpdate is one proposed ref change, with git facts computed by the hook
6778 // process (which can see quarantined objects; the daemon cannot).
6879 type RefUpdate struct {
internal/policy/access_test.go +22 −5
@@ -55,11 +55,7 @@ func TestScopeAllowsGit(t *testing.T) {
5555 }{
5656 {"full", "a/b", true, true},
5757 {"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
58 {"deploy:7:ro", "a/b", false, false}, // deploy keys never pass the account path
6359 {"", "a/b", false, false},
6460 }
6561 for _, tc := range cases {
@@ -69,6 +65,27 @@ func TestScopeAllowsGit(t *testing.T) {
6965 }
7066 }
7167
68func TestDeployScopeAllows(t *testing.T) {
69 cases := []struct {
70 scope string
71 repoID int64
72 write bool
73 want bool
74 }{
75 {"deploy:7:ro", 7, false, true},
76 {"deploy:7:ro", 7, true, false},
77 {"deploy:7:rw", 7, true, true},
78 {"deploy:7:rw", 8, false, false}, // wrong repo
79 {"deploy:7", 7, false, false}, // malformed
80 {"full", 7, false, false}, // not a deploy scope
81 }
82 for _, tc := range cases {
83 if got := DeployScopeAllows(tc.scope, tc.repoID, tc.write); got != tc.want {
84 t.Errorf("DeployScopeAllows(%q, %d, write=%v) = %v, want %v", tc.scope, tc.repoID, tc.write, got, tc.want)
85 }
86 }
87}
88
7289 func TestCheckPush(t *testing.T) {
7390 repo := store.Repo{Settings: store.RepoSettings{ProtectedBranches: []string{"main"}}}
7491 cases := []struct {
internal/sshd/sshd.go +27 −17
@@ -270,23 +270,33 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
270270 fmt.Fprintln(stderr, "repository not found")
271271 return protocol.ExitNotFound
272272 }
273 grant, err := st.AccessRole(repo.ID, user.ID)
274 if err != nil {
275 fmt.Fprintln(stderr, "internal error")
276 return protocol.ExitFailure
277 }
278 if !policy.CanRead(user, repo, grant) {
279 // Same answer as nonexistence: private repos must not be enumerable.
280 fmt.Fprintln(stderr, "repository not found")
281 return protocol.ExitNotFound
282 }
283 if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
284 fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path())
285 return protocol.ExitDenied
286 }
287 if write && !policy.CanWrite(user, repo, grant) {
288 fmt.Fprintf(stderr, "write access to %s denied\n", repo.Path())
289 return protocol.ExitDenied
273 if policy.IsDeployScope(scope) {
274 // A deploy key authorizes by its binding alone: one repository,
275 // its mode, nothing inherited from whoever registered it. Any
276 // mismatch reads as nonexistence, same as the access rules.
277 if !policy.DeployScopeAllows(scope, repo.ID, write) {
278 fmt.Fprintln(stderr, "repository not found")
279 return protocol.ExitNotFound
280 }
281 } else {
282 grant, err := st.AccessRole(repo.ID, user.ID)
283 if err != nil {
284 fmt.Fprintln(stderr, "internal error")
285 return protocol.ExitFailure
286 }
287 if !policy.CanRead(user, repo, grant) {
288 // Same answer as nonexistence: private repos must not be enumerable.
289 fmt.Fprintln(stderr, "repository not found")
290 return protocol.ExitNotFound
291 }
292 if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
293 fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path())
294 return protocol.ExitDenied
295 }
296 if write && !policy.CanWrite(user, repo, grant) {
297 fmt.Fprintf(stderr, "write access to %s denied\n", repo.Path())
298 return protocol.ExitDenied
299 }
290300 }
291301
292302 dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
internal/store/users.go +43
@@ -217,3 +217,46 @@ func (s *Store) SSHKeyByID(id int64) (SSHKey, error) {
217217 }
218218 return k, err
219219 }
220
221// ListDeployKeys returns the deploy keys bound to a repository.
222func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
223 rows, err := s.DB.Query(
224 "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id",
225 repoID)
226 if err != nil {
227 return nil, err
228 }
229 defer rows.Close()
230 var keys []SSHKey
231 for rows.Next() {
232 var k SSHKey
233 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope); err != nil {
234 return nil, err
235 }
236 keys = append(keys, k)
237 }
238 return keys, rows.Err()
239}
240
241// RemoveDeployKey removes a deploy key from a repository by fingerprint;
242// any repo admin may remove it regardless of who added it.
243func (s *Store) RemoveDeployKey(repoID int64, fingerprint string) error {
244 tx, err := s.DB.Begin()
245 if err != nil {
246 return err
247 }
248 defer tx.Rollback()
249 res, err := tx.Exec(
250 "DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%'",
251 fingerprint, repoID)
252 if err != nil {
253 return err
254 }
255 if n, _ := res.RowsAffected(); n == 0 {
256 return ErrNotFound
257 }
258 if err := bumpKeyEpoch(tx); err != nil {
259 return err
260 }
261 return tx.Commit()
262}