internal/control/teams.go
299 lines · 9321 bytes
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",
18 Usage: "org team create <org> <team>", Run: runTeamCreate})
19 register(Command{Path: []string{"org", "team", "delete"},
20 Summary: "delete a team (its grants with it)",
21 Usage: "org team delete <org> <team>", Run: runTeamDelete})
22 register(Command{Path: []string{"org", "team", "list"},
23 Summary: "list an org's teams",
24 Usage: "org team list <org>", ReadOnly: true, Run: runTeamList})
25 register(Command{Path: []string{"org", "team", "show"},
26 Summary: "show a team's members and grants",
27 Usage: "org team show <org> <team>", ReadOnly: true, Run: runTeamShow})
28 register(Command{Path: []string{"org", "team", "add"},
29 Summary: "add org members to a team",
30 Usage: "org team add <org> <team> <user>...", Run: runTeamAdd})
31 register(Command{Path: []string{"org", "team", "remove"},
32 Summary: "remove members from a team",
33 Usage: "org team remove <org> <team> <user>...", Run: runTeamRemove})
34 register(Command{Path: []string{"org", "team", "grant"},
35 Summary: "grant a team a role on an org repo",
36 Usage: "org team grant <org> <team> <owner/name> read|write|admin", Run: runTeamGrant})
37 register(Command{Path: []string{"org", "team", "revoke"},
38 Summary: "revoke a team's grant",
39 Usage: "org team revoke <org> <team> <owner/name>", Run: runTeamRevoke})
40 register(Command{Path: []string{"org", "settings", "members-role"},
41 Summary: "role plain membership implies on every org repo",
42 Usage: "org settings members-role <org> write|read|none (default write)", Run: runOrgMembersRole})
43}
44
45// orgAdminRef resolves an org and requires the caller to admin it.
46func orgAdminRef(c *Ctx, name string) (store.Org, int) {
47 org, err := c.Store.OrgByName(name)
48 if err != nil {
49 return org, c.fail(protocol.ExitNotFound, "no organization %q", name)
50 }
51 role, err := c.Store.OrgRole(org.ID, c.User.ID)
52 if err != nil {
53 return org, c.fail(protocol.ExitFailure, "%v", err)
54 }
55 if role != "admin" {
56 return org, c.fail(protocol.ExitDenied, "only admins of %s can manage teams", name)
57 }
58 return org, -1
59}
60
61// orgMemberRef resolves an org, requiring at least membership. An org's
62// existence and member list are public (org show answers anyone), so a
63// non-member is refused rather than told the org does not exist.
64func orgMemberRef(c *Ctx, name string) (store.Org, int) {
65 org, err := c.Store.OrgByName(name)
66 if err != nil {
67 return org, c.fail(protocol.ExitNotFound, "no organization %q", name)
68 }
69 role, err := c.Store.OrgRole(org.ID, c.User.ID)
70 if err != nil {
71 return org, c.fail(protocol.ExitFailure, "%v", err)
72 }
73 if role == "" {
74 return org, c.fail(protocol.ExitDenied, "teams of %s are visible to its members", name)
75 }
76 return org, -1
77}
78
79func teamRef(c *Ctx, org store.Org, name string) (store.Team, int) {
80 team, err := c.Store.TeamByName(org.ID, name)
81 if errors.Is(err, store.ErrNotFound) {
82 return team, c.fail(protocol.ExitNotFound, "no team %q in %s", name, org.Name)
83 }
84 if err != nil {
85 return team, c.fail(protocol.ExitFailure, "%v", err)
86 }
87 return team, -1
88}
89
90func runTeamCreate(c *Ctx, args []string) int {
91 if len(args) != 2 {
92 return c.usage()
93 }
94 org, code := orgAdminRef(c, args[0])
95 if code >= 0 {
96 return code
97 }
98 if err := policy.ValidateName(args[1]); err != nil {
99 return c.failInput(err)
100 }
101 if _, err := c.Store.CreateTeam(org.ID, args[1]); err != nil {
102 return c.failErr(err)
103 }
104 return c.emit(map[string]string{"team": args[1]}, func(w io.Writer) {
105 fmt.Fprintf(w, "created team %s/%s\n", org.Name, args[1])
106 })
107}
108
109func runTeamDelete(c *Ctx, args []string) int {
110 if len(args) != 2 {
111 return c.usage()
112 }
113 org, code := orgAdminRef(c, args[0])
114 if code >= 0 {
115 return code
116 }
117 team, code := teamRef(c, org, args[1])
118 if code >= 0 {
119 return code
120 }
121 if err := c.Store.DeleteTeam(team.ID); err != nil {
122 return c.fail(protocol.ExitFailure, "%v", err)
123 }
124 return c.emit(map[string]string{"deleted": team.Name}, func(w io.Writer) {
125 fmt.Fprintf(w, "deleted team %s/%s\n", org.Name, team.Name)
126 })
127}
128
129func runTeamList(c *Ctx, args []string) int {
130 if len(args) != 1 {
131 return c.usage()
132 }
133 org, code := orgMemberRef(c, args[0])
134 if code >= 0 {
135 return code
136 }
137 teams, err := c.Store.ListTeams(org.ID)
138 if err != nil {
139 return c.fail(protocol.ExitFailure, "%v", err)
140 }
141 var names []string
142 for _, t := range teams {
143 names = append(names, t.Name)
144 }
145 return c.emit(names, func(w io.Writer) {
146 for _, n := range names {
147 fmt.Fprintln(w, n)
148 }
149 })
150}
151
152func runTeamShow(c *Ctx, args []string) int {
153 if len(args) != 2 {
154 return c.usage()
155 }
156 org, code := orgMemberRef(c, args[0])
157 if code >= 0 {
158 return code
159 }
160 team, code := teamRef(c, org, args[1])
161 if code >= 0 {
162 return code
163 }
164 members, err := c.Store.TeamMembers(team.ID)
165 if err != nil {
166 return c.fail(protocol.ExitFailure, "%v", err)
167 }
168 grants, err := c.Store.TeamGrants(team.ID)
169 if err != nil {
170 return c.fail(protocol.ExitFailure, "%v", err)
171 }
172 d := struct {
173 Team string `json:"team"`
174 Members []string `json:"members,omitempty"`
175 Grants []store.TeamGrant `json:"grants,omitempty"`
176 }{team.Name, members, grants}
177 return c.emit(d, func(w io.Writer) {
178 fmt.Fprintf(w, "%s/%s\nmembers: %s\n", org.Name, team.Name, strings.Join(members, ", "))
179 for _, g := range grants {
180 fmt.Fprintf(w, "%s\t%s\n", g.RepoPath, g.Role)
181 }
182 })
183}
184
185func runTeamAdd(c *Ctx, args []string) int { return editTeamMembers(c, args, true) }
186func runTeamRemove(c *Ctx, args []string) int { return editTeamMembers(c, args, false) }
187
188func editTeamMembers(c *Ctx, args []string, add bool) int {
189 verb := "add"
190 if !add {
191 verb = "remove"
192 }
193 if len(args) < 3 {
194 return c.usage()
195 }
196 org, code := orgAdminRef(c, args[0])
197 if code >= 0 {
198 return code
199 }
200 team, code := teamRef(c, org, args[1])
201 if code >= 0 {
202 return code
203 }
204 for _, name := range args[2:] {
205 u, err := c.Store.UserByUsername(name)
206 if err != nil {
207 return c.fail(protocol.ExitNotFound, "no such user %q", name)
208 }
209 if add {
210 // Teams organize existing members; they do not grant
211 // membership by side effect.
212 role, err := c.Store.OrgRole(org.ID, u.ID)
213 if err != nil {
214 return c.fail(protocol.ExitFailure, "%v", err)
215 }
216 if role == "" {
217 return c.fail(protocol.ExitUsage, "%s is not a member of %s — add them to the org first", name, org.Name)
218 }
219 if err := c.Store.AddTeamMember(team.ID, u.ID); err != nil {
220 return c.fail(protocol.ExitFailure, "%v", err)
221 }
222 } else if err := c.Store.RemoveTeamMember(team.ID, u.ID); err != nil {
223 if errors.Is(err, store.ErrNotFound) {
224 return c.fail(protocol.ExitNotFound, "%s is not in team %s", name, team.Name)
225 }
226 return c.fail(protocol.ExitFailure, "%v", err)
227 }
228 }
229 return c.emit(map[string]any{"team": team.Name, verb: args[2:]}, func(w io.Writer) {
230 fmt.Fprintf(w, "%sed %s on team %s/%s\n", verb, strings.Join(args[2:], ", "), org.Name, team.Name)
231 })
232}
233
234func runTeamGrant(c *Ctx, args []string) int {
235 if len(args) != 4 || !slices.Contains([]string{"read", "write", "admin"}, args[3]) {
236 return c.usage()
237 }
238 org, code := orgAdminRef(c, args[0])
239 if code >= 0 {
240 return code
241 }
242 team, code := teamRef(c, org, args[1])
243 if code >= 0 {
244 return code
245 }
246 repo, err := c.Store.RepoByPath(args[2])
247 if err != nil || repo.OwnerKind != "org" || repo.OwnerID != org.ID {
248 return c.fail(protocol.ExitUsage, "teams grant access to their own org's repositories; %q is not one", args[2])
249 }
250 if err := c.Store.GrantTeamRepo(team.ID, repo.ID, args[3]); err != nil {
251 return c.fail(protocol.ExitFailure, "%v", err)
252 }
253 return c.emit(map[string]string{"team": team.Name, "repo": repo.Path(), "role": args[3]}, func(w io.Writer) {
254 fmt.Fprintf(w, "granted %s to team %s on %s\n", args[3], team.Name, repo.Path())
255 })
256}
257
258func runTeamRevoke(c *Ctx, args []string) int {
259 if len(args) != 3 {
260 return c.usage()
261 }
262 org, code := orgAdminRef(c, args[0])
263 if code >= 0 {
264 return code
265 }
266 team, code := teamRef(c, org, args[1])
267 if code >= 0 {
268 return code
269 }
270 repo, err := c.Store.RepoByPath(args[2])
271 if err != nil {
272 return c.fail(protocol.ExitNotFound, "repository %s not found", args[2])
273 }
274 if err := c.Store.RevokeTeamRepo(team.ID, repo.ID); err != nil {
275 if errors.Is(err, store.ErrNotFound) {
276 return c.fail(protocol.ExitNotFound, "team %s has no grant on %s", team.Name, repo.Path())
277 }
278 return c.fail(protocol.ExitFailure, "%v", err)
279 }
280 return c.emit(map[string]string{"revoked": repo.Path()}, func(w io.Writer) {
281 fmt.Fprintf(w, "revoked team %s on %s\n", team.Name, repo.Path())
282 })
283}
284
285func runOrgMembersRole(c *Ctx, args []string) int {
286 if len(args) != 2 || !slices.Contains([]string{"write", "read", "none"}, args[1]) {
287 return c.usage()
288 }
289 org, code := orgAdminRef(c, args[0])
290 if code >= 0 {
291 return code
292 }
293 if err := c.Store.SetOrgMembersRole(org.ID, args[1]); err != nil {
294 return c.fail(protocol.ExitFailure, "%v", err)
295 }
296 return c.emit(map[string]string{"members_role": args[1]}, func(w io.Writer) {
297 fmt.Fprintf(w, "plain members of %s now get %q on org repositories\n", org.Name, args[1])
298 })
299}