internal/control/teams.go

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

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