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