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