internal/control/teams.go
344 lines · 11172 bytes
14 symbols in this file
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.emitView(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 }, func() screen {
164 rows := make([]row, len(names))
165 for i, n := range names {
166 rows[i] = rowOf(cRef(n))
167 }
168 s := listScreen("Teams", rows)
169 if len(names) > 0 {
170 s.actions = []action{{"Read", []string{"org", "team", "show", org.Name, names[0]}}}
171 }
172 return s
173 })
174}
175
176func runTeamShow(c *Ctx, args []string) int {
177 if len(args) != 2 {
178 return c.usage()
179 }
180 org, code := orgMemberRef(c, args[0])
181 if code >= 0 {
182 return code
183 }
184 team, code := teamRef(c, org, args[1])
185 if code >= 0 {
186 return code
187 }
188 members, err := c.Store.TeamMembers(team.ID)
189 if err != nil {
190 return c.fail(protocol.ExitFailure, "%v", err)
191 }
192 grants, err := c.Store.TeamGrants(team.ID)
193 if err != nil {
194 return c.fail(protocol.ExitFailure, "%v", err)
195 }
196 d := struct {
197 Team string `json:"team"`
198 Members []string `json:"members,omitempty"`
199 Grants []store.TeamGrant `json:"grants,omitempty"`
200 }{team.Name, members, grants}
201 return c.emitView(d, func(w io.Writer) {
202 v := c.view(w)
203 v.title(org.Name+"/"+team.Name, "", "")
204 v.fields("members", strings.Join(members, ", "))
205 if len(grants) > 0 {
206 v.section("grants")
207 tb := c.table(w, "REPO", "ROLE")
208 for _, g := range grants {
209 tb.row(cRef(g.RepoPath), cState(g.Role))
210 }
211 tb.flush()
212 }
213 }, func() screen {
214 grantsSec := section{title: "Grants", n: len(grants)}
215 for _, g := range grants {
216 grantsSec.rows = append(grantsSec.rows, rowOf(cLink(g.RepoPath, c.siteURL(g.RepoPath)), cState(g.Role)))
217 }
218 s := screen{fields: []field{{"Team", []cell{cText(org.Name + "/" + team.Name)}}}, sections: []section{grantsSec}}
219 if len(members) > 0 {
220 s.fields = append(s.fields, field{"Members", []cell{cText(strings.Join(members, ", "))}})
221 }
222 s.actions = []action{
223 {"Team", []string{"org", "team", "add", org.Name, team.Name, "<user>"}},
224 {"Team", []string{"org", "team", "grant", org.Name, team.Name, "<owner/name>", "write"}},
225 }
226 return s
227 })
228}
229
230func runTeamAdd(c *Ctx, args []string) int { return editTeamMembers(c, args, true) }
231func runTeamRemove(c *Ctx, args []string) int { return editTeamMembers(c, args, false) }
232
233func editTeamMembers(c *Ctx, args []string, add bool) int {
234 verb := "add"
235 if !add {
236 verb = "remove"
237 }
238 if len(args) < 3 {
239 return c.usage()
240 }
241 org, code := orgAdminRef(c, args[0])
242 if code >= 0 {
243 return code
244 }
245 team, code := teamRef(c, org, args[1])
246 if code >= 0 {
247 return code
248 }
249 for _, name := range args[2:] {
250 u, err := c.Store.UserByUsername(name)
251 if err != nil {
252 return c.fail(protocol.ExitNotFound, "no such user %q", name)
253 }
254 if add {
255 // Teams organize existing members; they do not grant
256 // membership by side effect.
257 role, err := c.Store.OrgRole(org.ID, u.ID)
258 if err != nil {
259 return c.fail(protocol.ExitFailure, "%v", err)
260 }
261 if role == "" {
262 return c.fail(protocol.ExitUsage, "%s is not a member of %s — add them to the org first", name, org.Name)
263 }
264 if err := c.Store.AddTeamMember(team.ID, u.ID); err != nil {
265 return c.fail(protocol.ExitFailure, "%v", err)
266 }
267 } else if err := c.Store.RemoveTeamMember(team.ID, u.ID); err != nil {
268 if errors.Is(err, store.ErrNotFound) {
269 return c.fail(protocol.ExitNotFound, "%s is not in team %s", name, team.Name)
270 }
271 return c.fail(protocol.ExitFailure, "%v", err)
272 }
273 }
274 return c.emit(map[string]any{"team": team.Name, verb: args[2:]}, func(w io.Writer) {
275 fmt.Fprintf(w, "%sed %s on team %s/%s\n", verb, strings.Join(args[2:], ", "), org.Name, team.Name)
276 })
277}
278
279func runTeamGrant(c *Ctx, args []string) int {
280 if len(args) != 4 || !slices.Contains([]string{"read", "write", "admin"}, 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 || repo.OwnerKind != "org" || repo.OwnerID != org.ID {
293 return c.fail(protocol.ExitUsage, "teams grant access to their own org's repositories; %q is not one", args[2])
294 }
295 if err := c.Store.GrantTeamRepo(team.ID, repo.ID, args[3]); err != nil {
296 return c.fail(protocol.ExitFailure, "%v", err)
297 }
298 return c.emit(map[string]string{"team": team.Name, "repo": repo.Path(), "role": args[3]}, func(w io.Writer) {
299 fmt.Fprintf(w, "granted %s to team %s on %s\n", args[3], team.Name, repo.Path())
300 })
301}
302
303func runTeamRevoke(c *Ctx, args []string) int {
304 if len(args) != 3 {
305 return c.usage()
306 }
307 org, code := orgAdminRef(c, args[0])
308 if code >= 0 {
309 return code
310 }
311 team, code := teamRef(c, org, args[1])
312 if code >= 0 {
313 return code
314 }
315 repo, err := c.Store.RepoByPath(args[2])
316 if err != nil {
317 return c.fail(protocol.ExitNotFound, "repository %s not found", args[2])
318 }
319 if err := c.Store.RevokeTeamRepo(team.ID, repo.ID); err != nil {
320 if errors.Is(err, store.ErrNotFound) {
321 return c.fail(protocol.ExitNotFound, "team %s has no grant on %s", team.Name, repo.Path())
322 }
323 return c.fail(protocol.ExitFailure, "%v", err)
324 }
325 return c.emit(map[string]string{"revoked": repo.Path()}, func(w io.Writer) {
326 fmt.Fprintf(w, "revoked team %s on %s\n", team.Name, repo.Path())
327 })
328}
329
330func runOrgMembersRole(c *Ctx, args []string) int {
331 if len(args) != 2 || !slices.Contains([]string{"write", "read", "none"}, args[1]) {
332 return c.usage()
333 }
334 org, code := orgAdminRef(c, args[0])
335 if code >= 0 {
336 return code
337 }
338 if err := c.Store.SetOrgMembersRole(org.ID, args[1]); err != nil {
339 return c.fail(protocol.ExitFailure, "%v", err)
340 }
341 return c.emit(map[string]string{"members_role": args[1]}, func(w io.Writer) {
342 fmt.Fprintf(w, "plain members of %s now get %q on org repositories\n", org.Name, args[1])
343 })
344}