internal/control/teams.go

344 lines · 11172 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		NeedsRecentSignIn: true,
 34		Summary:           "add org members to a team",
 35		Usage:             "org team add <org> <team> <user>...",
 36		Examples:          []string{"org team add krz maintainers cmc"}, Run: runTeamAdd})
 37	register(Command{Path: []string{"org", "team", "remove"},
 38		Summary:  "remove members from a team",
 39		Usage:    "org team remove <org> <team> <user>...",
 40		Examples: []string{"org team remove krz maintainers cmc"}, Run: runTeamRemove})
 41	register(Command{Path: []string{"org", "team", "grant"},
 42		NeedsRecentSignIn: true,
 43		Summary:           "grant a team a role on an org repo",
 44		Usage:             "org team grant <org> <team> <owner/name> read|write|admin",
 45		Examples:          []string{"org team grant krz maintainers krz/gitbay write"}, Run: runTeamGrant})
 46	register(Command{Path: []string{"org", "team", "revoke"},
 47		Summary:  "revoke a team's grant",
 48		Usage:    "org team revoke <org> <team> <owner/name>",
 49		Examples: []string{"org team revoke krz maintainers krz/gitbay"}, Run: runTeamRevoke})
 50	register(Command{Path: []string{"org", "settings", "members-role"},
 51		NeedsRecentSignIn: true,
 52		Summary:           "role plain membership implies on every org repo",
 53		Usage:             "org settings members-role <org> write|read|none (default write)",
 54		Examples:          []string{"org settings members-role krz read"}, Run: runOrgMembersRole})
 55}
 56
 57// orgAdminRef resolves an org and requires the caller to admin it.
 58func orgAdminRef(c *Ctx, name string) (store.Org, int) {
 59	org, err := c.Store.OrgByName(name)
 60	if err != nil {
 61		return org, c.fail(protocol.ExitNotFound, "no organization %q", name)
 62	}
 63	role, err := c.Store.OrgRole(org.ID, c.User.ID)
 64	if err != nil {
 65		return org, c.fail(protocol.ExitFailure, "%v", err)
 66	}
 67	if role != "admin" {
 68		return org, c.fail(protocol.ExitDenied, "only admins of %s can manage teams; ask one to add you", name)
 69	}
 70	return org, -1
 71}
 72
 73// orgMemberRef resolves an org, requiring at least membership. An org's
 74// existence and member list are public (org show answers anyone), so a
 75// non-member is refused rather than told the org does not exist.
 76func orgMemberRef(c *Ctx, name string) (store.Org, int) {
 77	org, err := c.Store.OrgByName(name)
 78	if err != nil {
 79		return org, c.fail(protocol.ExitNotFound, "no organization %q", name)
 80	}
 81	role, err := c.Store.OrgRole(org.ID, c.User.ID)
 82	if err != nil {
 83		return org, c.fail(protocol.ExitFailure, "%v", err)
 84	}
 85	if role == "" {
 86		return org, c.fail(protocol.ExitDenied, "teams of %s are visible to its members; ask an admin to add you", name)
 87	}
 88	return org, -1
 89}
 90
 91func teamRef(c *Ctx, org store.Org, name string) (store.Team, int) {
 92	team, err := c.Store.TeamByName(org.ID, name)
 93	if errors.Is(err, store.ErrNotFound) {
 94		return team, c.fail(protocol.ExitNotFound, "no team %q in %s", name, org.Name)
 95	}
 96	if err != nil {
 97		return team, c.fail(protocol.ExitFailure, "%v", err)
 98	}
 99	return team, -1
100}
101
102func runTeamCreate(c *Ctx, args []string) int {
103	if len(args) != 2 {
104		return c.usage()
105	}
106	org, code := orgAdminRef(c, args[0])
107	if code >= 0 {
108		return code
109	}
110	if err := policy.ValidateName(args[1]); err != nil {
111		return c.failInput(err)
112	}
113	if _, err := c.Store.CreateTeam(org.ID, args[1]); err != nil {
114		return c.failErr(err)
115	}
116	return c.emit(map[string]string{"team": args[1]}, func(w io.Writer) {
117		fmt.Fprintf(w, "created team %s/%s\n", org.Name, args[1])
118	})
119}
120
121func runTeamDelete(c *Ctx, args []string) int {
122	if len(args) != 2 {
123		return c.usage()
124	}
125	org, code := orgAdminRef(c, args[0])
126	if code >= 0 {
127		return code
128	}
129	team, code := teamRef(c, org, args[1])
130	if code >= 0 {
131		return code
132	}
133	if err := c.Store.DeleteTeam(team.ID); err != nil {
134		return c.fail(protocol.ExitFailure, "%v", err)
135	}
136	return c.emit(map[string]string{"deleted": team.Name}, func(w io.Writer) {
137		fmt.Fprintf(w, "deleted team %s/%s\n", org.Name, team.Name)
138	})
139}
140
141func runTeamList(c *Ctx, args []string) int {
142	if len(args) != 1 {
143		return c.usage()
144	}
145	org, code := orgMemberRef(c, args[0])
146	if code >= 0 {
147		return code
148	}
149	teams, err := c.Store.ListTeams(org.ID)
150	if err != nil {
151		return c.fail(protocol.ExitFailure, "%v", err)
152	}
153	var names []string
154	for _, t := range teams {
155		names = append(names, t.Name)
156	}
157	return c.emitView(names, func(w io.Writer) {
158		tb := c.table(w, "TEAM")
159		for _, n := range names {
160			tb.row(cRef(n))
161		}
162		tb.flush()
163	}, func() screen {
164		rows := make([]row, len(names))
165		for i, n := range names {
166			rows[i] = rowOf(cRef(n))
167		}
168		s := listScreen("Teams", rows)
169		if len(names) > 0 {
170			s.actions = []action{{"Read", []string{"org", "team", "show", org.Name, names[0]}}}
171		}
172		return s
173	})
174}
175
176func runTeamShow(c *Ctx, args []string) int {
177	if len(args) != 2 {
178		return c.usage()
179	}
180	org, code := orgMemberRef(c, args[0])
181	if code >= 0 {
182		return code
183	}
184	team, code := teamRef(c, org, args[1])
185	if code >= 0 {
186		return code
187	}
188	members, err := c.Store.TeamMembers(team.ID)
189	if err != nil {
190		return c.fail(protocol.ExitFailure, "%v", err)
191	}
192	grants, err := c.Store.TeamGrants(team.ID)
193	if err != nil {
194		return c.fail(protocol.ExitFailure, "%v", err)
195	}
196	d := struct {
197		Team    string            `json:"team"`
198		Members []string          `json:"members,omitempty"`
199		Grants  []store.TeamGrant `json:"grants,omitempty"`
200	}{team.Name, members, grants}
201	return c.emitView(d, func(w io.Writer) {
202		v := c.view(w)
203		v.title(org.Name+"/"+team.Name, "", "")
204		v.fields("members", strings.Join(members, ", "))
205		if len(grants) > 0 {
206			v.section("grants")
207			tb := c.table(w, "REPO", "ROLE")
208			for _, g := range grants {
209				tb.row(cRef(g.RepoPath), cState(g.Role))
210			}
211			tb.flush()
212		}
213	}, func() screen {
214		grantsSec := section{title: "Grants", n: len(grants)}
215		for _, g := range grants {
216			grantsSec.rows = append(grantsSec.rows, rowOf(cLink(g.RepoPath, c.siteURL(g.RepoPath)), cState(g.Role)))
217		}
218		s := screen{fields: []field{{"Team", []cell{cText(org.Name + "/" + team.Name)}}}, sections: []section{grantsSec}}
219		if len(members) > 0 {
220			s.fields = append(s.fields, field{"Members", []cell{cText(strings.Join(members, ", "))}})
221		}
222		s.actions = []action{
223			{"Team", []string{"org", "team", "add", org.Name, team.Name, "<user>"}},
224			{"Team", []string{"org", "team", "grant", org.Name, team.Name, "<owner/name>", "write"}},
225		}
226		return s
227	})
228}
229
230func runTeamAdd(c *Ctx, args []string) int    { return editTeamMembers(c, args, true) }
231func runTeamRemove(c *Ctx, args []string) int { return editTeamMembers(c, args, false) }
232
233func editTeamMembers(c *Ctx, args []string, add bool) int {
234	verb := "add"
235	if !add {
236		verb = "remove"
237	}
238	if len(args) < 3 {
239		return c.usage()
240	}
241	org, code := orgAdminRef(c, args[0])
242	if code >= 0 {
243		return code
244	}
245	team, code := teamRef(c, org, args[1])
246	if code >= 0 {
247		return code
248	}
249	for _, name := range args[2:] {
250		u, err := c.Store.UserByUsername(name)
251		if err != nil {
252			return c.fail(protocol.ExitNotFound, "no such user %q", name)
253		}
254		if add {
255			// Teams organize existing members; they do not grant
256			// membership by side effect.
257			role, err := c.Store.OrgRole(org.ID, u.ID)
258			if err != nil {
259				return c.fail(protocol.ExitFailure, "%v", err)
260			}
261			if role == "" {
262				return c.fail(protocol.ExitUsage, "%s is not a member of %s — add them to the org first", name, org.Name)
263			}
264			if err := c.Store.AddTeamMember(team.ID, u.ID); err != nil {
265				return c.fail(protocol.ExitFailure, "%v", err)
266			}
267		} else if err := c.Store.RemoveTeamMember(team.ID, u.ID); err != nil {
268			if errors.Is(err, store.ErrNotFound) {
269				return c.fail(protocol.ExitNotFound, "%s is not in team %s", name, team.Name)
270			}
271			return c.fail(protocol.ExitFailure, "%v", err)
272		}
273	}
274	return c.emit(map[string]any{"team": team.Name, verb: args[2:]}, func(w io.Writer) {
275		fmt.Fprintf(w, "%sed %s on team %s/%s\n", verb, strings.Join(args[2:], ", "), org.Name, team.Name)
276	})
277}
278
279func runTeamGrant(c *Ctx, args []string) int {
280	if len(args) != 4 || !slices.Contains([]string{"read", "write", "admin"}, args[3]) {
281		return c.usage()
282	}
283	org, code := orgAdminRef(c, args[0])
284	if code >= 0 {
285		return code
286	}
287	team, code := teamRef(c, org, args[1])
288	if code >= 0 {
289		return code
290	}
291	repo, err := c.Store.RepoByPath(args[2])
292	if err != nil || repo.OwnerKind != "org" || repo.OwnerID != org.ID {
293		return c.fail(protocol.ExitUsage, "teams grant access to their own org's repositories; %q is not one", args[2])
294	}
295	if err := c.Store.GrantTeamRepo(team.ID, repo.ID, args[3]); err != nil {
296		return c.fail(protocol.ExitFailure, "%v", err)
297	}
298	return c.emit(map[string]string{"team": team.Name, "repo": repo.Path(), "role": args[3]}, func(w io.Writer) {
299		fmt.Fprintf(w, "granted %s to team %s on %s\n", args[3], team.Name, repo.Path())
300	})
301}
302
303func runTeamRevoke(c *Ctx, args []string) int {
304	if len(args) != 3 {
305		return c.usage()
306	}
307	org, code := orgAdminRef(c, args[0])
308	if code >= 0 {
309		return code
310	}
311	team, code := teamRef(c, org, args[1])
312	if code >= 0 {
313		return code
314	}
315	repo, err := c.Store.RepoByPath(args[2])
316	if err != nil {
317		return c.fail(protocol.ExitNotFound, "repository %s not found", args[2])
318	}
319	if err := c.Store.RevokeTeamRepo(team.ID, repo.ID); err != nil {
320		if errors.Is(err, store.ErrNotFound) {
321			return c.fail(protocol.ExitNotFound, "team %s has no grant on %s", team.Name, repo.Path())
322		}
323		return c.fail(protocol.ExitFailure, "%v", err)
324	}
325	return c.emit(map[string]string{"revoked": repo.Path()}, func(w io.Writer) {
326		fmt.Fprintf(w, "revoked team %s on %s\n", team.Name, repo.Path())
327	})
328}
329
330func runOrgMembersRole(c *Ctx, args []string) int {
331	if len(args) != 2 || !slices.Contains([]string{"write", "read", "none"}, args[1]) {
332		return c.usage()
333	}
334	org, code := orgAdminRef(c, args[0])
335	if code >= 0 {
336		return code
337	}
338	if err := c.Store.SetOrgMembersRole(org.ID, args[1]); err != nil {
339		return c.fail(protocol.ExitFailure, "%v", err)
340	}
341	return c.emit(map[string]string{"members_role": args[1]}, func(w io.Writer) {
342		fmt.Fprintf(w, "plain members of %s now get %q on org repositories\n", org.Name, args[1])
343	})
344}