internal/control/teams.go

320 lines · 10231 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.emit(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	})
164}
165
166func runTeamShow(c *Ctx, args []string) int {
167	if len(args) != 2 {
168		return c.usage()
169	}
170	org, code := orgMemberRef(c, args[0])
171	if code >= 0 {
172		return code
173	}
174	team, code := teamRef(c, org, args[1])
175	if code >= 0 {
176		return code
177	}
178	members, err := c.Store.TeamMembers(team.ID)
179	if err != nil {
180		return c.fail(protocol.ExitFailure, "%v", err)
181	}
182	grants, err := c.Store.TeamGrants(team.ID)
183	if err != nil {
184		return c.fail(protocol.ExitFailure, "%v", err)
185	}
186	d := struct {
187		Team    string            `json:"team"`
188		Members []string          `json:"members,omitempty"`
189		Grants  []store.TeamGrant `json:"grants,omitempty"`
190	}{team.Name, members, grants}
191	return c.emit(d, func(w io.Writer) {
192		v := c.view(w)
193		v.title(org.Name+"/"+team.Name, "", "")
194		v.fields("members", strings.Join(members, ", "))
195		if len(grants) > 0 {
196			v.section("grants")
197			tb := c.table(w, "REPO", "ROLE")
198			for _, g := range grants {
199				tb.row(cRef(g.RepoPath), cState(g.Role))
200			}
201			tb.flush()
202		}
203	})
204}
205
206func runTeamAdd(c *Ctx, args []string) int    { return editTeamMembers(c, args, true) }
207func runTeamRemove(c *Ctx, args []string) int { return editTeamMembers(c, args, false) }
208
209func editTeamMembers(c *Ctx, args []string, add bool) int {
210	verb := "add"
211	if !add {
212		verb = "remove"
213	}
214	if len(args) < 3 {
215		return c.usage()
216	}
217	org, code := orgAdminRef(c, args[0])
218	if code >= 0 {
219		return code
220	}
221	team, code := teamRef(c, org, args[1])
222	if code >= 0 {
223		return code
224	}
225	for _, name := range args[2:] {
226		u, err := c.Store.UserByUsername(name)
227		if err != nil {
228			return c.fail(protocol.ExitNotFound, "no such user %q", name)
229		}
230		if add {
231			// Teams organize existing members; they do not grant
232			// membership by side effect.
233			role, err := c.Store.OrgRole(org.ID, u.ID)
234			if err != nil {
235				return c.fail(protocol.ExitFailure, "%v", err)
236			}
237			if role == "" {
238				return c.fail(protocol.ExitUsage, "%s is not a member of %s — add them to the org first", name, org.Name)
239			}
240			if err := c.Store.AddTeamMember(team.ID, u.ID); err != nil {
241				return c.fail(protocol.ExitFailure, "%v", err)
242			}
243		} else if err := c.Store.RemoveTeamMember(team.ID, u.ID); err != nil {
244			if errors.Is(err, store.ErrNotFound) {
245				return c.fail(protocol.ExitNotFound, "%s is not in team %s", name, team.Name)
246			}
247			return c.fail(protocol.ExitFailure, "%v", err)
248		}
249	}
250	return c.emit(map[string]any{"team": team.Name, verb: args[2:]}, func(w io.Writer) {
251		fmt.Fprintf(w, "%sed %s on team %s/%s\n", verb, strings.Join(args[2:], ", "), org.Name, team.Name)
252	})
253}
254
255func runTeamGrant(c *Ctx, args []string) int {
256	if len(args) != 4 || !slices.Contains([]string{"read", "write", "admin"}, args[3]) {
257		return c.usage()
258	}
259	org, code := orgAdminRef(c, args[0])
260	if code >= 0 {
261		return code
262	}
263	team, code := teamRef(c, org, args[1])
264	if code >= 0 {
265		return code
266	}
267	repo, err := c.Store.RepoByPath(args[2])
268	if err != nil || repo.OwnerKind != "org" || repo.OwnerID != org.ID {
269		return c.fail(protocol.ExitUsage, "teams grant access to their own org's repositories; %q is not one", args[2])
270	}
271	if err := c.Store.GrantTeamRepo(team.ID, repo.ID, args[3]); err != nil {
272		return c.fail(protocol.ExitFailure, "%v", err)
273	}
274	return c.emit(map[string]string{"team": team.Name, "repo": repo.Path(), "role": args[3]}, func(w io.Writer) {
275		fmt.Fprintf(w, "granted %s to team %s on %s\n", args[3], team.Name, repo.Path())
276	})
277}
278
279func runTeamRevoke(c *Ctx, args []string) int {
280	if len(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 {
293		return c.fail(protocol.ExitNotFound, "repository %s not found", args[2])
294	}
295	if err := c.Store.RevokeTeamRepo(team.ID, repo.ID); err != nil {
296		if errors.Is(err, store.ErrNotFound) {
297			return c.fail(protocol.ExitNotFound, "team %s has no grant on %s", team.Name, repo.Path())
298		}
299		return c.fail(protocol.ExitFailure, "%v", err)
300	}
301	return c.emit(map[string]string{"revoked": repo.Path()}, func(w io.Writer) {
302		fmt.Fprintf(w, "revoked team %s on %s\n", team.Name, repo.Path())
303	})
304}
305
306func runOrgMembersRole(c *Ctx, args []string) int {
307	if len(args) != 2 || !slices.Contains([]string{"write", "read", "none"}, args[1]) {
308		return c.usage()
309	}
310	org, code := orgAdminRef(c, args[0])
311	if code >= 0 {
312		return code
313	}
314	if err := c.Store.SetOrgMembersRole(org.ID, args[1]); err != nil {
315		return c.fail(protocol.ExitFailure, "%v", err)
316	}
317	return c.emit(map[string]string{"members_role": args[1]}, func(w io.Writer) {
318		fmt.Fprintf(w, "plain members of %s now get %q on org repositories\n", org.Name, args[1])
319	})
320}