collab.go
4417 bytes
1package cmd
2
3import (
4 "context"
5 "errors"
6
7 "github.com/charmbracelet/soft-serve/pkg/access"
8 "github.com/charmbracelet/soft-serve/pkg/backend"
9 "github.com/charmbracelet/soft-serve/pkg/db"
10 "github.com/charmbracelet/soft-serve/pkg/proto"
11 "github.com/spf13/cobra"
12)
13
14func collabCommand() *cobra.Command {
15 cmd := &cobra.Command{
16 Use: "collab",
17 Aliases: []string{"collabs", "collaborator", "collaborators"},
18 Short: "Manage collaborators",
19 }
20
21 cmd.AddCommand(
22 collabAddCommand(),
23 collabRemoveCommand(),
24 collabListCommand(),
25 )
26
27 return cmd
28}
29
30// checkCollabGrant reports whether the caller may set a collaborator's access
31// level on a repository to level.
32//
33// Server admins may grant anything. Everyone else is bounded by their own
34// access level on the repository, so a read-write collaborator cannot mint an
35// admin-access collaborator and escalate beyond their own permissions.
36func checkCollabGrant(ctx context.Context, repo string, level access.AccessLevel) error {
37 if isServerAdmin(ctx) {
38 return nil
39 }
40
41 if proto.UserFromContext(ctx) == nil {
42 return proto.ErrUnauthorized
43 }
44
45 caller := repoAccessLevel(ctx, repo)
46 if level > caller {
47 return proto.ErrExceedsAccessLevel
48 }
49
50 return nil
51}
52
53// checkCollabDemote reports whether the caller may remove or overwrite an
54// existing collaborator on a repository.
55//
56// Removal is a privileged change in the same way granting is: without this,
57// a read-write collaborator could remove an admin-access collaborator and
58// then re-add them at a lower level, demoting someone above them.
59func checkCollabDemote(ctx context.Context, repo string, username string) error {
60 if isServerAdmin(ctx) {
61 return nil
62 }
63
64 if proto.UserFromContext(ctx) == nil {
65 return proto.ErrUnauthorized
66 }
67
68 be := backend.FromContext(ctx)
69 current, isCollab, err := be.IsCollaborator(ctx, repo, username)
70 if err != nil {
71 // A missing row just means the user is not a collaborator yet, which
72 // is the common case when adding one. Any other error is real and
73 // must fail closed.
74 if !errors.Is(err, db.ErrRecordNotFound) {
75 return err
76 }
77 return nil
78 }
79
80 if !isCollab {
81 return nil
82 }
83
84 if current > repoAccessLevel(ctx, repo) {
85 return proto.ErrExceedsAccessLevel
86 }
87
88 return nil
89}
90
91func collabAddCommand() *cobra.Command {
92 cmd := &cobra.Command{
93 Use: "add REPOSITORY USERNAME [LEVEL]",
94 Short: "Add a collaborator to a repo",
95 Long: "Add a collaborator to a repo. LEVEL can be one of: no-access, read-only, read-write, or admin-access. Defaults to read-write.",
96 Args: cobra.RangeArgs(2, 3),
97 PersistentPreRunE: checkIfReadableAndCollab,
98 RunE: func(cmd *cobra.Command, args []string) error {
99 ctx := cmd.Context()
100 be := backend.FromContext(ctx)
101 repo := repoArg(args)
102 username := args[1]
103 level := access.ReadWriteAccess
104 if len(args) > 2 {
105 level = access.ParseAccessLevel(args[2])
106 if level < 0 {
107 return access.ErrInvalidAccessLevel
108 }
109 }
110
111 if err := checkCollabGrant(ctx, repo, level); err != nil {
112 return err
113 }
114
115 if err := checkCollabDemote(ctx, repo, username); err != nil {
116 return err
117 }
118
119 return be.AddCollaborator(ctx, repo, username, level)
120 },
121 }
122
123 return cmd
124}
125
126func collabRemoveCommand() *cobra.Command {
127 cmd := &cobra.Command{
128 Use: "remove REPOSITORY USERNAME",
129 Args: cobra.ExactArgs(2),
130 Short: "Remove a collaborator from a repo",
131 PersistentPreRunE: checkIfReadableAndCollab,
132 RunE: func(cmd *cobra.Command, args []string) error {
133 ctx := cmd.Context()
134 be := backend.FromContext(ctx)
135 repo := repoArg(args)
136 username := args[1]
137
138 if err := checkCollabDemote(ctx, repo, username); err != nil {
139 return err
140 }
141
142 return be.RemoveCollaborator(ctx, repo, username)
143 },
144 }
145
146 return cmd
147}
148
149func collabListCommand() *cobra.Command {
150 cmd := &cobra.Command{
151 Use: "list REPOSITORY",
152 Short: "List collaborators for a repo",
153 Args: cobra.ExactArgs(1),
154 PersistentPreRunE: checkIfReadableAndCollab,
155 RunE: func(cmd *cobra.Command, args []string) error {
156 ctx := cmd.Context()
157 be := backend.FromContext(ctx)
158 repo := repoArg(args)
159 collabs, err := be.Collaborators(ctx, repo)
160 if err != nil {
161 return err
162 }
163
164 for _, c := range collabs {
165 cmd.Println(c)
166 }
167
168 return nil
169 },
170 }
171
172 return cmd
173}