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