internal/control/teams.go

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

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