Diff
1diff --git a/cmd/soft/migrate_config.go b/cmd/soft/migrate_config.go
2index 583153e5d84e1bbd3e73538267ed6bf85f0d36cb..86bea23d39ad148efb7b5a0cea1f2d2663306fb4 100644
3--- a/cmd/soft/migrate_config.go
4+++ b/cmd/soft/migrate_config.go
5@@ -273,7 +273,7 @@ var migrateConfig = &cobra.Command{
6 }
7
8 for _, collab := range r.Collabs {
9- if err := sb.AddCollaborator(ctx, repo, collab); err != nil {
10+ if err := sb.AddCollaborator(ctx, repo, collab, access.ReadWriteAccess); err != nil {
11 logger.Errorf("failed to add repo collab to %s: %s", repo, err)
12 }
13 }
14@@ -308,7 +308,7 @@ var migrateConfig = &cobra.Command{
15 }
16
17 for _, repo := range user.CollabRepos {
18- if err := sb.AddCollaborator(ctx, repo, username); err != nil {
19+ if err := sb.AddCollaborator(ctx, repo, username, access.ReadWriteAccess); err != nil {
20 logger.Errorf("failed to add user collab to %s: %s\n", repo, err)
21 }
22 }
23diff --git a/server/access/access.go b/server/access/access.go
24index 2ddc88b398c8e00eafeaee91e328859ccaaa38ba..44ec3828e15186c1b406ce150b2297afb6f59836 100644
25--- a/server/access/access.go
26+++ b/server/access/access.go
27@@ -1,5 +1,10 @@
28 package access
29
30+import (
31+ "encoding"
32+ "errors"
33+)
34+
35 // AccessLevel is the level of access allowed to a repo.
36 type AccessLevel int // nolint: revive
37
38@@ -48,3 +53,26 @@ func ParseAccessLevel(s string) AccessLevel {
39 return AccessLevel(-1)
40 }
41 }
42+
43+var _ encoding.TextMarshaler = AccessLevel(0)
44+var _ encoding.TextUnmarshaler = (*AccessLevel)(nil)
45+
46+// ErrInvalidAccessLevel is returned when an invalid access level is provided.
47+var ErrInvalidAccessLevel = errors.New("invalid access level")
48+
49+// UnmarshalText implements encoding.TextUnmarshaler.
50+func (a *AccessLevel) UnmarshalText(text []byte) error {
51+ l := ParseAccessLevel(string(text))
52+ if l < 0 {
53+ return ErrInvalidAccessLevel
54+ }
55+
56+ *a = l
57+
58+ return nil
59+}
60+
61+// MarshalText implements encoding.TextMarshaler.
62+func (a AccessLevel) MarshalText() (text []byte, err error) {
63+ return []byte(a.String()), nil
64+}
65diff --git a/server/backend/collab.go b/server/backend/collab.go
66index 78c5b8a230ab285875953e62aeb273d69eef0381..ffe4b5f6ee725cc6f0b47d7e68b9f7464b674e46 100644
67--- a/server/backend/collab.go
68+++ b/server/backend/collab.go
69@@ -4,6 +4,7 @@ import (
70 "context"
71 "strings"
72
73+ "github.com/charmbracelet/soft-serve/server/access"
74 "github.com/charmbracelet/soft-serve/server/db"
75 "github.com/charmbracelet/soft-serve/server/db/models"
76 "github.com/charmbracelet/soft-serve/server/utils"
77@@ -12,7 +13,7 @@ import (
78 // AddCollaborator adds a collaborator to a repository.
79 //
80 // It implements backend.Backend.
81-func (d *Backend) AddCollaborator(ctx context.Context, repo string, username string) error {
82+func (d *Backend) AddCollaborator(ctx context.Context, repo string, username string, level access.AccessLevel) error {
83 username = strings.ToLower(username)
84 if err := utils.ValidateUsername(username); err != nil {
85 return err
86@@ -21,7 +22,7 @@ func (d *Backend) AddCollaborator(ctx context.Context, repo string, username str
87 repo = utils.SanitizeRepo(repo)
88 return db.WrapError(
89 d.db.TransactionContext(ctx, func(tx *db.Tx) error {
90- return d.store.AddCollabByUsernameAndRepo(ctx, tx, username, repo)
91+ return d.store.AddCollabByUsernameAndRepo(ctx, tx, username, repo, level)
92 }),
93 )
94 }
95@@ -48,12 +49,12 @@ func (d *Backend) Collaborators(ctx context.Context, repo string) ([]string, err
96 return usernames, nil
97 }
98
99-// IsCollaborator returns true if the user is a collaborator of the repository.
100+// IsCollaborator returns the access level and true if the user is a collaborator of the repository.
101 //
102 // It implements backend.Backend.
103-func (d *Backend) IsCollaborator(ctx context.Context, repo string, username string) (bool, error) {
104+func (d *Backend) IsCollaborator(ctx context.Context, repo string, username string) (access.AccessLevel, bool, error) {
105 if username == "" {
106- return false, nil
107+ return -1, false, nil
108 }
109
110 repo = utils.SanitizeRepo(repo)
111@@ -63,10 +64,10 @@ func (d *Backend) IsCollaborator(ctx context.Context, repo string, username stri
112 m, err = d.store.GetCollabByUsernameAndRepo(ctx, tx, username, repo)
113 return err
114 }); err != nil {
115- return false, db.WrapError(err)
116+ return -1, false, db.WrapError(err)
117 }
118
119- return m.ID > 0, nil
120+ return m.AccessLevel, m.ID > 0, nil
121 }
122
123 // RemoveCollaborator removes a collaborator from a repository.
124diff --git a/server/backend/user.go b/server/backend/user.go
125index 07db904d55d26d8d0cbc28407d0cb4de7960298c..d5cf38ca329af86bc2e4487c66abf5990e644aa9 100644
126--- a/server/backend/user.go
127+++ b/server/backend/user.go
128@@ -69,13 +69,13 @@ func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user prot
129 }
130 }
131
132- // If the user is a collaborator, they have read/write access.
133- isCollab, _ := d.IsCollaborator(ctx, repo, username)
134+ // If the user is a collaborator, they have return their access level.
135+ collabAccess, isCollab, _ := d.IsCollaborator(ctx, repo, username)
136 if isCollab {
137- if anon > access.ReadWriteAccess {
138+ if anon > collabAccess {
139 return anon
140 }
141- return access.ReadWriteAccess
142+ return collabAccess
143 }
144
145 // If the repository is private, the user has no access.
146diff --git a/server/db/migrate/0001_create_tables.go b/server/db/migrate/0001_create_tables.go
147index 6df595cb29583a83667b809089cfb1c58c7114be..d1cadd4d4bf3e517e8912d76cb1997c7de2b4430 100644
148--- a/server/db/migrate/0001_create_tables.go
149+++ b/server/db/migrate/0001_create_tables.go
150@@ -4,6 +4,7 @@ import (
151 "context"
152 "errors"
153 "fmt"
154+ "strconv"
155
156 "github.com/charmbracelet/soft-serve/server/access"
157 "github.com/charmbracelet/soft-serve/server/config"
158@@ -118,8 +119,8 @@ var createTables = Migration{
159
160 if hasTable(tx, "collab_old") {
161 sqlm := `
162- INSERT INTO collabs (id, user_id, repo_id, created_at, updated_at)
163- SELECT id, user_id, repo_id, created_at, updated_at FROM collab_old;
164+ INSERT INTO collabs (id, user_id, repo_id, access_level, created_at, updated_at)
165+ SELECT id, user_id, repo_id, ` + strconv.Itoa(int(access.ReadWriteAccess)) + `, created_at, updated_at FROM collab_old;
166 `
167 if _, err := tx.ExecContext(ctx, sqlm); err != nil {
168 return err
169diff --git a/server/db/migrate/0001_create_tables_postgres.up.sql b/server/db/migrate/0001_create_tables_postgres.up.sql
170index 59cf16a58e550ddcab0066203f3166d0fa01175c..7e2d9275ded141f9cbbaaf95d7859b62358eb082 100644
171--- a/server/db/migrate/0001_create_tables_postgres.up.sql
172+++ b/server/db/migrate/0001_create_tables_postgres.up.sql
173@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS collabs (
174 id SERIAL PRIMARY KEY,
175 user_id INTEGER NOT NULL,
176 repo_id INTEGER NOT NULL,
177+ access_level INTEGER NOT NULL,
178 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
179 updated_at TIMESTAMP NOT NULL,
180 UNIQUE (user_id, repo_id),
181diff --git a/server/db/migrate/0001_create_tables_sqlite.up.sql b/server/db/migrate/0001_create_tables_sqlite.up.sql
182index dad8d3f05dc281f8db440479250749d3fabf042e..9a1b15fb192b6373f6330c002661d38a601703cb 100644
183--- a/server/db/migrate/0001_create_tables_sqlite.up.sql
184+++ b/server/db/migrate/0001_create_tables_sqlite.up.sql
185@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS collabs (
186 id INTEGER PRIMARY KEY AUTOINCREMENT,
187 user_id INTEGER NOT NULL,
188 repo_id INTEGER NOT NULL,
189+ access_level INTEGER NOT NULL,
190 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
191 updated_at DATETIME NOT NULL,
192 UNIQUE (user_id, repo_id),
193diff --git a/server/db/models/collab.go b/server/db/models/collab.go
194index e14660189d85fb325ca348492fb02216a6906780..7efc44b9d3b7350b1b14c4f0640948447c8f28df 100644
195--- a/server/db/models/collab.go
196+++ b/server/db/models/collab.go
197@@ -1,12 +1,17 @@
198 package models
199
200-import "time"
201+import (
202+ "time"
203+
204+ "github.com/charmbracelet/soft-serve/server/access"
205+)
206
207 // Collab represents a repository collaborator.
208 type Collab struct {
209- ID int64 `db:"id"`
210- RepoID int64 `db:"repo_id"`
211- UserID int64 `db:"user_id"`
212- CreatedAt time.Time `db:"created_at"`
213- UpdatedAt time.Time `db:"updated_at"`
214+ ID int64 `db:"id"`
215+ RepoID int64 `db:"repo_id"`
216+ UserID int64 `db:"user_id"`
217+ AccessLevel access.AccessLevel `db:"access_level"`
218+ CreatedAt time.Time `db:"created_at"`
219+ UpdatedAt time.Time `db:"updated_at"`
220 }
221diff --git a/server/ssh/cmd/collab.go b/server/ssh/cmd/collab.go
222index 92a0829d8b4dc5ba0db8449233db2825b5e60aeb..7f45939a9a42a1d1262bc91c0f39eabbdeade1e8 100644
223--- a/server/ssh/cmd/collab.go
224+++ b/server/ssh/cmd/collab.go
225@@ -1,6 +1,7 @@
226 package cmd
227
228 import (
229+ "github.com/charmbracelet/soft-serve/server/access"
230 "github.com/charmbracelet/soft-serve/server/backend"
231 "github.com/spf13/cobra"
232 )
233@@ -23,17 +24,25 @@ func collabCommand() *cobra.Command {
234
235 func collabAddCommand() *cobra.Command {
236 cmd := &cobra.Command{
237- Use: "add REPOSITORY USERNAME",
238+ Use: "add REPOSITORY USERNAME [LEVEL]",
239 Short: "Add a collaborator to a repo",
240- Args: cobra.ExactArgs(2),
241+ 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.",
242+ Args: cobra.RangeArgs(2, 3),
243 PersistentPreRunE: checkIfCollab,
244 RunE: func(cmd *cobra.Command, args []string) error {
245 ctx := cmd.Context()
246 be := backend.FromContext(ctx)
247 repo := args[0]
248 username := args[1]
249+ level := access.ReadWriteAccess
250+ if len(args) > 2 {
251+ level = access.ParseAccessLevel(args[2])
252+ if level < 0 {
253+ return access.ErrInvalidAccessLevel
254+ }
255+ }
256
257- return be.AddCollaborator(ctx, repo, username)
258+ return be.AddCollaborator(ctx, repo, username, level)
259 },
260 }
261
262diff --git a/server/store/collab.go b/server/store/collab.go
263index 430e39d8f667dfb82a57bb53b29d6656b2d95eb6..f8907e1ca8487204ee37939cbbda5382dc92ddf3 100644
264--- a/server/store/collab.go
265+++ b/server/store/collab.go
266@@ -3,6 +3,7 @@ package store
267 import (
268 "context"
269
270+ "github.com/charmbracelet/soft-serve/server/access"
271 "github.com/charmbracelet/soft-serve/server/db"
272 "github.com/charmbracelet/soft-serve/server/db/models"
273 )
274@@ -10,7 +11,7 @@ import (
275 // CollaboratorStore is an interface for managing collaborators.
276 type CollaboratorStore interface {
277 GetCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) (models.Collab, error)
278- AddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
279+ AddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string, level access.AccessLevel) error
280 RemoveCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
281 ListCollabsByRepo(ctx context.Context, h db.Handler, repo string) ([]models.Collab, error)
282 ListCollabsByRepoAsUsers(ctx context.Context, h db.Handler, repo string) ([]models.User, error)
283diff --git a/server/store/database/collab.go b/server/store/database/collab.go
284index e290593c2d6f5dbef4461daf147a72b9eaafb924..e93044e01cab1e49c9d57939b110001c7b64e9ba 100644
285--- a/server/store/database/collab.go
286+++ b/server/store/database/collab.go
287@@ -4,6 +4,7 @@ import (
288 "context"
289 "strings"
290
291+ "github.com/charmbracelet/soft-serve/server/access"
292 "github.com/charmbracelet/soft-serve/server/db"
293 "github.com/charmbracelet/soft-serve/server/db/models"
294 "github.com/charmbracelet/soft-serve/server/store"
295@@ -15,7 +16,7 @@ type collabStore struct{}
296 var _ store.CollaboratorStore = (*collabStore)(nil)
297
298 // AddCollabByUsernameAndRepo implements store.CollaboratorStore.
299-func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) error {
300+func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string, level access.AccessLevel) error {
301 username = strings.ToLower(username)
302 if err := utils.ValidateUsername(username); err != nil {
303 return err
304@@ -23,8 +24,9 @@ func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx db.Handle
305
306 repo = utils.SanitizeRepo(repo)
307
308- query := tx.Rebind(`INSERT INTO collabs (user_id, repo_id, updated_at)
309+ query := tx.Rebind(`INSERT INTO collabs (access_level, user_id, repo_id, updated_at)
310 VALUES (
311+ ?,
312 (
313 SELECT id FROM users WHERE username = ?
314 ),
315@@ -33,7 +35,7 @@ func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx db.Handle
316 ),
317 CURRENT_TIMESTAMP
318 );`)
319- _, err := tx.ExecContext(ctx, query, username, repo)
320+ _, err := tx.ExecContext(ctx, query, level, username, repo)
321 return err
322 }
323
324diff --git a/testscript/testdata/repo-perms.txtar b/testscript/testdata/repo-perms.txtar
325index cf21151b55dde6e58e7853172aaad422bbb9e775..66341b9fecea85d043d3722bcc11aa89c2189fca 100644
326--- a/testscript/testdata/repo-perms.txtar
327+++ b/testscript/testdata/repo-perms.txtar
328@@ -60,7 +60,9 @@ stderr 'unauthorized'
329 stderr 'unauthorized'
330
331 # add user1 as collab
332-soft repo collab add repo1 user1
333+! soft repo collab add repo1 user1 foobar
334+stderr 'invalid access level'
335+soft repo collab add repo1 user1 read-write
336 soft repo collab list repo1
337 stdout user1
338 usoft repo collab list repo1