Przeglądaj źródła

invite backend done

Alexander Belanger 5 lat temu
rodzic
commit
6b809e354a

+ 1 - 0
cmd/app/main.go

@@ -57,6 +57,7 @@ func main() {
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.Infra{},
+		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 1 - 0
cmd/migrate/main.go

@@ -37,6 +37,7 @@ func main() {
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.Infra{},
+		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 28 - 0
internal/forms/invite.go

@@ -0,0 +1,28 @@
+package forms
+
+import (
+	"time"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+)
+
+// CreateInvite represents the accepted values for creating an
+// invite to a project
+type CreateInvite struct {
+	Email     string `json:"email" form:"required"`
+	ProjectID uint   `form:"required"`
+}
+
+// ToInvite converts the project to a gorm project model
+func (ci *CreateInvite) ToInvite() (*models.Invite, error) {
+	// generate a token and an expiry time
+	expiry := time.Now().Add(24 * time.Hour)
+
+	return &models.Invite{
+		Email:     ci.Email,
+		Expiry:    &expiry,
+		ProjectID: ci.ProjectID,
+		Token:     oauth.CreateRandomState(),
+	}, nil
+}

+ 46 - 0
internal/models/invite.go

@@ -0,0 +1,46 @@
+package models
+
+import (
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// Invite type that extends gorm.Model
+type Invite struct {
+	gorm.Model
+
+	Token  string `gorm:"unique"`
+	Expiry *time.Time
+	Email  string
+
+	ProjectID uint
+	UserID    uint
+}
+
+// InviteExternal represents the Invite type that is sent over REST
+type InviteExternal struct {
+	Token    string `json:"token"`
+	Expired  bool   `json:"expired"`
+	Email    string `json:"email"`
+	Accepted bool   `json:"accepted"`
+}
+
+// Externalize generates an external Invite to be shared over REST
+func (i *Invite) Externalize() *InviteExternal {
+	return &InviteExternal{
+		Token:    i.Token,
+		Email:    i.Email,
+		Expired:  i.IsExpired(),
+		Accepted: i.IsAccepted(),
+	}
+}
+
+func (i *Invite) IsExpired() bool {
+	timeLeft := i.Expiry.Sub(time.Now())
+	return timeLeft < 0
+}
+
+func (i *Invite) IsAccepted() bool {
+	return i.UserID != 0
+}

+ 3 - 0
internal/models/project.go

@@ -26,6 +26,9 @@ type Project struct {
 	// linked helm repos
 	HelmRepos []HelmRepo `json:"helm_repos"`
 
+	// invitations to the project
+	Invites []Invite `json:"invites"`
+
 	// provisioned aws infra
 	Infras []Infra `json:"infras"`
 

+ 28 - 0
internal/repository/gorm/helpers_test.go

@@ -3,6 +3,7 @@ package gorm_test
 import (
 	"os"
 	"testing"
+	"time"
 
 	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/config"
@@ -23,6 +24,7 @@ type tester struct {
 	initClusters []*models.Cluster
 	initHRs      []*models.HelmRepo
 	initInfras   []*models.Infra
+	initInvites  []*models.Invite
 	initCCs      []*models.ClusterCandidate
 	initKIs      []*ints.KubeIntegration
 	initBasics   []*ints.BasicIntegration
@@ -58,6 +60,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.Infra{},
+		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
@@ -457,3 +460,28 @@ func initInfra(tester *tester, t *testing.T) {
 
 	tester.initInfras = append(tester.initInfras, infra)
 }
+
+func initInvite(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	expiry := time.Now().Add(24 * time.Hour)
+
+	invite := &models.Invite{
+		Token:     "abcd",
+		Expiry:    &expiry,
+		Email:     "testing@test.it",
+		ProjectID: 1,
+	}
+
+	invite, err := tester.repo.Invite.CreateInvite(invite)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initInvites = append(tester.initInvites, invite)
+}

+ 98 - 0
internal/repository/gorm/invite.go

@@ -0,0 +1,98 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// InviteRepository uses gorm.DB for querying the database
+type InviteRepository struct {
+	db *gorm.DB
+}
+
+// NewInviteRepository returns a InviteRepository which uses
+// gorm.DB for querying the database
+func NewInviteRepository(db *gorm.DB) repository.InviteRepository {
+	return &InviteRepository{db}
+}
+
+// CreateInvite creates a new invite
+func (repo *InviteRepository) CreateInvite(invite *models.Invite) (*models.Invite, error) {
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", invite.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("Invites")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(invite); err != nil {
+		return nil, err
+	}
+
+	return invite, nil
+}
+
+// ReadInvite gets an invite specified by a unique id
+func (repo *InviteRepository) ReadInvite(id uint) (*models.Invite, error) {
+	invite := &models.Invite{}
+
+	if err := repo.db.Where("id = ?", id).First(&invite).Error; err != nil {
+		return nil, err
+	}
+
+	return invite, nil
+}
+
+// ReadInviteByToken gets an invite specified by a unique token
+func (repo *InviteRepository) ReadInviteByToken(token string) (*models.Invite, error) {
+	invite := &models.Invite{}
+
+	if err := repo.db.Where("token = ?", token).First(&invite).Error; err != nil {
+		return nil, err
+	}
+
+	return invite, nil
+}
+
+// ListInvitesByProjectID finds all invites
+// for a given project id
+func (repo *InviteRepository) ListInvitesByProjectID(
+	projectID uint,
+) ([]*models.Invite, error) {
+	invites := []*models.Invite{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&invites).Error; err != nil {
+		return nil, err
+	}
+
+	return invites, nil
+}
+
+// UpdateInvite updates an invitation in the DB
+func (repo *InviteRepository) UpdateInvite(
+	invite *models.Invite,
+) (*models.Invite, error) {
+	if err := repo.db.Save(invite).Error; err != nil {
+		return nil, err
+	}
+
+	return invite, nil
+}
+
+// DeleteInvite removes a registry from the db
+func (repo *InviteRepository) DeleteInvite(
+	invite *models.Invite,
+) error {
+	// clear TokenCache association
+	if err := repo.db.Where("id = ?", invite.ID).Delete(&models.Invite{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}

+ 100 - 0
internal/repository/gorm/invite_test.go

@@ -0,0 +1,100 @@
+package gorm_test
+
+import (
+	"testing"
+	"time"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+func TestCreateInvite(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_invite.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	expiry := time.Now().Add(24 * time.Hour)
+
+	invite := &models.Invite{
+		Token:     "abcd",
+		Expiry:    &expiry,
+		Email:     "testing@test.it",
+		ProjectID: 1,
+	}
+
+	invite, err := tester.repo.Invite.CreateInvite(invite)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	invite, err = tester.repo.Invite.ReadInvite(invite.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1, project id is 1 and token is "abcd"
+	if invite.Model.ID != 1 {
+		t.Errorf("incorrect invite ID: expected %d, got %d\n", 1, invite.Model.ID)
+	}
+
+	if invite.ProjectID != 1 {
+		t.Errorf("incorrect invite project ID: expected %d, got %d\n", 1, invite.ProjectID)
+	}
+
+	if invite.Token != "abcd" {
+		t.Errorf("incorrect token: expected %s, got %s\n", "abcd", invite.Token)
+	}
+
+	if invite.Email != "testing@test.it" {
+		t.Errorf("incorrect email: expected %s, got %s\n", "testing@test.it", invite.Email)
+	}
+}
+
+func TestListInvitesByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_invites.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initInvite(tester, t)
+	defer cleanup(tester, t)
+
+	invites, err := tester.repo.Invite.ListInvitesByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(invites) != 1 {
+		t.Fatalf("length of invites incorrect: expected %d, got %d\n", 1, len(invites))
+	}
+
+	// make sure data is correct
+	expInvite := models.Invite{
+		Token:     "abcd",
+		Email:     "testing@test.it",
+		Expiry:    &time.Time{},
+		ProjectID: 1,
+	}
+
+	invite := invites[0]
+	invite.Expiry = &time.Time{}
+
+	// reset fields for reflect.DeepEqual
+	invite.Model = gorm.Model{}
+
+	if diff := deep.Equal(expInvite, *invite); diff != nil {
+		t.Errorf("incorrect invite")
+		t.Error(diff)
+	}
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -18,6 +18,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		HelmRepo:         NewHelmRepoRepository(db, key),
 		Registry:         NewRegistryRepository(db, key),
 		Infra:            NewInfraRepository(db, key),
+		Invite:           NewInviteRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),

+ 15 - 0
internal/repository/invite.go

@@ -0,0 +1,15 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// InviteRepository represents the set of queries on the Invite model
+type InviteRepository interface {
+	CreateInvite(invite *models.Invite) (*models.Invite, error)
+	ReadInvite(id uint) (*models.Invite, error)
+	ReadInviteByToken(token string) (*models.Invite, error)
+	ListInvitesByProjectID(projectID uint) ([]*models.Invite, error)
+	UpdateInvite(invite *models.Invite) (*models.Invite, error)
+	DeleteInvite(invite *models.Invite) error
+}

+ 124 - 0
internal/repository/memory/invite.go

@@ -0,0 +1,124 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// InviteRepository uses gorm.DB for querying the database
+type InviteRepository struct {
+	canQuery bool
+	invites  []*models.Invite
+}
+
+// NewInviteRepository returns a InviteRepository which uses
+// gorm.DB for querying the database
+func NewInviteRepository(canQuery bool) repository.InviteRepository {
+	return &InviteRepository{canQuery, []*models.Invite{}}
+}
+
+// CreateInvite creates a new invite
+func (repo *InviteRepository) CreateInvite(invite *models.Invite) (*models.Invite, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.invites = append(repo.invites, invite)
+	invite.ID = uint(len(repo.invites))
+
+	return invite, nil
+}
+
+// ReadInvite gets an invite specified by a unique id
+func (repo *InviteRepository) ReadInvite(id uint) (*models.Invite, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.invites) || repo.invites[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.invites[index], nil
+}
+
+// ReadInviteByToken gets an invite specified by a unique token
+func (repo *InviteRepository) ReadInviteByToken(token string) (*models.Invite, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	var res *models.Invite
+
+	for _, invite := range repo.invites {
+		if token == invite.Token {
+			res = invite
+		}
+	}
+
+	if res == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	return res, nil
+}
+
+// ListInvitesByProjectID finds all invites
+// for a given project id
+func (repo *InviteRepository) ListInvitesByProjectID(
+	projectID uint,
+) ([]*models.Invite, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*models.Invite, 0)
+
+	for _, invite := range repo.invites {
+		if invite != nil && invite.ProjectID == projectID {
+			res = append(res, invite)
+		}
+	}
+
+	return res, nil
+}
+
+// UpdateInvite updates an invitation in the DB
+func (repo *InviteRepository) UpdateInvite(
+	invite *models.Invite,
+) (*models.Invite, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(invite.ID-1) >= len(repo.invites) || repo.invites[invite.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(invite.ID - 1)
+	repo.invites[index] = invite
+
+	return invite, nil
+}
+
+// DeleteInvite removes a registry from the db
+func (repo *InviteRepository) DeleteInvite(
+	invite *models.Invite,
+) error {
+	if !repo.canQuery {
+		return errors.New("Cannot write database")
+	}
+
+	if int(invite.ID-1) >= len(repo.invites) || repo.invites[invite.ID-1] == nil {
+		return gorm.ErrRecordNotFound
+	}
+
+	index := int(invite.ID - 1)
+	repo.invites[index] = nil
+
+	return nil
+}

+ 1 - 0
internal/repository/memory/repository.go

@@ -15,6 +15,7 @@ func NewRepository(canQuery bool) *repository.Repository {
 		HelmRepo:         NewHelmRepoRepository(canQuery),
 		Registry:         NewRegistryRepository(canQuery),
 		GitRepo:          NewGitRepoRepository(canQuery),
+		Invite:           NewInviteRepository(canQuery),
 		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
 		BasicIntegration: NewBasicIntegrationRepository(canQuery),
 		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),

+ 2 - 1
internal/repository/repository.go

@@ -10,7 +10,8 @@ type Repository struct {
 	Cluster          ClusterRepository
 	HelmRepo         HelmRepoRepository
 	Registry         RegistryRepository
-	Infra         InfraRepository
+	Infra            InfraRepository
+	Invite           InviteRepository
 	KubeIntegration  KubeIntegrationRepository
 	BasicIntegration BasicIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository

+ 239 - 0
server/api/invite_handler.go

@@ -0,0 +1,239 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// HandleCreateInvite creates a new invite for a project
+func (app *App) HandleCreateInvite(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateInvite{
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to an invite
+	invite, err := form.ToInvite()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	invite, err = app.Repo.Invite.CreateInvite(invite)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("New invite created: %d", invite.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	inviteExt := invite.Externalize()
+
+	if err := json.NewEncoder(w).Encode(inviteExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleAcceptInvite accepts an invite to a new project: if successful, a new role
+// is created for that user in the project
+func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	token := chi.URLParam(r, "token")
+
+	if token == "" {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	invite, err := app.Repo.Invite.ReadInviteByToken(token)
+
+	if err != nil || invite.ProjectID != uint(projID) {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Invalid invite token",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	// check that the invite has not expired and has not been accepted
+	if invite.IsExpired() || invite.IsAccepted() {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Invite has expired",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	// check that the invite email matches the user's email
+	if user.Email != invite.Email {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Cannot accept this invite",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	// create a new role for the user in the project
+	projModel, err := app.Repo.Project.ReadProject(uint(projID))
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	// create a new Role with the user as the admin
+	_, err = app.Repo.Project.CreateProjectRole(projModel, &models.Role{
+		UserID:    userID,
+		ProjectID: uint(projID),
+		Kind:      models.RoleAdmin,
+	})
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	// update the invite
+	invite.UserID = userID
+
+	_, err = app.Repo.Invite.UpdateInvite(invite)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	http.Redirect(w, r, "/dashboard", 302)
+	return
+}
+
+// HandleListProjectInvites returns a list of invites for a project
+func (app *App) HandleListProjectInvites(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	invites, err := app.Repo.Invite.ListInvitesByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	extInvites := make([]*models.InviteExternal, 0)
+
+	for _, invite := range invites {
+		extInvites = append(extInvites, invite.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(extInvites); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleDeleteProjectInvite handles the deletion of an Invite via the invite ID
+func (app *App) HandleDeleteProjectInvite(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "invite_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	invite, err := app.Repo.Invite.ReadInvite(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	err = app.Repo.Invite.DeleteInvite(invite)
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 263 - 0
server/api/invite_handler_test.go

@@ -0,0 +1,263 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type inviteTest struct {
+	initializers []func(t *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *inviteTest, tester *tester, t *testing.T)
+}
+
+func testInviteRequests(t *testing.T, tests []*inviteTest, canQuery bool) {
+	for _, c := range tests {
+		// create a new tester
+		tester := newTester(canQuery)
+
+		// if there's an initializer, call it
+		for _, init := range c.initializers {
+			init(tester)
+		}
+
+		req, err := http.NewRequest(
+			c.method,
+			c.endpoint,
+			strings.NewReader(c.body),
+		)
+
+		tester.req = req
+
+		if c.useCookie {
+			req.AddCookie(tester.cookie)
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		tester.execute()
+		rr := tester.rr
+
+		// first, check that the status matches
+		if status := rr.Code; status != c.expStatus {
+			t.Errorf("%s, handler returned wrong status code: got %v want %v",
+				c.msg, status, c.expStatus)
+		}
+
+		// if there's a validator, call it
+		for _, validate := range c.validators {
+			validate(c, tester, t)
+		}
+	}
+}
+
+// ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
+
+var createInviteTests = []*inviteTest{
+	&inviteTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:       "Create invite",
+		method:    "POST",
+		endpoint:  "/api/projects/1/invites",
+		body:      `{"email":"test@test.it"}`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"expired":false,"email":"test@test.it","accepted":false}`,
+		useCookie: true,
+		validators: []func(c *inviteTest, tester *tester, t *testing.T){
+			func(c *inviteTest, tester *tester, t *testing.T) {
+				// manually read the invite to get the expected token
+				invite, _ := tester.repo.Invite.ReadInvite(1)
+
+				gotBody := &models.InviteExternal{}
+				expBody := &models.InviteExternal{
+					Token: invite.Token,
+				}
+
+				json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+				json.Unmarshal([]byte(c.expBody), &expBody)
+
+				if diff := deep.Equal(gotBody, expBody); diff != nil {
+					t.Errorf("handler returned wrong body:\n")
+					t.Error(diff)
+				}
+			},
+		},
+	},
+}
+
+func TestHandleCreateInvite(t *testing.T) {
+	testInviteRequests(t, createInviteTests, true)
+}
+
+var listInvitesTest = []*inviteTest{
+	&inviteTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initInvite,
+		},
+		msg:       "List invites",
+		method:    "GET",
+		endpoint:  "/api/projects/1/invites",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `[{"expired":false,"email":"test@test.it","accepted":false}]`,
+		useCookie: true,
+		validators: []func(c *inviteTest, tester *tester, t *testing.T){
+			func(c *inviteTest, tester *tester, t *testing.T) {
+				// manually read the invite to get the expected token
+				invite, _ := tester.repo.Invite.ReadInvite(1)
+
+				gotBody := []*models.InviteExternal{}
+				expBody := []*models.InviteExternal{}
+
+				json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+				json.Unmarshal([]byte(c.expBody), &expBody)
+
+				expBody[0].Token = invite.Token
+
+				if diff := deep.Equal(gotBody, expBody); diff != nil {
+					t.Errorf("handler returned wrong body:\n")
+					t.Error(diff)
+				}
+			},
+		},
+	},
+}
+
+func TestHandleListInvites(t *testing.T) {
+	testInviteRequests(t, listInvitesTest, true)
+}
+
+var acceptInviteTests = []*inviteTest{
+	&inviteTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initUserAlt,
+			initProject,
+			initInvite,
+		},
+		msg:       "Accept invite",
+		method:    "GET",
+		endpoint:  "/api/projects/1/invites/abcd",
+		body:      ``,
+		expStatus: http.StatusFound,
+		expBody:   ``,
+		useCookie: true,
+		validators: []func(c *inviteTest, tester *tester, t *testing.T){
+			func(c *inviteTest, tester *tester, t *testing.T) {
+				user, err := tester.repo.User.ReadUserByEmail("test@test.it")
+
+				if err != nil {
+					t.Fatalf("%v\n", err)
+				}
+
+				projects, err := tester.repo.Project.ListProjectsByUserID(user.ID)
+
+				t.Errorf("%v\n", projects)
+			},
+		},
+	},
+	&inviteTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initUserAlt,
+			initProject,
+			initInvite,
+		},
+		msg:        "Accept invite wrong token",
+		method:     "GET",
+		endpoint:   "/api/projects/1/invites/abcd1",
+		body:       ``,
+		expStatus:  http.StatusForbidden,
+		expBody:    ``,
+		useCookie:  true,
+		validators: []func(c *inviteTest, tester *tester, t *testing.T){},
+	},
+	&inviteTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initInvite,
+		},
+		msg:        "Accept invite wrong user",
+		method:     "GET",
+		endpoint:   "/api/projects/1/invites/abcd",
+		body:       ``,
+		expStatus:  http.StatusForbidden,
+		expBody:    ``,
+		useCookie:  true,
+		validators: []func(c *inviteTest, tester *tester, t *testing.T){},
+	},
+	&inviteTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initUserAlt,
+			initProject,
+			initInviteExpiredToken,
+		},
+		msg:        "Accept invite expired token",
+		method:     "GET",
+		endpoint:   "/api/projects/1/invites/abcd",
+		body:       ``,
+		expStatus:  http.StatusForbidden,
+		expBody:    ``,
+		useCookie:  true,
+		validators: []func(c *inviteTest, tester *tester, t *testing.T){},
+	},
+}
+
+func TestHandleAcceptInvite(t *testing.T) {
+	testInviteRequests(t, acceptInviteTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initInvite(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	expiry := time.Now().Add(24 * time.Hour)
+
+	invite := &models.Invite{
+		Token:     "abcd",
+		Expiry:    &expiry,
+		Email:     "test@test.it",
+		ProjectID: proj.Model.ID,
+	}
+
+	tester.repo.Invite.CreateInvite(invite)
+}
+
+func initInviteExpiredToken(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	expiry := time.Now().Add(-1 * time.Hour)
+
+	invite := &models.Invite{
+		Token:     "abcd",
+		Expiry:    &expiry,
+		Email:     "belanger@getporter.dev",
+		ProjectID: proj.Model.ID,
+	}
+
+	tester.repo.Invite.CreateInvite(invite)
+}

+ 4 - 0
server/api/user_handler_test.go

@@ -487,6 +487,10 @@ func initUserDefault(tester *tester) {
 	tester.createUserSession("belanger@getporter.dev", "hello")
 }
 
+func initUserAlt(tester *tester) {
+	tester.createUserSession("test@test.it", "hello")
+}
+
 func userBasicBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	if body := tester.rr.Body.String(); strings.TrimSpace(body) != strings.TrimSpace(c.expBody) {
 		t.Errorf("%s, handler returned wrong body: got %v want %v",

+ 99 - 0
server/router/middleware/auth.go

@@ -82,6 +82,10 @@ type bodyInfraID struct {
 	InfraID uint64 `json:"infra_id"`
 }
 
+type bodyInviteID struct {
+	InviteID uint64 `json:"invite_id"`
+}
+
 type bodyAWSIntegrationID struct {
 	AWSIntegrationID uint64 `json:"aws_integration_id"`
 }
@@ -230,6 +234,56 @@ func (auth *Auth) DoesUserHaveClusterAccess(
 	})
 }
 
+// DoesUserHaveInviteAccess looks for a project_id parameter and a
+// invite_id parameter, and verifies that the invite belongs
+// to the project
+func (auth *Auth) DoesUserHaveInviteAccess(
+	next http.Handler,
+	projLoc IDLocation,
+	inviteLoc IDLocation,
+) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		inviteID, err := findInviteIDInRequest(r, inviteLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		projID, err := findProjIDInRequest(r, projLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		// get the service accounts belonging to the project
+		invites, err := auth.repo.Invite.ListInvitesByProjectID(uint(projID))
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return
+		}
+
+		doesExist := false
+
+		for _, invite := range invites {
+			if invite.ID == uint(inviteID) {
+				doesExist = true
+				break
+			}
+		}
+
+		if doesExist {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	})
+}
+
 // DoesUserHaveRegistryAccess looks for a project_id parameter and a
 // registry_id parameter, and verifies that the registry belongs
 // to the project
@@ -706,6 +760,51 @@ func findClusterIDInRequest(r *http.Request, clusterLoc IDLocation) (uint64, err
 	return clusterID, nil
 }
 
+func findInviteIDInRequest(r *http.Request, inviteLoc IDLocation) (uint64, error) {
+	var inviteID uint64
+	var err error
+
+	if inviteLoc == URLParam {
+		inviteID, err = strconv.ParseUint(chi.URLParam(r, "invite_id"), 0, 64)
+
+		if err != nil {
+			return 0, err
+		}
+	} else if inviteLoc == BodyParam {
+		form := &bodyInviteID{}
+		body, err := ioutil.ReadAll(r.Body)
+
+		if err != nil {
+			return 0, err
+		}
+
+		err = json.Unmarshal(body, form)
+
+		if err != nil {
+			return 0, err
+		}
+
+		inviteID = form.InviteID
+
+		// need to create a new stream for the body
+		r.Body = ioutil.NopCloser(bytes.NewReader(body))
+	} else {
+		vals, err := url.ParseQuery(r.URL.RawQuery)
+
+		if err != nil {
+			return 0, err
+		}
+
+		if invStrArr, ok := vals["invite_id"]; ok && len(invStrArr) == 1 {
+			inviteID, err = strconv.ParseUint(invStrArr[0], 10, 64)
+		} else {
+			return 0, errors.New("invite id not found")
+		}
+	}
+
+	return inviteID, nil
+}
+
 func findRegistryIDInRequest(r *http.Request, registryLoc IDLocation) (uint64, error) {
 	var regID uint64
 	var err error

+ 43 - 0
server/router/router.go

@@ -193,6 +193,49 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		// /api/projects/{project_id}/invites routes
+		r.Method(
+			"POST",
+			"/projects/{project_id}/invites",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleCreateInvite, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/invites",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListProjectInvites, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/invites/{token}",
+			auth.BasicAuthenticate(
+				requestlog.NewHandler(a.HandleAcceptInvite, l),
+			),
+		)
+
+		r.Method(
+			"DELETE",
+			"/projects/{project_id}/invites/{invite_id}",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveInviteAccess(
+					requestlog.NewHandler(a.HandleDeleteProjectInvite, l),
+					mw.URLParam,
+					mw.URLParam,
+				),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/infra routes
 		r.Method(
 			"GET",