Commit 08edfb55e9

08edfb55e9aaa928470ea444e141008827e77aa2

parent: 399d2f300e

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 00:35 UTC

orgs: shared-namespace organizations with membership-derived access

- org create/list/show/delete and org members add/remove/list; creator
  becomes admin; last-admin demotion/removal refused; delete refused
  while the org owns repositories
- users and orgs share one owner namespace, enforced on every creation
  path (org create, admin user create, self-registration)
- repo queries resolve owners from either table; ListReposForUser also
  fixes a latent owner_id collision (missing owner_kind check) and now
  includes org repos via membership
- AccessRole returns the effective role: strongest of the explicit
  grant and the org-derived role (org admin -> admin, member -> write);
  every access check (control, git transport, web) picks this up
- repo create under an org requires org admin; forks still land under
  the forking user
- CLI org passthrough group
- e2e: namespace collisions both directions, member write via git push,
  outsider not-found on private org repo, member vs admin boundaries,
  explicit grant alongside membership, promote/last-admin protection,
  delete lifecycle, org repos on the anonymous web index

Layout: unified · split

cmd/gitbay/main.go +15
@@ -30,6 +30,7 @@ func main() {
30 issueCmd(), 30 issueCmd(),
31 mrCmd(), 31 mrCmd(),
32 webCmd(), 32 webCmd(),
33 orgCmd(),
33 remoteCmd(), 34 remoteCmd(),
34 initCmd(), 35 initCmd(),
35 pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>", 36 pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>",
@@ -287,6 +288,20 @@ func webCmd() *cobra.Command {
287 ) 288 )
288} 289}
289 290
291func orgCmd() *cobra.Command {
292 return group("org", "organizations",
293 pass("create", "create an organization", passOpts{server: []string{"org", "create"}}),
294 pass("list", "list organizations you belong to", passOpts{server: []string{"org", "list"}}),
295 pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}),
296 pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}),
297 group("members", "manage members",
298 pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}),
299 pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}),
300 pass("list", "list members: <org>", passOpts{server: []string{"org", "members", "list"}}),
301 ),
302 )
303}
304
290func remoteCmd() *cobra.Command { 305func remoteCmd() *cobra.Command {
291 return group("remote", "local instance profiles (no server contact)", 306 return group("remote", "local instance profiles (no server contact)",
292 local("add", "add a named gitbay instance: gitbay remote add <name> <host> [--port n] [--user u] [--ssh-option o]... [--default]", 307 local("add", "add a named gitbay instance: gitbay remote add <name> <host> [--port n] [--user u] [--ssh-option o]... [--default]",
e2e/org_test.go added +130
@@ -0,0 +1,130 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func TestOrganizations(t *testing.T) {
11 inst := startInstance(t)
12 aliceKey := inst.newKey(t, "alice")
13 bobKey := inst.newKey(t, "bob")
14 eveKey := inst.newKey(t, "eve")
15 inst.admin(t, "admin", "user", "create", "alice",
16 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
17 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
18 inst.admin(t, "admin", "user", "create", "eve", "--key", eveKey+".pub")
19
20 // Alice creates an org; the namespace is shared with users.
21 if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "krz"); code != 0 {
22 t.Fatalf("org create: %s", errOut)
23 }
24 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "bob"); code == 0 {
25 t.Fatal("org created with a user's name")
26 }
27 if out := inst.admin(t, "admin", "user", "create", "krz2", "--key", inst.newKey(t, "krz2")+".pub"); out == "" {
28 t.Fatal("control user create failed")
29 }
30 // A user cannot claim an org's name either.
31 cmd := inst.forgedAdminErr(t, "admin", "user", "create", "krz")
32 if !strings.Contains(cmd, "taken") {
33 t.Fatalf("user with org name: %s", cmd)
34 }
35
36 // Only org admins create repos under the org.
37 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "create", "krz/lib"); code != 4 {
38 t.Fatalf("non-member org repo create: %d %s", code, errOut)
39 }
40 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "krz/lib", "--private"); code != 0 {
41 t.Fatalf("org repo create: %s", errOut)
42 }
43
44 // Membership-derived access: bob (member) gets write, eve (outsider)
45 // sees nothing on the private repo.
46 if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "members", "add", "krz", "bob"); code != 0 {
47 t.Fatalf("members add: %s", errOut)
48 }
49 work := t.TempDir()
50 bobEnv := inst.gitEnv(bobKey)
51 mustGit(t, work, bobEnv, "clone", inst.sshURL("krz/lib"), "w")
52 dir := filepath.Join(work, "w")
53 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("org work\n"), 0o644)
54 mustGit(t, dir, bobEnv, "checkout", "-q", "-b", "main")
55 mustGit(t, dir, bobEnv, "add", ".")
56 mustGit(t, dir, bobEnv, "commit", "-q", "-m", "bob pushes to org repo")
57 mustGit(t, dir, bobEnv, "push", "-q", "origin", "main")
58
59 if out, code := gitRun(t, t.TempDir(), inst.gitEnv(eveKey), "clone", inst.sshURL("krz/lib")); code == 0 || !strings.Contains(out, "repository not found") {
60 t.Fatalf("outsider on private org repo: %d\n%s", code, out)
61 }
62
63 // Members are not repo admins: bob cannot change settings or grant
64 // access; an org admin can.
65 if _, _, code := inst.ssh(t, bobKey, "", "repo", "settings", "protect", "krz/lib", "main"); code != 4 {
66 t.Fatal("member changed org repo settings")
67 }
68 if _, _, code := inst.ssh(t, bobKey, "", "org", "members", "add", "krz", "eve"); code != 4 {
69 t.Fatal("member managed org membership")
70 }
71 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "protect", "krz/lib", "main"); code != 0 {
72 t.Fatalf("org admin protect: %s", errOut)
73 }
74
75 // Explicit per-repo grants still work alongside membership: eve gets
76 // read on the private org repo.
77 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "krz/lib", "eve", "read"); code != 0 {
78 t.Fatalf("grant: %s", errOut)
79 }
80 mustGit(t, t.TempDir(), inst.gitEnv(eveKey), "clone", inst.sshURL("krz/lib"))
81
82 // Org repos list for members; org shows in org list.
83 out, _, _ := inst.ssh(t, bobKey, "", "repo", "list")
84 if !strings.Contains(out, "krz/lib") {
85 t.Fatalf("member repo list missing org repo:\n%s", out)
86 }
87 out, _, _ = inst.ssh(t, bobKey, "", "org", "list")
88 if !strings.Contains(out, "krz\tmember") {
89 t.Fatalf("org list: %s", out)
90 }
91
92 // Promotion works; the last admin is protected.
93 if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "members", "add", "krz", "bob", "--role", "admin"); code != 0 {
94 t.Fatalf("promote: %s", errOut)
95 }
96 if _, errOut, code := inst.ssh(t, bobKey, "", "org", "members", "remove", "krz", "alice"); code != 0 {
97 t.Fatalf("bob (now admin) removing alice: %s", errOut)
98 }
99 _, errOut, code := inst.ssh(t, bobKey, "", "org", "members", "remove", "krz", "bob")
100 if code != 2 || !strings.Contains(errOut, "at least one admin") {
101 t.Fatalf("last admin removal: %d %s", code, errOut)
102 }
103
104 // Org deletion refuses while repos exist, then succeeds.
105 _, errOut, code = inst.ssh(t, bobKey, "", "org", "delete", "krz", "--yes")
106 if code != 1 || !strings.Contains(errOut, "still owns") {
107 t.Fatalf("delete with repos: %d %s", code, errOut)
108 }
109 if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "delete", "krz/lib", "--yes"); code != 0 {
110 t.Fatalf("org repo delete: %s", errOut)
111 }
112 if _, errOut, code = inst.ssh(t, bobKey, "", "org", "delete", "krz", "--yes"); code != 0 {
113 t.Fatalf("org delete: %s", errOut)
114 }
115
116 // Public org repos appear on the anonymous web index.
117 if _, _, code = inst.ssh(t, aliceKey, "", "org", "create", "puborg"); code != 0 {
118 t.Fatal("org create failed")
119 }
120 if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "puborg/site"); code != 0 {
121 t.Fatal("org public repo failed")
122 }
123 status, body := inst.get(t, "/")
124 if status != 200 || !strings.Contains(body, "puborg/site") {
125 t.Fatalf("org repo missing from index: %d", status)
126 }
127 if status, _ := inst.get(t, "/puborg/site"); status != 200 {
128 t.Fatalf("org repo page: %d", status)
129 }
130}
e2e/ssh_test.go +11
@@ -115,6 +115,17 @@ func (i *instance) admin(t *testing.T, args ...string) string {
115 return string(out) 115 return string(out)
116} 116}
117 117
118// forgedAdminErr runs an admin command expected to fail, returning output.
119func (i *instance) forgedAdminErr(t *testing.T, args ...string) string {
120 t.Helper()
121 cmd := exec.Command(i.gitbayd, append([]string{"--config", i.config}, args...)...)
122 out, err := cmd.CombinedOutput()
123 if err == nil {
124 t.Fatalf("gitbayd %v unexpectedly succeeded:\n%s", args, out)
125 }
126 return string(out)
127}
128
118// newKey generates a client keypair and returns the private key path. 129// newKey generates a client keypair and returns the private key path.
119func (i *instance) newKey(t *testing.T, name string) string { 130func (i *instance) newKey(t *testing.T, name string) string {
120 t.Helper() 131 t.Helper()
internal/control/org.go added +216
@@ -0,0 +1,216 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7
8 "gitbay.org/gitbay/internal/policy"
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func init() {
14 register(Command{Path: []string{"org", "create"},
15 Summary: "create an organization (you become its first admin): org create <name>", Run: runOrgCreate})
16 register(Command{Path: []string{"org", "list"},
17 Summary: "list organizations you belong to", Run: runOrgList})
18 register(Command{Path: []string{"org", "show"},
19 Summary: "show an organization and its members: org show <name>", Run: runOrgShow})
20 register(Command{Path: []string{"org", "delete"},
21 Summary: "delete an empty organization: org delete <name> --yes", Run: runOrgDelete})
22 register(Command{Path: []string{"org", "members", "add"},
23 Summary: "add or update a member: org members add <org> <user> [--role member|admin]", Run: runOrgMembersAdd})
24 register(Command{Path: []string{"org", "members", "remove"},
25 Summary: "remove a member: org members remove <org> <user>", Run: runOrgMembersRemove})
26 register(Command{Path: []string{"org", "members", "list"},
27 Summary: "list members: org members list <org>", Run: runOrgMembersList})
28}
29
30// orgAdmin loads an org and requires the caller to be one of its admins.
31func orgAdmin(c *Ctx, name string) (store.Org, int) {
32 org, err := c.Store.OrgByName(name)
33 if errors.Is(err, store.ErrNotFound) {
34 return org, c.fail(protocol.ExitNotFound, "no organization %q", name)
35 }
36 if err != nil {
37 return org, c.fail(protocol.ExitFailure, "%v", err)
38 }
39 role, err := c.Store.OrgRole(org.ID, c.User.ID)
40 if err != nil {
41 return org, c.fail(protocol.ExitFailure, "%v", err)
42 }
43 if role != "admin" {
44 return org, c.fail(protocol.ExitDenied, "only admins of %s can do that", name)
45 }
46 return org, -1
47}
48
49func runOrgCreate(c *Ctx, args []string) int {
50 if len(args) != 1 {
51 return c.fail(protocol.ExitUsage, "usage: org create <name>")
52 }
53 if err := policy.ValidateOwnerName(args[0]); err != nil {
54 return c.fail(protocol.ExitUsage, "%v", err)
55 }
56 if _, err := c.Store.CreateOrg(args[0], c.User.ID); err != nil {
57 return c.fail(protocol.ExitFailure, "%v", err)
58 }
59 return c.emit(map[string]string{"org": args[0], "role": "admin"}, func(w io.Writer) {
60 fmt.Fprintf(w, "created organization %s; you are its admin\n", args[0])
61 })
62}
63
64func runOrgList(c *Ctx, args []string) int {
65 orgs, err := c.Store.ListOrgsForUser(c.User.ID)
66 if err != nil {
67 return c.fail(protocol.ExitFailure, "%v", err)
68 }
69 type out struct {
70 Org string `json:"org"`
71 Role string `json:"role"`
72 }
73 var ds []out
74 for _, o := range orgs {
75 ds = append(ds, out{o.Username, o.Role})
76 }
77 return c.emit(ds, func(w io.Writer) {
78 for _, d := range ds {
79 fmt.Fprintf(w, "%s\t%s\n", d.Org, d.Role)
80 }
81 })
82}
83
84func runOrgShow(c *Ctx, args []string) int {
85 if len(args) != 1 {
86 return c.fail(protocol.ExitUsage, "usage: org show <name>")
87 }
88 org, err := c.Store.OrgByName(args[0])
89 if errors.Is(err, store.ErrNotFound) {
90 return c.fail(protocol.ExitNotFound, "no organization %q", args[0])
91 }
92 if err != nil {
93 return c.fail(protocol.ExitFailure, "%v", err)
94 }
95 members, err := c.Store.OrgMembers(org.ID)
96 if err != nil {
97 return c.fail(protocol.ExitFailure, "%v", err)
98 }
99 type memberOut struct {
100 User string `json:"user"`
101 Role string `json:"role"`
102 }
103 var ms []memberOut
104 for _, m := range members {
105 ms = append(ms, memberOut{m.Username, m.Role})
106 }
107 d := struct {
108 Org string `json:"org"`
109 Members []memberOut `json:"members"`
110 }{org.Name, ms}
111 return c.emit(d, func(w io.Writer) {
112 fmt.Fprintf(w, "%s\n", d.Org)
113 for _, m := range ms {
114 fmt.Fprintf(w, " %s\t%s\n", m.User, m.Role)
115 }
116 })
117}
118
119func runOrgDelete(c *Ctx, args []string) int {
120 var name string
121 yes := false
122 for _, a := range args {
123 if a == "--yes" {
124 yes = true
125 } else if name == "" {
126 name = a
127 } else {
128 return c.fail(protocol.ExitUsage, "usage: org delete <name> --yes")
129 }
130 }
131 if name == "" {
132 return c.fail(protocol.ExitUsage, "usage: org delete <name> --yes")
133 }
134 org, code := orgAdmin(c, name)
135 if code >= 0 {
136 return code
137 }
138 if !yes {
139 return c.fail(protocol.ExitUsage, "org delete is permanent; re-run with --yes")
140 }
141 if err := c.Store.DeleteOrg(org.ID); err != nil {
142 return c.fail(protocol.ExitFailure, "%v", err)
143 }
144 return c.emit(map[string]string{"deleted": name}, func(w io.Writer) {
145 fmt.Fprintf(w, "deleted organization %s\n", name)
146 })
147}
148
149func runOrgMembersAdd(c *Ctx, args []string) int {
150 role := "member"
151 var rest []string
152 for i := 0; i < len(args); i++ {
153 if args[i] == "--role" {
154 if i+1 >= len(args) {
155 return c.fail(protocol.ExitUsage, "--role requires member|admin")
156 }
157 role = args[i+1]
158 i++
159 continue
160 }
161 rest = append(rest, args[i])
162 }
163 if len(rest) != 2 || (role != "member" && role != "admin") {
164 return c.fail(protocol.ExitUsage, "usage: org members add <org> <user> [--role member|admin]")
165 }
166 org, code := orgAdmin(c, rest[0])
167 if code >= 0 {
168 return code
169 }
170 target, err := c.Store.UserByUsername(rest[1])
171 if errors.Is(err, store.ErrNotFound) {
172 return c.fail(protocol.ExitNotFound, "no such user %q", rest[1])
173 }
174 if err != nil {
175 return c.fail(protocol.ExitFailure, "%v", err)
176 }
177 if err := c.Store.SetOrgMember(org.ID, target.ID, role); err != nil {
178 return c.fail(protocol.ExitUsage, "%v", err)
179 }
180 return c.emit(map[string]string{"org": org.Name, "user": target.Username, "role": role}, func(w io.Writer) {
181 fmt.Fprintf(w, "%s is now a %s of %s\n", target.Username, role, org.Name)
182 })
183}
184
185func runOrgMembersRemove(c *Ctx, args []string) int {
186 if len(args) != 2 {
187 return c.fail(protocol.ExitUsage, "usage: org members remove <org> <user>")
188 }
189 org, code := orgAdmin(c, args[0])
190 if code >= 0 {
191 return code
192 }
193 target, err := c.Store.UserByUsername(args[1])
194 if errors.Is(err, store.ErrNotFound) {
195 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
196 }
197 if err != nil {
198 return c.fail(protocol.ExitFailure, "%v", err)
199 }
200 if err := c.Store.RemoveOrgMember(org.ID, target.ID); err != nil {
201 if errors.Is(err, store.ErrNotFound) {
202 return c.fail(protocol.ExitNotFound, "%s is not a member of %s", target.Username, org.Name)
203 }
204 return c.fail(protocol.ExitUsage, "%v", err)
205 }
206 return c.emit(map[string]string{"org": org.Name, "removed": target.Username}, func(w io.Writer) {
207 fmt.Fprintf(w, "removed %s from %s\n", target.Username, org.Name)
208 })
209}
210
211func runOrgMembersList(c *Ctx, args []string) int {
212 if len(args) != 1 {
213 return c.fail(protocol.ExitUsage, "usage: org members list <org>")
214 }
215 return runOrgShow(c, args)
216}
internal/control/repo.go +16 −4
@@ -90,13 +90,25 @@ func runRepoCreate(c *Ctx, args []string) int {
90 if !ok { 90 if !ok {
91 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]") 91 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
92 } 92 }
93 if owner != c.User.Username {
94 return c.fail(protocol.ExitDenied, "cannot create repositories under %q (orgs not yet supported)", owner)
95 }
96 if err := policyValidateRepoName(name); err != nil { 93 if err := policyValidateRepoName(name); err != nil {
97 return c.fail(protocol.ExitUsage, "%v", err) 94 return c.fail(protocol.ExitUsage, "%v", err)
98 } 95 }
99 id, err := c.Store.CreateRepo("user", c.User.ID, name, visibility) 96 ownerKind, ownerID := "user", c.User.ID
97 if owner != c.User.Username {
98 org, err := c.Store.OrgByName(owner)
99 if err != nil {
100 return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
101 }
102 role, err := c.Store.OrgRole(org.ID, c.User.ID)
103 if err != nil {
104 return c.fail(protocol.ExitFailure, "%v", err)
105 }
106 if role != "admin" {
107 return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
108 }
109 ownerKind, ownerID = "org", org.ID
110 }
111 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
100 if err != nil { 112 if err != nil {
101 return c.fail(protocol.ExitFailure, "%v", err) 113 return c.fail(protocol.ExitFailure, "%v", err)
102 } 114 }
internal/store/orgs.go added +192
@@ -0,0 +1,192 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7)
8
9type Org struct {
10 ID int64
11 Name string
12}
13
14type OrgMember struct {
15 Username string
16 Role string // member | admin
17}
18
19// ownerNameTaken reports whether name is claimed by any user or org. Users
20// and orgs share one namespace: /<owner>/<repo> must be unambiguous.
21func ownerNameTaken(q interface {
22 QueryRow(string, ...any) *sql.Row
23}, name string) (bool, error) {
24 var n int
25 err := q.QueryRow(
26 "SELECT (SELECT COUNT(*) FROM users WHERE username = ?) + (SELECT COUNT(*) FROM orgs WHERE name = ?)",
27 name, name).Scan(&n)
28 return n > 0, err
29}
30
31// CreateOrg makes an organization with the creator as its first admin.
32func (s *Store) CreateOrg(name string, creatorID int64) (int64, error) {
33 tx, err := s.DB.Begin()
34 if err != nil {
35 return 0, err
36 }
37 defer tx.Rollback()
38 taken, err := ownerNameTaken(tx, name)
39 if err != nil {
40 return 0, err
41 }
42 if taken {
43 return 0, fmt.Errorf("the name %q is taken", name)
44 }
45 res, err := tx.Exec("INSERT INTO orgs (name) VALUES (?)", name)
46 if err != nil {
47 return 0, err
48 }
49 id, err := res.LastInsertId()
50 if err != nil {
51 return 0, err
52 }
53 if _, err := tx.Exec(
54 "INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, 'admin')", id, creatorID); err != nil {
55 return 0, err
56 }
57 return id, tx.Commit()
58}
59
60func (s *Store) OrgByName(name string) (Org, error) {
61 var o Org
62 err := s.DB.QueryRow("SELECT id, name FROM orgs WHERE name = ?", name).Scan(&o.ID, &o.Name)
63 if errors.Is(err, sql.ErrNoRows) {
64 return o, ErrNotFound
65 }
66 return o, err
67}
68
69// OrgRole returns the user's role in the org ("" for non-members).
70func (s *Store) OrgRole(orgID, userID int64) (string, error) {
71 var role string
72 err := s.DB.QueryRow(
73 "SELECT role FROM org_members WHERE org_id = ? AND user_id = ?", orgID, userID).Scan(&role)
74 if errors.Is(err, sql.ErrNoRows) {
75 return "", nil
76 }
77 return role, err
78}
79
80func (s *Store) OrgMembers(orgID int64) ([]OrgMember, error) {
81 rows, err := s.DB.Query(`
82 SELECT u.username, m.role FROM org_members m
83 JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY u.username`, orgID)
84 if err != nil {
85 return nil, err
86 }
87 defer rows.Close()
88 var out []OrgMember
89 for rows.Next() {
90 var m OrgMember
91 if err := rows.Scan(&m.Username, &m.Role); err != nil {
92 return nil, err
93 }
94 out = append(out, m)
95 }
96 return out, rows.Err()
97}
98
99// ListOrgsForUser returns the orgs the user belongs to, with their role.
100func (s *Store) ListOrgsForUser(userID int64) ([]OrgMember, error) {
101 rows, err := s.DB.Query(`
102 SELECT o.name, m.role FROM org_members m
103 JOIN orgs o ON o.id = m.org_id WHERE m.user_id = ? ORDER BY o.name`, userID)
104 if err != nil {
105 return nil, err
106 }
107 defer rows.Close()
108 var out []OrgMember
109 for rows.Next() {
110 var m OrgMember
111 if err := rows.Scan(&m.Username, &m.Role); err != nil {
112 return nil, err
113 }
114 out = append(out, m)
115 }
116 return out, rows.Err()
117}
118
119// SetOrgMember adds a member or updates their role. Demoting the last admin
120// is refused: an org must always have one.
121func (s *Store) SetOrgMember(orgID, userID int64, role string) error {
122 tx, err := s.DB.Begin()
123 if err != nil {
124 return err
125 }
126 defer tx.Rollback()
127 if role == "member" {
128 ok, err := wouldKeepAdmin(tx, orgID, userID)
129 if err != nil {
130 return err
131 }
132 if !ok {
133 return errors.New("an organization needs at least one admin")
134 }
135 }
136 if _, err := tx.Exec(`
137 INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, ?)
138 ON CONFLICT (org_id, user_id) DO UPDATE SET role = excluded.role`,
139 orgID, userID, role); err != nil {
140 return err
141 }
142 return tx.Commit()
143}
144
145// RemoveOrgMember drops a member, refusing to remove the last admin.
146func (s *Store) RemoveOrgMember(orgID, userID int64) error {
147 tx, err := s.DB.Begin()
148 if err != nil {
149 return err
150 }
151 defer tx.Rollback()
152 ok, err := wouldKeepAdmin(tx, orgID, userID)
153 if err != nil {
154 return err
155 }
156 if !ok {
157 return errors.New("an organization needs at least one admin")
158 }
159 res, err := tx.Exec("DELETE FROM org_members WHERE org_id = ? AND user_id = ?", orgID, userID)
160 if err != nil {
161 return err
162 }
163 if n, _ := res.RowsAffected(); n == 0 {
164 return ErrNotFound
165 }
166 return tx.Commit()
167}
168
169// wouldKeepAdmin reports whether the org keeps at least one admin after
170// userID stops being one.
171func wouldKeepAdmin(tx *sql.Tx, orgID, userID int64) (bool, error) {
172 var n int
173 err := tx.QueryRow(
174 "SELECT COUNT(*) FROM org_members WHERE org_id = ? AND role = 'admin' AND user_id <> ?",
175 orgID, userID).Scan(&n)
176 return n > 0, err
177}
178
179// DeleteOrg removes an empty organization; orgs still owning repositories
180// are refused.
181func (s *Store) DeleteOrg(orgID int64) error {
182 var n int
183 if err := s.DB.QueryRow(
184 "SELECT COUNT(*) FROM repos WHERE owner_kind = 'org' AND owner_id = ?", orgID).Scan(&n); err != nil {
185 return err
186 }
187 if n > 0 {
188 return fmt.Errorf("the organization still owns %d repositories; delete or transfer them first", n)
189 }
190 _, err := s.DB.Exec("DELETE FROM orgs WHERE id = ?", orgID)
191 return err
192}
internal/store/registration.go +5
@@ -56,6 +56,11 @@ func (s *Store) ConsumeEmailToken(userID int64, tokenHash string) (string, error
56// CreateRegisteredUser makes a self-registered account, pending until its 56// CreateRegisteredUser makes a self-registered account, pending until its
57// email is verified. 57// email is verified.
58func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) { 58func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) {
59 if taken, err := ownerNameTaken(s.DB, username); err != nil {
60 return 0, err
61 } else if taken {
62 return 0, errors.New("that username is taken")
63 }
59 res, err := s.DB.Exec("INSERT INTO users (username, pending) VALUES (?, ?)", username, boolInt(pending)) 64 res, err := s.DB.Exec("INSERT INTO users (username, pending) VALUES (?, ?)", username, boolInt(pending))
60 if err != nil { 65 if err != nil {
61 if isUniqueErr(err) { 66 if isUniqueErr(err) {
internal/store/repos.go +66 −55
@@ -42,29 +42,39 @@ func (s *Store) CreateRepo(ownerKind string, ownerID int64, name, visibility str
42 return res.LastInsertId() 42 return res.LastInsertId()
43} 43}
44 44
45// RepoByPath resolves "owner/name". Only user owners exist until orgs land. 45// repoSelect resolves the owner name from whichever table owns the repo.
46const repoSelect = `
47 SELECT r.id, r.owner_kind, r.owner_id, COALESCE(u.username, o.name),
48 r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json
49 FROM repos r
50 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
51 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id`
52
53func scanRepo(row interface{ Scan(...any) error }) (Repo, error) {
54 var r Repo
55 var settingsJSON string
56 err := row.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON)
57 if err != nil {
58 return r, err
59 }
60 if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
61 return r, fmt.Errorf("repo %d settings: %w", r.ID, err)
62 }
63 return r, nil
64}
65
66// RepoByPath resolves "owner/name"; the owner may be a user or an org.
46func (s *Store) RepoByPath(path string) (Repo, error) { 67func (s *Store) RepoByPath(path string) (Repo, error) {
47 owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/") 68 owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/")
48 if !ok || owner == "" || name == "" || strings.Contains(name, "/") { 69 if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
49 return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound) 70 return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound)
50 } 71 }
51 var r Repo 72 r, err := scanRepo(s.DB.QueryRow(
52 var settingsJSON string 73 repoSelect+" WHERE COALESCE(u.username, o.name) = ? AND r.name = ?", owner, name))
53 err := s.DB.QueryRow(`
54 SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json
55 FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
56 WHERE u.username = ? AND r.name = ?`, owner, name).
57 Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON)
58 if errors.Is(err, sql.ErrNoRows) { 74 if errors.Is(err, sql.ErrNoRows) {
59 return Repo{}, ErrNotFound 75 return Repo{}, ErrNotFound
60 } 76 }
61 if err != nil { 77 return r, err
62 return Repo{}, err
63 }
64 if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
65 return Repo{}, fmt.Errorf("repo %d settings: %w", r.ID, err)
66 }
67 return r, nil
68} 78}
69 79
70func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error { 80func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error {
@@ -92,27 +102,23 @@ func (s *Store) DeleteRepo(repoID int64) error {
92 return nil 102 return nil
93} 103}
94 104
95// ListReposForUser returns repos the user owns or has an explicit grant on. 105// ListReposForUser returns repos the user owns, belongs to through an org,
106// or has an explicit grant on.
96func (s *Store) ListReposForUser(userID int64) ([]Repo, error) { 107func (s *Store) ListReposForUser(userID int64) ([]Repo, error) {
97 rows, err := s.DB.Query(` 108 rows, err := s.DB.Query(repoSelect+`
98 SELECT DISTINCT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
99 FROM repos r
100 JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
101 LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ? 109 LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?
102 WHERE r.owner_id = ? OR a.subject_id IS NOT NULL 110 LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
103 ORDER BY u.username, r.name`, userID, userID) 111 WHERE (r.owner_kind = 'user' AND r.owner_id = ?) OR a.subject_id IS NOT NULL OR m.user_id IS NOT NULL
112 GROUP BY r.id
113 ORDER BY 4, r.name`, userID, userID, userID)
104 if err != nil { 114 if err != nil {
105 return nil, err 115 return nil, err
106 } 116 }
107 defer rows.Close() 117 defer rows.Close()
108 var out []Repo 118 var out []Repo
109 for rows.Next() { 119 for rows.Next() {
110 var r Repo 120 r, err := scanRepo(rows)
111 var settingsJSON string 121 if err != nil {
112 if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil {
113 return nil, err
114 }
115 if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
116 return nil, err 122 return nil, err
117 } 123 }
118 out = append(out, r) 124 out = append(out, r)
@@ -120,16 +126,37 @@ func (s *Store) ListReposForUser(userID int64) ([]Repo, error) {
120 return out, rows.Err() 126 return out, rows.Err()
121} 127}
122 128
123// AccessRole returns the explicit grant for userID on repoID ("" if none). 129// AccessRole returns the user's effective role on the repo ("" if none):
130// the strongest of any explicit grant and, for org-owned repos, the role
131// derived from org membership (org admin -> admin, org member -> write).
124func (s *Store) AccessRole(repoID, userID int64) (string, error) { 132func (s *Store) AccessRole(repoID, userID int64) (string, error) {
125 var role string 133 rank := map[string]int{"": 0, "read": 1, "write": 2, "admin": 3}
134 best := ""
135
136 var explicit string
126 err := s.DB.QueryRow( 137 err := s.DB.QueryRow(
127 "SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?", 138 "SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
128 repoID, userID).Scan(&role) 139 repoID, userID).Scan(&explicit)
129 if errors.Is(err, sql.ErrNoRows) { 140 if err != nil && !errors.Is(err, sql.ErrNoRows) {
130 return "", nil 141 return "", err
142 }
143 if rank[explicit] > rank[best] {
144 best = explicit
145 }
146
147 var orgRole string
148 err = s.DB.QueryRow(`
149 SELECT m.role FROM repos r
150 JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
151 WHERE r.id = ?`, userID, repoID).Scan(&orgRole)
152 if err != nil && !errors.Is(err, sql.ErrNoRows) {
153 return "", err
154 }
155 derived := map[string]string{"admin": "admin", "member": "write"}[orgRole]
156 if rank[derived] > rank[best] {
157 best = derived
131 } 158 }
132 return role, err 159 return best, nil
133} 160}
134 161
135func (s *Store) GrantAccess(repoID, userID int64, role string) error { 162func (s *Store) GrantAccess(repoID, userID int64, role string) error {
@@ -179,40 +206,24 @@ func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) {
179} 206}
180 207
181func (s *Store) RepoByID(id int64) (Repo, error) { 208func (s *Store) RepoByID(id int64) (Repo, error) {
182 var r Repo 209 r, err := scanRepo(s.DB.QueryRow(repoSelect+" WHERE r.id = ?", id))
183 var settingsJSON string
184 err := s.DB.QueryRow(`
185 SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json
186 FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
187 WHERE r.id = ?`, id).
188 Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON)
189 if errors.Is(err, sql.ErrNoRows) { 210 if errors.Is(err, sql.ErrNoRows) {
190 return Repo{}, ErrNotFound 211 return Repo{}, ErrNotFound
191 } 212 }
192 if err != nil { 213 return r, err
193 return Repo{}, err
194 }
195 if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
196 return Repo{}, err
197 }
198 return r, nil
199} 214}
200 215
201// ListPublicRepos returns all public repositories, for the anonymous index. 216// ListPublicRepos returns all public repositories, for the anonymous index.
202func (s *Store) ListPublicRepos() ([]Repo, error) { 217func (s *Store) ListPublicRepos() ([]Repo, error) {
203 rows, err := s.DB.Query(` 218 rows, err := s.DB.Query(repoSelect + " WHERE r.visibility = 'public' ORDER BY 4, r.name")
204 SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
205 FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
206 WHERE r.visibility = 'public' ORDER BY u.username, r.name`)
207 if err != nil { 219 if err != nil {
208 return nil, err 220 return nil, err
209 } 221 }
210 defer rows.Close() 222 defer rows.Close()
211 var out []Repo 223 var out []Repo
212 for rows.Next() { 224 for rows.Next() {
213 var r Repo 225 r, err := scanRepo(rows)
214 var settingsJSON string 226 if err != nil {
215 if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil {
216 return nil, err 227 return nil, err
217 } 228 }
218 out = append(out, r) 229 out = append(out, r)
internal/store/users.go +5
@@ -30,6 +30,11 @@ var ErrDuplicateKey = errors.New("that key is already registered to another acco
30var ErrNotFound = errors.New("not found") 30var ErrNotFound = errors.New("not found")
31 31
32func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) { 32func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) {
33 if taken, err := ownerNameTaken(s.DB, username); err != nil {
34 return 0, err
35 } else if taken {
36 return 0, fmt.Errorf("username %q is taken", username)
37 }
33 res, err := s.DB.Exec("INSERT INTO users (username, is_admin) VALUES (?, ?)", username, boolInt(isAdmin)) 38 res, err := s.DB.Exec("INSERT INTO users (username, is_admin) VALUES (?, ?)", username, boolInt(isAdmin))
34 if err != nil { 39 if err != nil {
35 if isUniqueErr(err) { 40 if isUniqueErr(err) {