internal/control/teams.go

2e62f52baae22d14056deefc922dc1b9ec9b7bc5
gitbay/internal/control/teams.go history · blame · raw

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}