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