internal/control/teams.go

bd49b87fce895e9f0a7588152548fb6e1821ac7d
gitbay/internal/control/teams.go history · blame · raw

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}