A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 6e33f61b02

6e33f61b02ed6ea71578f30a0516582e8e69d07a

parent: 9b6ae6d866

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-25T01:10:20Z

Add teams within organizations

Closes #21

Teams scope repository access inside an org (migration 0020). The old
model survives as the degenerate case: plain membership implies the
org's members_role — write by default, settable to read or none via
org settings members-role. Named teams hold org members and per-repo
read/write/admin grants on the org's own repositories; effective
access is the max of explicit grants, the membership-derived role (org
admins always admin), and team grants. Listings and dashboard
involvement follow the same rules, so a scoped-out member no longer
sees private org repos they cannot read. Full team CRUD over SSH with
CLI passthrough; teams never grant org membership by side effect.
cmd/gitbay/main.go +13
@@ -406,6 +406,19 @@ func orgCmd() *cobra.Command {
406406 pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}),
407407 pass("list", "list members: <org>", passOpts{server: []string{"org", "members", "list"}}),
408408 ),
409 group("team", "scope repository access with teams",
410 pass("create", "create a team: <org> <team>", passOpts{server: []string{"org", "team", "create"}}),
411 pass("delete", "delete a team: <org> <team>", passOpts{server: []string{"org", "team", "delete"}}),
412 pass("list", "list teams: <org>", passOpts{server: []string{"org", "team", "list"}}),
413 pass("show", "show members and grants: <org> <team>", passOpts{server: []string{"org", "team", "show"}}),
414 pass("add", "add org members: <org> <team> <user>...", passOpts{server: []string{"org", "team", "add"}}),
415 pass("remove", "remove members: <org> <team> <user>...", passOpts{server: []string{"org", "team", "remove"}}),
416 pass("grant", "grant a repo role: <org> <team> <owner/name> read|write|admin", passOpts{server: []string{"org", "team", "grant"}}),
417 pass("revoke", "revoke a repo grant: <org> <team> <owner/name>", passOpts{server: []string{"org", "team", "revoke"}}),
418 ),
419 group("settings", "organization settings",
420 pass("members-role", "role plain membership implies: <org> write|read|none", passOpts{server: []string{"org", "settings", "members-role"}}),
421 ),
409422 )
410423 }
411424
docs/users.org +15 −2
@@ -159,8 +159,8 @@ gitbay repo mirror sync / remove <id>
159159 * Organizations
160160
161161 Orgs share the owner namespace with users and own repositories at
162=org/repo=. Members get write on all org repos; org admins get repo
163admin, create repos under the org, and manage membership.
162=org/repo=. By default members get write on all org repos; org admins
163get repo admin, create repos under the org, and manage membership.
164164
165165 #+begin_src sh
166166 gitbay org create krz
@@ -170,6 +170,19 @@ gitbay org rename krz newname # clone URLs change
170170 gitbay org delete krz --yes # only when it owns no repositories
171171 #+end_src
172172
173Large orgs scope access with teams: set what plain membership implies,
174then grant per-repo roles through named teams (org admins always keep
175admin; the default =write= keeps the simple model):
176
177#+begin_src sh
178gitbay org settings members-role krz none # write | read | none
179gitbay org team create krz core-devs
180gitbay org team add krz core-devs alice bob # org members only
181gitbay org team grant krz core-devs krz/gitbay write
182gitbay org team show krz core-devs # members + grants
183gitbay org team revoke / remove / delete ...
184#+end_src
185
173186 * Issues
174187
175188 Anyone who can read a repository can file and comment. Closing/reopening
e2e/teams_test.go added +119
@@ -0,0 +1,119 @@
1package e2e
2
3import (
4 "strings"
5 "testing"
6)
7
8func TestOrgTeams(t *testing.T) {
9 inst := startInstance(t)
10 adminKey := inst.newKey(t, "alice")
11 bobKey := inst.newKey(t, "bob")
12 carolKey := inst.newKey(t, "carol")
13 eveKey := inst.newKey(t, "eve")
14 inst.admin(t, "admin", "user", "create", "alice", "--key", adminKey+".pub")
15 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
16 inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub")
17 inst.admin(t, "admin", "user", "create", "eve", "--key", eveKey+".pub")
18
19 // Org with two private repos; bob and carol are plain members.
20 for _, args := range [][]string{
21 {"org", "create", "acme"},
22 {"org", "members", "add", "acme", "bob"},
23 {"org", "members", "add", "acme", "carol"},
24 {"repo", "create", "acme/core", "--private"},
25 {"repo", "create", "acme/site", "--private"},
26 } {
27 if _, errOut, code := inst.ssh(t, adminKey, "", args...); code != 0 {
28 t.Fatalf("%v: %s", args, errOut)
29 }
30 }
31
32 // Degenerate case: plain membership implies write everywhere.
33 if _, _, code := inst.ssh(t, bobKey, "", "issue", "create", "acme/core", "--title", "'pre'"); code != 0 {
34 t.Fatal("member write lost (degenerate case broken)")
35 }
36
37 // Scope the org: members get nothing by default, teams grant.
38 if _, _, code := inst.ssh(t, bobKey, "", "org", "settings", "members-role", "acme", "none"); code != 4 {
39 t.Fatal("non-admin changed members-role")
40 }
41 if _, _, code := inst.ssh(t, adminKey, "", "org", "settings", "members-role", "acme", "none"); code != 0 {
42 t.Fatal("members-role failed")
43 }
44 // bob now cannot even see the private repo.
45 if _, _, code := inst.ssh(t, bobKey, "", "repo", "show", "acme/core"); code != 3 {
46 t.Fatal("scoped member still sees private repo")
47 }
48
49 // Team "core-devs": bob gets write on core, read on site.
50 if _, _, code := inst.ssh(t, adminKey, "", "org", "team", "create", "acme", "core-devs"); code != 0 {
51 t.Fatal("team create failed")
52 }
53 if _, errOut, code := inst.ssh(t, adminKey, "", "org", "team", "add", "acme", "core-devs", "eve"); code != 2 || !strings.Contains(errOut, "not a member") {
54 t.Fatalf("non-member added to team: %d %s", code, errOut)
55 }
56 if _, _, code := inst.ssh(t, adminKey, "", "org", "team", "add", "acme", "core-devs", "bob"); code != 0 {
57 t.Fatal("team add failed")
58 }
59 if _, _, code := inst.ssh(t, adminKey, "", "org", "team", "grant", "acme", "core-devs", "acme/core", "write"); code != 0 {
60 t.Fatal("team grant failed")
61 }
62 if _, _, code := inst.ssh(t, adminKey, "", "org", "team", "grant", "acme", "core-devs", "acme/site", "read"); code != 0 {
63 t.Fatal("second grant failed")
64 }
65 // Grants are limited to the org's own repos.
66 if _, _, code := inst.ssh(t, adminKey, "", "repo", "create", "alice/own"); code != 0 {
67 t.Fatal("repo create failed")
68 }
69 if _, errOut, code := inst.ssh(t, adminKey, "", "org", "team", "grant", "acme", "core-devs", "alice/own", "read"); code != 2 || !strings.Contains(errOut, "own org") {
70 t.Fatalf("cross-org grant allowed: %d %s", code, errOut)
71 }
72
73 // bob: write on core (can open issues), read-only on site (visible,
74 // not writable). carol (no team): nothing.
75 if _, _, code := inst.ssh(t, bobKey, "", "issue", "create", "acme/core", "--title", "'works'"); code != 0 {
76 t.Fatal("team write not effective")
77 }
78 if _, _, code := inst.ssh(t, bobKey, "", "repo", "show", "acme/site"); code != 0 {
79 t.Fatal("team read not effective")
80 }
81 if _, _, code := inst.ssh(t, bobKey, "", "repo", "settings", "protect", "acme/site", "main"); code != 4 {
82 t.Fatal("read grant allowed admin action")
83 }
84 if _, _, code := inst.ssh(t, carolKey, "", "repo", "show", "acme/core"); code != 3 {
85 t.Fatal("teamless member sees scoped repo")
86 }
87 out, _, _ := inst.ssh(t, bobKey, "", "repo", "list")
88 if !strings.Contains(out, "acme/core") || !strings.Contains(out, "acme/site") {
89 t.Fatalf("team repos missing from listing: %s", out)
90 }
91
92 // show reflects members and grants; member removal drops access.
93 out, _, _ = inst.ssh(t, adminKey, "", "org", "team", "show", "acme", "core-devs", "--json")
94 if !strings.Contains(out, `"members":["bob"]`) || !strings.Contains(out, `"repo":"acme/core","role":"write"`) {
95 t.Fatalf("team show: %s", out)
96 }
97 if _, _, code := inst.ssh(t, adminKey, "", "org", "team", "remove", "acme", "core-devs", "bob"); code != 0 {
98 t.Fatal("team remove failed")
99 }
100 if _, _, code := inst.ssh(t, bobKey, "", "repo", "show", "acme/core"); code != 3 {
101 t.Fatal("removed member kept access")
102 }
103
104 // Deleting the team cascades its grants.
105 inst.ssh(t, adminKey, "", "org", "team", "add", "acme", "core-devs", "carol")
106 if _, _, code := inst.ssh(t, carolKey, "", "repo", "show", "acme/core"); code != 0 {
107 t.Fatal("carol team access missing")
108 }
109 if _, _, code := inst.ssh(t, adminKey, "", "org", "team", "delete", "acme", "core-devs"); code != 0 {
110 t.Fatal("team delete failed")
111 }
112 if _, _, code := inst.ssh(t, carolKey, "", "repo", "show", "acme/core"); code != 3 {
113 t.Fatal("deleted team's grant survived")
114 }
115 // Org admins keep admin regardless of scoping.
116 if _, _, code := inst.ssh(t, adminKey, "", "repo", "settings", "protect", "acme/core", "main"); code != 0 {
117 t.Fatal("org admin lost access")
118 }
119}
internal/control/teams.go added +288
@@ -0,0 +1,288 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "slices"
8 "strings"
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{"org", "team", "create"},
17 Summary: "create a team: org team create <org> <team>", Run: runTeamCreate})
18 register(Command{Path: []string{"org", "team", "delete"},
19 Summary: "delete a team (its grants with it): org team delete <org> <team>", Run: runTeamDelete})
20 register(Command{Path: []string{"org", "team", "list"},
21 Summary: "list an org's teams: org team list <org>", ReadOnly: true, Run: runTeamList})
22 register(Command{Path: []string{"org", "team", "show"},
23 Summary: "show a team's members and grants: org team show <org> <team>", ReadOnly: true, Run: runTeamShow})
24 register(Command{Path: []string{"org", "team", "add"},
25 Summary: "add org members to a team: org team add <org> <team> <user>...", Run: runTeamAdd})
26 register(Command{Path: []string{"org", "team", "remove"},
27 Summary: "remove members from a team: org team remove <org> <team> <user>...", Run: runTeamRemove})
28 register(Command{Path: []string{"org", "team", "grant"},
29 Summary: "grant a team a role on an org repo: org team grant <org> <team> <owner/name> read|write|admin", Run: runTeamGrant})
30 register(Command{Path: []string{"org", "team", "revoke"},
31 Summary: "revoke a team's grant: org team revoke <org> <team> <owner/name>", Run: runTeamRevoke})
32 register(Command{Path: []string{"org", "settings", "members-role"},
33 Summary: "role plain membership implies on every org repo: org settings members-role <org> write|read|none (default write)", Run: runOrgMembersRole})
34}
35
36// orgAdminRef resolves an org and requires the caller to admin it.
37func orgAdminRef(c *Ctx, name string) (store.Org, int) {
38 org, err := c.Store.OrgByName(name)
39 if err != nil {
40 return org, c.fail(protocol.ExitNotFound, "no organization %q", name)
41 }
42 role, err := c.Store.OrgRole(org.ID, c.User.ID)
43 if err != nil {
44 return org, c.fail(protocol.ExitFailure, "%v", err)
45 }
46 if role != "admin" {
47 return org, c.fail(protocol.ExitDenied, "only admins of %s can manage teams", name)
48 }
49 return org, -1
50}
51
52// orgMemberRef resolves an org, requiring at least membership.
53func orgMemberRef(c *Ctx, name string) (store.Org, int) {
54 org, err := c.Store.OrgByName(name)
55 if err != nil {
56 return org, c.fail(protocol.ExitNotFound, "no organization %q", name)
57 }
58 role, err := c.Store.OrgRole(org.ID, c.User.ID)
59 if err != nil {
60 return org, c.fail(protocol.ExitFailure, "%v", err)
61 }
62 if role == "" {
63 return org, c.fail(protocol.ExitNotFound, "no organization %q", name)
64 }
65 return org, -1
66}
67
68func teamRef(c *Ctx, org store.Org, name string) (store.Team, int) {
69 team, err := c.Store.TeamByName(org.ID, name)
70 if errors.Is(err, store.ErrNotFound) {
71 return team, c.fail(protocol.ExitNotFound, "no team %q in %s", name, org.Name)
72 }
73 if err != nil {
74 return team, c.fail(protocol.ExitFailure, "%v", err)
75 }
76 return team, -1
77}
78
79func runTeamCreate(c *Ctx, args []string) int {
80 if len(args) != 2 {
81 return c.fail(protocol.ExitUsage, "usage: org team create <org> <team>")
82 }
83 org, code := orgAdminRef(c, args[0])
84 if code >= 0 {
85 return code
86 }
87 if err := policy.ValidateName(args[1]); err != nil {
88 return c.fail(protocol.ExitUsage, "%v", err)
89 }
90 if _, err := c.Store.CreateTeam(org.ID, args[1]); err != nil {
91 return c.fail(protocol.ExitUsage, "%v", err)
92 }
93 return c.emit(map[string]string{"team": args[1]}, func(w io.Writer) {
94 fmt.Fprintf(w, "created team %s/%s\n", org.Name, args[1])
95 })
96}
97
98func runTeamDelete(c *Ctx, args []string) int {
99 if len(args) != 2 {
100 return c.fail(protocol.ExitUsage, "usage: org team delete <org> <team>")
101 }
102 org, code := orgAdminRef(c, args[0])
103 if code >= 0 {
104 return code
105 }
106 team, code := teamRef(c, org, args[1])
107 if code >= 0 {
108 return code
109 }
110 if err := c.Store.DeleteTeam(team.ID); err != nil {
111 return c.fail(protocol.ExitFailure, "%v", err)
112 }
113 return c.emit(map[string]string{"deleted": team.Name}, func(w io.Writer) {
114 fmt.Fprintf(w, "deleted team %s/%s\n", org.Name, team.Name)
115 })
116}
117
118func runTeamList(c *Ctx, args []string) int {
119 if len(args) != 1 {
120 return c.fail(protocol.ExitUsage, "usage: org team list <org>")
121 }
122 org, code := orgMemberRef(c, args[0])
123 if code >= 0 {
124 return code
125 }
126 teams, err := c.Store.ListTeams(org.ID)
127 if err != nil {
128 return c.fail(protocol.ExitFailure, "%v", err)
129 }
130 var names []string
131 for _, t := range teams {
132 names = append(names, t.Name)
133 }
134 return c.emit(names, func(w io.Writer) {
135 for _, n := range names {
136 fmt.Fprintln(w, n)
137 }
138 })
139}
140
141func runTeamShow(c *Ctx, args []string) int {
142 if len(args) != 2 {
143 return c.fail(protocol.ExitUsage, "usage: org team show <org> <team>")
144 }
145 org, code := orgMemberRef(c, args[0])
146 if code >= 0 {
147 return code
148 }
149 team, code := teamRef(c, org, args[1])
150 if code >= 0 {
151 return code
152 }
153 members, err := c.Store.TeamMembers(team.ID)
154 if err != nil {
155 return c.fail(protocol.ExitFailure, "%v", err)
156 }
157 grants, err := c.Store.TeamGrants(team.ID)
158 if err != nil {
159 return c.fail(protocol.ExitFailure, "%v", err)
160 }
161 d := struct {
162 Team string `json:"team"`
163 Members []string `json:"members,omitempty"`
164 Grants []store.TeamGrant `json:"grants,omitempty"`
165 }{team.Name, members, grants}
166 return c.emit(d, func(w io.Writer) {
167 fmt.Fprintf(w, "%s/%s\nmembers: %s\n", org.Name, team.Name, strings.Join(members, ", "))
168 for _, g := range grants {
169 fmt.Fprintf(w, "%s\t%s\n", g.RepoPath, g.Role)
170 }
171 })
172}
173
174func runTeamAdd(c *Ctx, args []string) int { return editTeamMembers(c, args, true) }
175func runTeamRemove(c *Ctx, args []string) int { return editTeamMembers(c, args, false) }
176
177func editTeamMembers(c *Ctx, args []string, add bool) int {
178 verb := "add"
179 if !add {
180 verb = "remove"
181 }
182 if len(args) < 3 {
183 return c.fail(protocol.ExitUsage, "usage: org team %s <org> <team> <user>...", verb)
184 }
185 org, code := orgAdminRef(c, args[0])
186 if code >= 0 {
187 return code
188 }
189 team, code := teamRef(c, org, args[1])
190 if code >= 0 {
191 return code
192 }
193 for _, name := range args[2:] {
194 u, err := c.Store.UserByUsername(name)
195 if err != nil {
196 return c.fail(protocol.ExitNotFound, "no such user %q", name)
197 }
198 if add {
199 // Teams organize existing members; they do not grant
200 // membership by side effect.
201 role, err := c.Store.OrgRole(org.ID, u.ID)
202 if err != nil {
203 return c.fail(protocol.ExitFailure, "%v", err)
204 }
205 if role == "" {
206 return c.fail(protocol.ExitUsage, "%s is not a member of %s — add them to the org first", name, org.Name)
207 }
208 if err := c.Store.AddTeamMember(team.ID, u.ID); err != nil {
209 return c.fail(protocol.ExitFailure, "%v", err)
210 }
211 } else if err := c.Store.RemoveTeamMember(team.ID, u.ID); err != nil {
212 if errors.Is(err, store.ErrNotFound) {
213 return c.fail(protocol.ExitNotFound, "%s is not in team %s", name, team.Name)
214 }
215 return c.fail(protocol.ExitFailure, "%v", err)
216 }
217 }
218 return c.emit(map[string]any{"team": team.Name, verb: args[2:]}, func(w io.Writer) {
219 fmt.Fprintf(w, "%sed %s on team %s/%s\n", verb, strings.Join(args[2:], ", "), org.Name, team.Name)
220 })
221}
222
223func runTeamGrant(c *Ctx, args []string) int {
224 if len(args) != 4 || !slices.Contains([]string{"read", "write", "admin"}, args[3]) {
225 return c.fail(protocol.ExitUsage, "usage: org team grant <org> <team> <owner/name> read|write|admin")
226 }
227 org, code := orgAdminRef(c, args[0])
228 if code >= 0 {
229 return code
230 }
231 team, code := teamRef(c, org, args[1])
232 if code >= 0 {
233 return code
234 }
235 repo, err := c.Store.RepoByPath(args[2])
236 if err != nil || repo.OwnerKind != "org" || repo.OwnerID != org.ID {
237 return c.fail(protocol.ExitUsage, "teams grant access to their own org's repositories; %q is not one", args[2])
238 }
239 if err := c.Store.GrantTeamRepo(team.ID, repo.ID, args[3]); err != nil {
240 return c.fail(protocol.ExitFailure, "%v", err)
241 }
242 return c.emit(map[string]string{"team": team.Name, "repo": repo.Path(), "role": args[3]}, func(w io.Writer) {
243 fmt.Fprintf(w, "granted %s to team %s on %s\n", args[3], team.Name, repo.Path())
244 })
245}
246
247func runTeamRevoke(c *Ctx, args []string) int {
248 if len(args) != 3 {
249 return c.fail(protocol.ExitUsage, "usage: org team revoke <org> <team> <owner/name>")
250 }
251 org, code := orgAdminRef(c, args[0])
252 if code >= 0 {
253 return code
254 }
255 team, code := teamRef(c, org, args[1])
256 if code >= 0 {
257 return code
258 }
259 repo, err := c.Store.RepoByPath(args[2])
260 if err != nil {
261 return c.fail(protocol.ExitNotFound, "repository %s not found", args[2])
262 }
263 if err := c.Store.RevokeTeamRepo(team.ID, repo.ID); err != nil {
264 if errors.Is(err, store.ErrNotFound) {
265 return c.fail(protocol.ExitNotFound, "team %s has no grant on %s", team.Name, repo.Path())
266 }
267 return c.fail(protocol.ExitFailure, "%v", err)
268 }
269 return c.emit(map[string]string{"revoked": repo.Path()}, func(w io.Writer) {
270 fmt.Fprintf(w, "revoked team %s on %s\n", team.Name, repo.Path())
271 })
272}
273
274func runOrgMembersRole(c *Ctx, args []string) int {
275 if len(args) != 2 || !slices.Contains([]string{"write", "read", "none"}, args[1]) {
276 return c.fail(protocol.ExitUsage, "usage: org settings members-role <org> write|read|none")
277 }
278 org, code := orgAdminRef(c, args[0])
279 if code >= 0 {
280 return code
281 }
282 if err := c.Store.SetOrgMembersRole(org.ID, args[1]); err != nil {
283 return c.fail(protocol.ExitFailure, "%v", err)
284 }
285 return c.emit(map[string]string{"members_role": args[1]}, func(w io.Writer) {
286 fmt.Fprintf(w, "plain members of %s now get %q on org repositories\n", org.Name, args[1])
287 })
288}
internal/store/dashboard.go +6 −1
@@ -18,7 +18,12 @@ const involvedCond = `(
1818 OR EXISTS (SELECT 1 FROM repo_access a
1919 WHERE a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?1)
2020 OR EXISTS (SELECT 1 FROM org_members mm
21 WHERE r.owner_kind = 'org' AND mm.org_id = r.owner_id AND mm.user_id = ?1)
21 JOIN orgs oo ON oo.id = mm.org_id
22 WHERE r.owner_kind = 'org' AND mm.org_id = r.owner_id AND mm.user_id = ?1
23 AND (mm.role = 'admin' OR oo.members_role <> 'none'))
24 OR EXISTS (SELECT 1 FROM team_repos tr
25 JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?1
26 WHERE tr.repo_id = r.id)
2227 )`
2328
2429 func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error) {
internal/store/migrations/0020_teams.down.sql added +4
@@ -0,0 +1,4 @@
1DROP TABLE team_repos;
2DROP TABLE team_members;
3DROP TABLE teams;
4ALTER TABLE orgs DROP COLUMN members_role;
internal/store/migrations/0020_teams.up.sql added +25
@@ -0,0 +1,25 @@
1- Teams scope repository access inside an organization. The pre-teams
2- model (every member writes every org repo) survives as the default via
3- orgs.members_role = 'write'; large orgs set it to 'read' or 'none' and
4- grant through teams instead.
5ALTER TABLE orgs ADD COLUMN members_role TEXT NOT NULL DEFAULT 'write'
6 CHECK (members_role IN ('write', 'read', 'none'));
7
8CREATE TABLE teams (
9 id INTEGER PRIMARY KEY,
10 org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
11 name TEXT NOT NULL,
12 UNIQUE (org_id, name)
13);
14CREATE TABLE team_members (
15 team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
16 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
17 PRIMARY KEY (team_id, user_id)
18);
19CREATE TABLE team_repos (
20 team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
21 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
22 role TEXT NOT NULL CHECK (role IN ('read', 'write', 'admin')),
23 PRIMARY KEY (team_id, repo_id)
24);
25CREATE INDEX team_repos_repo ON team_repos(repo_id);
internal/store/repos.go +42 −16
@@ -106,15 +106,22 @@ func (s *Store) DeleteRepo(repoID int64) error {
106106 return nil
107107 }
108108
109// ListReposForUser returns repos the user owns, belongs to through an org,
110// or has an explicit grant on.
109// ListReposForUser returns repos the user owns, reaches through an org
110// (unless the org scopes members to 'none'), has an explicit grant on, or
111// reaches through a team.
111112 func (s *Store) ListReposForUser(userID int64) ([]Repo, error) {
112113 rows, err := s.DB.Query(repoSelect+`
113114 LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?
114115 LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
115 WHERE (r.owner_kind = 'user' AND r.owner_id = ?) OR a.subject_id IS NOT NULL OR m.user_id IS NOT NULL
116 LEFT JOIN orgs og ON r.owner_kind = 'org' AND og.id = r.owner_id
117 WHERE (r.owner_kind = 'user' AND r.owner_id = ?)
118 OR a.subject_id IS NOT NULL
119 OR (m.user_id IS NOT NULL AND (m.role = 'admin' OR og.members_role <> 'none'))
120 OR EXISTS (SELECT 1 FROM team_repos tr
121 JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?
122 WHERE tr.repo_id = r.id)
116123 GROUP BY r.id
117 ORDER BY 4, r.name`, userID, userID, userID)
124 ORDER BY 4, r.name`, userID, userID, userID, userID)
118125 if err != nil {
119126 return nil, err
120127 }
@@ -131,11 +138,18 @@ func (s *Store) ListReposForUser(userID int64) ([]Repo, error) {
131138 }
132139
133140 // AccessRole returns the user's effective role on the repo ("" if none):
134// the strongest of any explicit grant and, for org-owned repos, the role
135// derived from org membership (org admin -> admin, org member -> write).
141// the strongest of any explicit grant, the role derived from org
142// membership (org admin -> admin; plain member -> the org's members_role,
143// 'write' by default so the pre-teams model is the degenerate case), and
144// any team grants on the repo.
136145 func (s *Store) AccessRole(repoID, userID int64) (string, error) {
137 rank := map[string]int{"": 0, "read": 1, "write": 2, "admin": 3}
146 rank := map[string]int{"": 0, "none": 0, "read": 1, "write": 2, "admin": 3}
138147 best := ""
148 better := func(role string) {
149 if rank[role] > rank[best] {
150 best = role
151 }
152 }
139153
140154 var explicit string
141155 err := s.DB.QueryRow(
@@ -144,22 +158,34 @@ func (s *Store) AccessRole(repoID, userID int64) (string, error) {
144158 if err != nil && !errors.Is(err, sql.ErrNoRows) {
145159 return "", err
146160 }
147 if rank[explicit] > rank[best] {
148 best = explicit
149 }
161 better(explicit)
150162
151 var orgRole string
163 var orgRole, membersRole string
152164 err = s.DB.QueryRow(`
153 SELECT m.role FROM repos r
165 SELECT m.role, o.members_role FROM repos r
154166 JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
155 WHERE r.id = ?`, userID, repoID).Scan(&orgRole)
167 JOIN orgs o ON o.id = r.owner_id
168 WHERE r.id = ?`, userID, repoID).Scan(&orgRole, &membersRole)
156169 if err != nil && !errors.Is(err, sql.ErrNoRows) {
157170 return "", err
158171 }
159 derived := map[string]string{"admin": "admin", "member": "write"}[orgRole]
160 if rank[derived] > rank[best] {
161 best = derived
172 if orgRole == "admin" {
173 better("admin")
174 } else if orgRole == "member" {
175 better(membersRole) // write | read | none
176 }
177
178 var teamRole string
179 err = s.DB.QueryRow(`
180 SELECT tr.role FROM team_repos tr
181 JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?
182 WHERE tr.repo_id = ?
183 ORDER BY CASE tr.role WHEN 'admin' THEN 3 WHEN 'write' THEN 2 ELSE 1 END DESC
184 LIMIT 1`, userID, repoID).Scan(&teamRole)
185 if err != nil && !errors.Is(err, sql.ErrNoRows) {
186 return "", err
162187 }
188 better(teamRole)
163189 return best, nil
164190 }
165191
internal/store/teams.go added +151
@@ -0,0 +1,151 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7)
8
9type Team struct {
10 ID int64
11 OrgID int64
12 Name string
13}
14
15func (s *Store) CreateTeam(orgID int64, name string) (int64, error) {
16 res, err := s.DB.Exec("INSERT INTO teams (org_id, name) VALUES (?, ?)", orgID, name)
17 if err != nil {
18 if isUniqueErr(err) {
19 return 0, fmt.Errorf("team %q already exists", name)
20 }
21 return 0, err
22 }
23 return res.LastInsertId()
24}
25
26func (s *Store) TeamByName(orgID int64, name string) (Team, error) {
27 var t Team
28 err := s.DB.QueryRow("SELECT id, org_id, name FROM teams WHERE org_id = ? AND name = ?",
29 orgID, name).Scan(&t.ID, &t.OrgID, &t.Name)
30 if errors.Is(err, sql.ErrNoRows) {
31 return t, ErrNotFound
32 }
33 return t, err
34}
35
36func (s *Store) DeleteTeam(teamID int64) error {
37 res, err := s.DB.Exec("DELETE FROM teams WHERE id = ?", teamID)
38 if err != nil {
39 return err
40 }
41 if n, _ := res.RowsAffected(); n == 0 {
42 return ErrNotFound
43 }
44 return nil
45}
46
47func (s *Store) ListTeams(orgID int64) ([]Team, error) {
48 rows, err := s.DB.Query("SELECT id, org_id, name FROM teams WHERE org_id = ? ORDER BY name", orgID)
49 if err != nil {
50 return nil, err
51 }
52 defer rows.Close()
53 var out []Team
54 for rows.Next() {
55 var t Team
56 if err := rows.Scan(&t.ID, &t.OrgID, &t.Name); err != nil {
57 return nil, err
58 }
59 out = append(out, t)
60 }
61 return out, rows.Err()
62}
63
64func (s *Store) AddTeamMember(teamID, userID int64) error {
65 _, err := s.DB.Exec(
66 "INSERT INTO team_members (team_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
67 teamID, userID)
68 return err
69}
70
71func (s *Store) RemoveTeamMember(teamID, userID int64) error {
72 res, err := s.DB.Exec("DELETE FROM team_members WHERE team_id = ? AND user_id = ?", teamID, userID)
73 if err != nil {
74 return err
75 }
76 if n, _ := res.RowsAffected(); n == 0 {
77 return ErrNotFound
78 }
79 return nil
80}
81
82func (s *Store) TeamMembers(teamID int64) ([]string, error) {
83 rows, err := s.DB.Query(`
84 SELECT u.username FROM team_members tm JOIN users u ON u.id = tm.user_id
85 WHERE tm.team_id = ? ORDER BY u.username`, teamID)
86 if err != nil {
87 return nil, err
88 }
89 defer rows.Close()
90 var out []string
91 for rows.Next() {
92 var n string
93 if err := rows.Scan(&n); err != nil {
94 return nil, err
95 }
96 out = append(out, n)
97 }
98 return out, rows.Err()
99}
100
101// GrantTeamRepo attaches (or updates) a team's role on a repo.
102func (s *Store) GrantTeamRepo(teamID, repoID int64, role string) error {
103 _, err := s.DB.Exec(`
104 INSERT INTO team_repos (team_id, repo_id, role) VALUES (?, ?, ?)
105 ON CONFLICT (team_id, repo_id) DO UPDATE SET role = excluded.role`,
106 teamID, repoID, role)
107 return err
108}
109
110func (s *Store) RevokeTeamRepo(teamID, repoID int64) error {
111 res, err := s.DB.Exec("DELETE FROM team_repos WHERE team_id = ? AND repo_id = ?", teamID, repoID)
112 if err != nil {
113 return err
114 }
115 if n, _ := res.RowsAffected(); n == 0 {
116 return ErrNotFound
117 }
118 return nil
119}
120
121type TeamGrant struct {
122 RepoPath string `json:"repo"`
123 Role string `json:"role"`
124}
125
126func (s *Store) TeamGrants(teamID int64) ([]TeamGrant, error) {
127 rows, err := s.DB.Query(`
128 SELECT COALESCE(u.username, o.name) || '/' || r.name, tr.role
129 FROM team_repos tr JOIN repos r ON r.id = tr.repo_id
130 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
131 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
132 WHERE tr.team_id = ? ORDER BY 1`, teamID)
133 if err != nil {
134 return nil, err
135 }
136 defer rows.Close()
137 var out []TeamGrant
138 for rows.Next() {
139 var g TeamGrant
140 if err := rows.Scan(&g.RepoPath, &g.Role); err != nil {
141 return nil, err
142 }
143 out = append(out, g)
144 }
145 return out, rows.Err()
146}
147
148func (s *Store) SetOrgMembersRole(orgID int64, role string) error {
149 _, err := s.DB.Exec("UPDATE orgs SET members_role = ? WHERE id = ?", role, orgID)
150 return err
151}