feat: add collaborators with access level

Ayman Bagabas created

Now you can add a collaborator with a specific access level

Fixes: https://github.com/charmbracelet/soft-serve/issues/281

Change summary

cmd/soft/migrate_config.go                           |  4 +-
server/access/access.go                              | 28 ++++++++++++++
server/backend/collab.go                             | 15 ++++---
server/backend/user.go                               |  8 ++--
server/db/migrate/0001_create_tables.go              |  5 +-
server/db/migrate/0001_create_tables_postgres.up.sql |  1 
server/db/migrate/0001_create_tables_sqlite.up.sql   |  1 
server/db/models/collab.go                           | 17 +++++---
server/ssh/cmd/collab.go                             | 15 ++++++-
server/store/collab.go                               |  3 +
server/store/database/collab.go                      |  8 ++-
testscript/testdata/repo-perms.txtar                 |  4 +
12 files changed, 80 insertions(+), 29 deletions(-)

Detailed changes

cmd/soft/migrate_config.go 🔗

@@ -273,7 +273,7 @@ var migrateConfig = &cobra.Command{
 			}
 
 			for _, collab := range r.Collabs {
-				if err := sb.AddCollaborator(ctx, repo, collab); err != nil {
+				if err := sb.AddCollaborator(ctx, repo, collab, access.ReadWriteAccess); err != nil {
 					logger.Errorf("failed to add repo collab to %s: %s", repo, err)
 				}
 			}
@@ -308,7 +308,7 @@ var migrateConfig = &cobra.Command{
 			}
 
 			for _, repo := range user.CollabRepos {
-				if err := sb.AddCollaborator(ctx, repo, username); err != nil {
+				if err := sb.AddCollaborator(ctx, repo, username, access.ReadWriteAccess); err != nil {
 					logger.Errorf("failed to add user collab to %s: %s\n", repo, err)
 				}
 			}

server/access/access.go 🔗

@@ -1,5 +1,10 @@
 package access
 
+import (
+	"encoding"
+	"errors"
+)
+
 // AccessLevel is the level of access allowed to a repo.
 type AccessLevel int // nolint: revive
 
@@ -48,3 +53,26 @@ func ParseAccessLevel(s string) AccessLevel {
 		return AccessLevel(-1)
 	}
 }
+
+var _ encoding.TextMarshaler = AccessLevel(0)
+var _ encoding.TextUnmarshaler = (*AccessLevel)(nil)
+
+// ErrInvalidAccessLevel is returned when an invalid access level is provided.
+var ErrInvalidAccessLevel = errors.New("invalid access level")
+
+// UnmarshalText implements encoding.TextUnmarshaler.
+func (a *AccessLevel) UnmarshalText(text []byte) error {
+	l := ParseAccessLevel(string(text))
+	if l < 0 {
+		return ErrInvalidAccessLevel
+	}
+
+	*a = l
+
+	return nil
+}
+
+// MarshalText implements encoding.TextMarshaler.
+func (a AccessLevel) MarshalText() (text []byte, err error) {
+	return []byte(a.String()), nil
+}

server/backend/collab.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	"strings"
 
+	"github.com/charmbracelet/soft-serve/server/access"
 	"github.com/charmbracelet/soft-serve/server/db"
 	"github.com/charmbracelet/soft-serve/server/db/models"
 	"github.com/charmbracelet/soft-serve/server/utils"
@@ -12,7 +13,7 @@ import (
 // AddCollaborator adds a collaborator to a repository.
 //
 // It implements backend.Backend.
-func (d *Backend) AddCollaborator(ctx context.Context, repo string, username string) error {
+func (d *Backend) AddCollaborator(ctx context.Context, repo string, username string, level access.AccessLevel) error {
 	username = strings.ToLower(username)
 	if err := utils.ValidateUsername(username); err != nil {
 		return err
@@ -21,7 +22,7 @@ func (d *Backend) AddCollaborator(ctx context.Context, repo string, username str
 	repo = utils.SanitizeRepo(repo)
 	return db.WrapError(
 		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
-			return d.store.AddCollabByUsernameAndRepo(ctx, tx, username, repo)
+			return d.store.AddCollabByUsernameAndRepo(ctx, tx, username, repo, level)
 		}),
 	)
 }
@@ -48,12 +49,12 @@ func (d *Backend) Collaborators(ctx context.Context, repo string) ([]string, err
 	return usernames, nil
 }
 
-// IsCollaborator returns true if the user is a collaborator of the repository.
+// IsCollaborator returns the access level and true if the user is a collaborator of the repository.
 //
 // It implements backend.Backend.
-func (d *Backend) IsCollaborator(ctx context.Context, repo string, username string) (bool, error) {
+func (d *Backend) IsCollaborator(ctx context.Context, repo string, username string) (access.AccessLevel, bool, error) {
 	if username == "" {
-		return false, nil
+		return -1, false, nil
 	}
 
 	repo = utils.SanitizeRepo(repo)
@@ -63,10 +64,10 @@ func (d *Backend) IsCollaborator(ctx context.Context, repo string, username stri
 		m, err = d.store.GetCollabByUsernameAndRepo(ctx, tx, username, repo)
 		return err
 	}); err != nil {
-		return false, db.WrapError(err)
+		return -1, false, db.WrapError(err)
 	}
 
-	return m.ID > 0, nil
+	return m.AccessLevel, m.ID > 0, nil
 }
 
 // RemoveCollaborator removes a collaborator from a repository.

server/backend/user.go 🔗

@@ -69,13 +69,13 @@ func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user prot
 			}
 		}
 
-		// If the user is a collaborator, they have read/write access.
-		isCollab, _ := d.IsCollaborator(ctx, repo, username)
+		// If the user is a collaborator, they have return their access level.
+		collabAccess, isCollab, _ := d.IsCollaborator(ctx, repo, username)
 		if isCollab {
-			if anon > access.ReadWriteAccess {
+			if anon > collabAccess {
 				return anon
 			}
-			return access.ReadWriteAccess
+			return collabAccess
 		}
 
 		// If the repository is private, the user has no access.

server/db/migrate/0001_create_tables.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"strconv"
 
 	"github.com/charmbracelet/soft-serve/server/access"
 	"github.com/charmbracelet/soft-serve/server/config"
@@ -118,8 +119,8 @@ var createTables = Migration{
 
 			if hasTable(tx, "collab_old") {
 				sqlm := `
-				INSERT INTO collabs (id, user_id, repo_id, created_at, updated_at)
-					SELECT id, user_id, repo_id, created_at, updated_at FROM collab_old;
+				INSERT INTO collabs (id, user_id, repo_id, access_level, created_at, updated_at)
+					SELECT id, user_id, repo_id, ` + strconv.Itoa(int(access.ReadWriteAccess)) + `, created_at, updated_at FROM collab_old;
 				`
 				if _, err := tx.ExecContext(ctx, sqlm); err != nil {
 					return err

server/db/migrate/0001_create_tables_postgres.up.sql 🔗

@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS collabs (
   id SERIAL PRIMARY KEY,
   user_id INTEGER NOT NULL,
   repo_id INTEGER NOT NULL,
+  access_level INTEGER NOT NULL,
   created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
   updated_at TIMESTAMP NOT NULL,
   UNIQUE (user_id, repo_id),

server/db/migrate/0001_create_tables_sqlite.up.sql 🔗

@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS collabs (
   id INTEGER PRIMARY KEY AUTOINCREMENT,
   user_id INTEGER NOT NULL,
   repo_id INTEGER NOT NULL,
+  access_level INTEGER NOT NULL,
   created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
   updated_at DATETIME NOT NULL,
   UNIQUE (user_id, repo_id),

server/db/models/collab.go 🔗

@@ -1,12 +1,17 @@
 package models
 
-import "time"
+import (
+	"time"
+
+	"github.com/charmbracelet/soft-serve/server/access"
+)
 
 // Collab represents a repository collaborator.
 type Collab struct {
-	ID        int64     `db:"id"`
-	RepoID    int64     `db:"repo_id"`
-	UserID    int64     `db:"user_id"`
-	CreatedAt time.Time `db:"created_at"`
-	UpdatedAt time.Time `db:"updated_at"`
+	ID          int64              `db:"id"`
+	RepoID      int64              `db:"repo_id"`
+	UserID      int64              `db:"user_id"`
+	AccessLevel access.AccessLevel `db:"access_level"`
+	CreatedAt   time.Time          `db:"created_at"`
+	UpdatedAt   time.Time          `db:"updated_at"`
 }

server/ssh/cmd/collab.go 🔗

@@ -1,6 +1,7 @@
 package cmd
 
 import (
+	"github.com/charmbracelet/soft-serve/server/access"
 	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/spf13/cobra"
 )
@@ -23,17 +24,25 @@ func collabCommand() *cobra.Command {
 
 func collabAddCommand() *cobra.Command {
 	cmd := &cobra.Command{
-		Use:               "add REPOSITORY USERNAME",
+		Use:               "add REPOSITORY USERNAME [LEVEL]",
 		Short:             "Add a collaborator to a repo",
-		Args:              cobra.ExactArgs(2),
+		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.",
+		Args:              cobra.RangeArgs(2, 3),
 		PersistentPreRunE: checkIfCollab,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
 			be := backend.FromContext(ctx)
 			repo := args[0]
 			username := args[1]
+			level := access.ReadWriteAccess
+			if len(args) > 2 {
+				level = access.ParseAccessLevel(args[2])
+				if level < 0 {
+					return access.ErrInvalidAccessLevel
+				}
+			}
 
-			return be.AddCollaborator(ctx, repo, username)
+			return be.AddCollaborator(ctx, repo, username, level)
 		},
 	}
 

server/store/collab.go 🔗

@@ -3,6 +3,7 @@ package store
 import (
 	"context"
 
+	"github.com/charmbracelet/soft-serve/server/access"
 	"github.com/charmbracelet/soft-serve/server/db"
 	"github.com/charmbracelet/soft-serve/server/db/models"
 )
@@ -10,7 +11,7 @@ import (
 // CollaboratorStore is an interface for managing collaborators.
 type CollaboratorStore interface {
 	GetCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) (models.Collab, error)
-	AddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
+	AddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string, level access.AccessLevel) error
 	RemoveCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
 	ListCollabsByRepo(ctx context.Context, h db.Handler, repo string) ([]models.Collab, error)
 	ListCollabsByRepoAsUsers(ctx context.Context, h db.Handler, repo string) ([]models.User, error)

server/store/database/collab.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	"strings"
 
+	"github.com/charmbracelet/soft-serve/server/access"
 	"github.com/charmbracelet/soft-serve/server/db"
 	"github.com/charmbracelet/soft-serve/server/db/models"
 	"github.com/charmbracelet/soft-serve/server/store"
@@ -15,7 +16,7 @@ type collabStore struct{}
 var _ store.CollaboratorStore = (*collabStore)(nil)
 
 // AddCollabByUsernameAndRepo implements store.CollaboratorStore.
-func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) error {
+func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string, level access.AccessLevel) error {
 	username = strings.ToLower(username)
 	if err := utils.ValidateUsername(username); err != nil {
 		return err
@@ -23,8 +24,9 @@ func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx db.Handle
 
 	repo = utils.SanitizeRepo(repo)
 
-	query := tx.Rebind(`INSERT INTO collabs (user_id, repo_id, updated_at)
+	query := tx.Rebind(`INSERT INTO collabs (access_level, user_id, repo_id, updated_at)
 			VALUES (
+				?,
 				(
 					SELECT id FROM users WHERE username = ?
 				),
@@ -33,7 +35,7 @@ func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx db.Handle
 				),
 				CURRENT_TIMESTAMP
 			);`)
-	_, err := tx.ExecContext(ctx, query, username, repo)
+	_, err := tx.ExecContext(ctx, query, level, username, repo)
 	return err
 }
 

testscript/testdata/repo-perms.txtar 🔗

@@ -60,7 +60,9 @@ stderr 'unauthorized'
 stderr 'unauthorized'
 
 # add user1 as collab
-soft repo collab add repo1 user1
+! soft repo collab add repo1 user1 foobar
+stderr 'invalid access level'
+soft repo collab add repo1 user1 read-write
 soft repo collab list repo1
 stdout user1
 usoft repo collab list repo1