Просмотр исходного кода

Merge branch 'nico/rbac-crud-operations' into dev

Mohammed Nafees 3 лет назад
Родитель
Сommit
866939f854
56 измененных файлов с 2780 добавлено и 285 удалено
  1. 32 20
      api/server/authz/policy/loader.go
  2. 1 1
      api/server/handlers/policy/create.go
  3. 76 32
      api/server/handlers/project/create.go
  4. 21 0
      api/server/handlers/project/delete.go
  5. 2 2
      api/server/handlers/project/delete_role.go
  6. 1 1
      api/server/handlers/project/get_policy.go
  7. 54 23
      api/server/handlers/project/list_collaborators.go
  8. 2 2
      api/server/handlers/project/update_role.go
  9. 116 0
      api/server/handlers/project_role/create.go
  10. 88 0
      api/server/handlers/project_role/delete.go
  11. 69 0
      api/server/handlers/project_role/get.go
  12. 28 0
      api/server/handlers/project_role/get_scope_hierarchy.go
  13. 67 0
      api/server/handlers/project_role/list.go
  14. 121 0
      api/server/handlers/project_role/update.go
  15. 89 8
      api/server/handlers/user/create.go
  16. 233 0
      api/server/router/project_role.go
  17. 3 1
      api/server/router/router.go
  18. 12 9
      api/types/invite.go
  19. 28 16
      api/types/policy.go
  20. 7 6
      api/types/project.go
  21. 22 0
      api/types/project_role.go
  22. 28 0
      cmd/migrate/main.go
  23. 184 0
      cmd/migrate/migrate_legacy_rbac/migrate.go
  24. 7 9
      dashboard/src/main/home/dashboard/Dashboard.tsx
  25. 38 5
      dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx
  26. 1 0
      dashboard/src/main/home/project-settings/BillingPage.tsx
  27. 117 10
      dashboard/src/main/home/project-settings/InviteList.tsx
  28. 96 92
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  29. 30 0
      dashboard/src/main/home/project-settings/roles-admin/RolesAdmin.tsx
  30. 204 0
      dashboard/src/main/home/project-settings/roles-admin/Store.tsx
  31. 218 0
      dashboard/src/main/home/project-settings/roles-admin/components/PolicyDocumentRenderer.tsx
  32. 1 0
      dashboard/src/main/home/project-settings/roles-admin/index.ts
  33. 130 0
      dashboard/src/main/home/project-settings/roles-admin/pages/CreateRole.tsx
  34. 117 0
      dashboard/src/main/home/project-settings/roles-admin/pages/EditRole.tsx
  35. 38 0
      dashboard/src/main/home/project-settings/roles-admin/pages/ListRoles.tsx
  36. 14 0
      dashboard/src/main/home/project-settings/roles-admin/types.ts
  37. 89 1
      dashboard/src/shared/api.tsx
  38. 8 5
      dashboard/src/shared/auth/authorization-helpers.ts
  39. 2 0
      dashboard/src/shared/types.tsx
  40. 60 14
      ee/api/server/handlers/invite/accept.go
  41. 22 2
      ee/api/server/handlers/invite/create.go
  42. 2 1
      ee/api/server/handlers/invite/list.go
  43. 37 4
      ee/api/server/handlers/invite/update_role.go
  44. 5 3
      internal/models/invite.go
  45. 41 0
      internal/models/project_role.go
  46. 1 0
      internal/repository/gorm/migrate.go
  47. 7 7
      internal/repository/gorm/project.go
  48. 128 0
      internal/repository/gorm/project_role.go
  49. 6 0
      internal/repository/gorm/repository.go
  50. 5 5
      internal/repository/project.go
  51. 15 0
      internal/repository/project_role.go
  52. 1 0
      internal/repository/repository.go
  53. 5 5
      internal/repository/test/project.go
  54. 45 0
      internal/repository/test/project_role.go
  55. 5 0
      internal/repository/test/repository.go
  56. 1 1
      internal/usage/usage.go

+ 32 - 20
api/server/authz/policy/loader.go

@@ -23,12 +23,12 @@ type PolicyDocumentLoader interface {
 
 // RepoPolicyDocumentLoader loads policy documents by reading from the repository database
 type RepoPolicyDocumentLoader struct {
-	projRepo   repository.ProjectRepository
-	policyRepo repository.PolicyRepository
+	projRoleRepo repository.ProjectRoleRepository
+	policyRepo   repository.PolicyRepository
 }
 
-func NewBasicPolicyDocumentLoader(projRepo repository.ProjectRepository, policyRepo repository.PolicyRepository) *RepoPolicyDocumentLoader {
-	return &RepoPolicyDocumentLoader{projRepo, policyRepo}
+func NewBasicPolicyDocumentLoader(projRoleRepo repository.ProjectRoleRepository, policyRepo repository.PolicyRepository) *RepoPolicyDocumentLoader {
+	return &RepoPolicyDocumentLoader{projRoleRepo, policyRepo}
 }
 
 func (b *RepoPolicyDocumentLoader) LoadPolicyDocuments(
@@ -51,30 +51,42 @@ func (b *RepoPolicyDocumentLoader) LoadPolicyDocuments(
 	} else if opts.ProjectID != 0 && opts.UserID != 0 {
 		userID := opts.UserID
 		projectID := opts.ProjectID
-		// read role and case on role "kind"
-		role, err := b.projRepo.ReadProjectRole(projectID, userID)
 
-		if err != nil && err == gorm.ErrRecordNotFound {
+		roles, err := b.projRoleRepo.ListAllRolesForUser(projectID, userID)
+
+		if err != nil {
+			return nil, apierrors.NewErrInternal(err)
+		} else if len(roles) == 0 {
 			return nil, apierrors.NewErrForbidden(
-				fmt.Errorf("user %d does not have a role in project %d", userID, projectID),
+				fmt.Errorf("user does not have any roles assigned in this project"),
 			)
-		} else if err != nil {
-			return nil, apierrors.NewErrInternal(err)
 		}
 
-		// load role based on role kind
-		switch role.Kind {
-		case types.RoleAdmin:
-			return types.AdminPolicy, nil
-		case types.RoleDeveloper:
-			return types.DeveloperPolicy, nil
-		case types.RoleViewer:
-			return types.ViewerPolicy, nil
-		default:
+		var policies []*types.PolicyDocument
+
+		for _, role := range roles {
+			policy, err := b.policyRepo.ReadPolicy(projectID, role.PolicyUID)
+
+			if err != nil {
+				return nil, apierrors.NewErrInternal(err)
+			}
+
+			policyType, err := policy.ToAPIPolicyType()
+
+			if err != nil {
+				return nil, apierrors.NewErrInternal(err)
+			}
+
+			policies = append(policies, policyType.Policy...)
+		}
+
+		if len(policies) == 0 {
 			return nil, apierrors.NewErrForbidden(
-				fmt.Errorf("%s role not supported for user %d, project %d", string(role.Kind), userID, projectID),
+				fmt.Errorf("user does not have any roles assigned in this project"),
 			)
 		}
+
+		return policies, nil
 	}
 
 	return nil, apierrors.NewErrForbidden(

+ 1 - 1
api/server/handlers/policy/create.go

@@ -33,7 +33,7 @@ func (p *PolicyCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
-	req := &types.CreatePolicy{}
+	req := &types.CreatePolicyRequest{}
 
 	if ok := p.DecodeAndValidate(w, r, req); !ok {
 		return

+ 76 - 32
api/server/handlers/project/create.go

@@ -1,6 +1,8 @@
 package project
 
 import (
+	"encoding/json"
+	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -9,6 +11,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/analytics"
+	"github.com/porter-dev/porter/internal/encryption"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 )
@@ -43,14 +46,23 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Name: request.Name,
 	}
 
-	var err error
-	proj, _, err = CreateProjectWithUser(p.Repo().Project(), proj, user)
+	proj, err := p.Repo().Project().CreateProject(proj)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	err = createDefaultProjectRoles(proj.ID, user.ID, p.Repo())
+
+	if err != nil {
+		// we need to first delete the default project roles we just created
+		deleteAllProjectRoles(proj.ID, p.Repo())
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	// create onboarding flow set to the first step
 	_, err = p.Repo().Onboarding().CreateProjectOnboarding(&models.Onboarding{
 		ProjectID:   proj.ID,
@@ -92,36 +104,68 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}))
 }
 
-func CreateProjectWithUser(
-	projectRepo repository.ProjectRepository,
-	proj *models.Project,
-	user *models.User,
-) (*models.Project, *models.Role, error) {
-	proj, err := projectRepo.CreateProject(proj)
-
-	if err != nil {
-		return nil, nil, err
-	}
-
-	// create a new Role with the user as the admin
-	role, err := projectRepo.CreateProjectRole(proj, &models.Role{
-		Role: types.Role{
-			UserID:    user.ID,
-			ProjectID: proj.ID,
-			Kind:      types.RoleAdmin,
-		},
-	})
-
-	if err != nil {
-		return nil, nil, err
-	}
-
-	// read the project again to get the model with the role attached
-	proj, err = projectRepo.ReadProject(proj.ID)
-
-	if err != nil {
-		return nil, nil, err
+func createDefaultProjectRoles(projectID, userID uint, repo repository.Repository) error {
+	for _, kind := range []types.RoleKind{types.RoleAdmin, types.RoleDeveloper, types.RoleViewer} {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return err
+		}
+
+		var policyBytes []byte
+
+		switch kind {
+		case types.RoleAdmin:
+			policyBytes, err = json.Marshal(types.AdminPolicy)
+
+			if err != nil {
+				return err
+			}
+		case types.RoleDeveloper:
+			policyBytes, err = json.Marshal(types.DeveloperPolicy)
+
+			if err != nil {
+				return err
+			}
+		case types.RoleViewer:
+			policyBytes, err = json.Marshal(types.ViewerPolicy)
+
+			if err != nil {
+				return err
+			}
+		}
+
+		policy, err := repo.Policy().CreatePolicy(&models.Policy{
+			UniqueID:        uid,
+			ProjectID:       projectID,
+			CreatedByUserID: userID,
+			Name:            fmt.Sprintf("%s-project-role-policy", kind),
+			PolicyBytes:     policyBytes,
+		})
+
+		if err != nil {
+			return err
+		}
+
+		role, err := repo.ProjectRole().CreateProjectRole(&models.ProjectRole{
+			UniqueID:  fmt.Sprintf("%d-%s", projectID, kind),
+			ProjectID: projectID,
+			PolicyUID: policy.UniqueID,
+			Name:      string(kind),
+		})
+
+		if err != nil {
+			return err
+		}
+
+		if kind == types.RoleAdmin {
+			err := repo.ProjectRole().UpdateUsersInProjectRole(projectID, role.UniqueID, []uint{userID})
+
+			if err != nil {
+				return err
+			}
+		}
 	}
 
-	return proj, role, nil
+	return nil
 }

+ 21 - 0
api/server/handlers/project/delete.go

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
 )
 
 type ProjectDeleteHandler struct {
@@ -28,6 +29,8 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
+	deleteAllProjectRoles(proj.ID, p.Repo())
+
 	proj, err := p.Repo().Project().DeleteProject(proj)
 
 	if err != nil {
@@ -44,3 +47,21 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
 	}
 }
+
+func deleteAllProjectRoles(projectID uint, repo repository.Repository) {
+	policies, err := repo.Policy().ListPoliciesByProjectID(projectID)
+
+	if err == nil {
+		for _, policy := range policies {
+			repo.Policy().DeletePolicy(policy)
+		}
+	}
+
+	roles, err := repo.ProjectRole().ListProjectRoles(projectID)
+
+	if err == nil {
+		for _, role := range roles {
+			repo.ProjectRole().DeleteProjectRole(role)
+		}
+	}
+}

+ 2 - 2
api/server/handlers/project/delete_role.go

@@ -36,14 +36,14 @@ func (p *RoleDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	role, err := p.Repo().Project().ReadProjectRole(proj.ID, request.UserID)
+	role, err := p.Repo().Project().ReadLegacyProjectRole(proj.ID, request.UserID)
 
 	if err != nil {
 		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
 		return
 	}
 
-	role, err = p.Repo().Project().DeleteProjectRole(proj.ID, request.UserID)
+	role, err = p.Repo().Project().DeleteLegacyProjectRole(proj.ID, request.UserID)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 1 - 1
api/server/handlers/project/get_policy.go

@@ -30,7 +30,7 @@ func (p *ProjectGetPolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
-	policyDocLoader := policy.NewBasicPolicyDocumentLoader(p.Config().Repo.Project(), p.Config().Repo.Policy())
+	policyDocLoader := policy.NewBasicPolicyDocumentLoader(p.Config().Repo.ProjectRole(), p.Config().Repo.Policy())
 
 	policyDocs, err := policyDocLoader.LoadPolicyDocuments(&policy.PolicyLoaderOpts{
 		UserID:    user.ID,

+ 54 - 23
api/server/handlers/project/list_collaborators.go

@@ -1,9 +1,10 @@
 package project
 
 import (
-	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"net/http"
 
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
@@ -27,39 +28,69 @@ func NewCollaboratorsListHandler(
 func (p *CollaboratorsListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
-	roles, err := p.Repo().Project().ListProjectRoles(proj.ID)
+	var res types.ListCollaboratorsResponse
+
+	roles, err := p.Repo().ProjectRole().ListProjectRoles(proj.ID)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	roleMap := make(map[uint]*models.Role)
-	idArr := make([]uint, 0)
+	if len(roles) > 0 {
+		userCollaboratorMap := make(map[uint]*types.Collaborator)
 
-	for _, role := range roles {
-		roleCp := role
-		roleMap[role.UserID] = &roleCp
-		idArr = append(idArr, role.UserID)
-	}
+		for _, role := range roles {
+			for _, user := range role.Users {
+				if _, ok := userCollaboratorMap[user.ID]; ok {
+					userCollaboratorMap[user.ID].RoleUIDs = append(userCollaboratorMap[user.ID].RoleUIDs, role.UniqueID)
+				} else {
+					userCollaboratorMap[user.ID] = &types.Collaborator{
+						RoleUIDs:  []string{role.UniqueID},
+						UserID:    user.ID,
+						Email:     user.Email,
+						ProjectID: proj.ID,
+					}
+				}
+			}
+		}
 
-	users, err := p.Repo().User().ListUsersByIDs(idArr)
+		for _, user := range userCollaboratorMap {
+			res = append(res, user)
+		}
+	} else { // legacy operation
+		legacyRoles, err := p.Repo().Project().ListLegacyProjectRoles(proj.ID)
 
-	if err != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		roleMap := make(map[uint]*models.Role)
+		idArr := make([]uint, 0)
+
+		for _, role := range legacyRoles {
+			roleCp := role
+			roleMap[role.UserID] = &roleCp
+			idArr = append(idArr, role.UserID)
+		}
+
+		users, err := p.Repo().User().ListUsersByIDs(idArr)
 
-	var res types.ListCollaboratorsResponse = make([]*types.Collaborator, 0)
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 
-	for _, user := range users {
-		res = append(res, &types.Collaborator{
-			ID:        roleMap[user.ID].ID,
-			Kind:      string(roleMap[user.ID].Kind),
-			UserID:    roleMap[user.ID].UserID,
-			Email:     user.Email,
-			ProjectID: roleMap[user.ID].ProjectID,
-		})
+		for _, user := range users {
+			res = append(res, &types.Collaborator{
+				ID:        roleMap[user.ID].ID,
+				Kind:      string(roleMap[user.ID].Kind),
+				UserID:    roleMap[user.ID].UserID,
+				Email:     user.Email,
+				ProjectID: roleMap[user.ID].ProjectID,
+			})
+		}
 	}
 
 	p.WriteResult(w, r, res)

+ 2 - 2
api/server/handlers/project/update_role.go

@@ -36,7 +36,7 @@ func (p *RoleUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	role, err := p.Repo().Project().ReadProjectRole(proj.ID, request.UserID)
+	role, err := p.Repo().Project().ReadLegacyProjectRole(proj.ID, request.UserID)
 
 	if err != nil {
 		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
@@ -45,7 +45,7 @@ func (p *RoleUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	role.Kind = types.RoleKind(request.Kind)
 
-	role, err = p.Repo().Project().UpdateProjectRole(proj.ID, role)
+	role, err = p.Repo().Project().UpdateLegacyProjectRole(proj.ID, role)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 116 - 0
api/server/handlers/project_role/create.go

@@ -0,0 +1,116 @@
+package project_role
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/encryption"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CreateProjectRoleHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewCreateProjectRoleHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateProjectRoleHandler {
+	return &CreateProjectRoleHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *CreateProjectRoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	request := &types.CreateProjectRoleRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if request.Name == string(types.RoleAdmin) ||
+		request.Name == string(types.RoleDeveloper) ||
+		request.Name == string(types.RoleViewer) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("default role names admin, developer, viewer are not allowed"), http.StatusConflict,
+		))
+		return
+	}
+
+	uid, err := encryption.GenerateRandomBytes(16)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	policyBytes, err := json.Marshal([]*types.PolicyDocument{request.Policy})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	policy, err := c.Repo().Policy().CreatePolicy(&models.Policy{
+		UniqueID:        uid,
+		ProjectID:       project.ID,
+		CreatedByUserID: user.ID,
+		Name:            fmt.Sprintf("%s-project-role-policy", request.Name),
+		PolicyBytes:     policyBytes,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	uid, err = encryption.GenerateRandomBytes(16)
+
+	if err != nil {
+		// we need to delete the policy we just created
+		c.Repo().Policy().DeletePolicy(policy)
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	role, err := c.Repo().ProjectRole().CreateProjectRole(&models.ProjectRole{
+		UniqueID:  uid,
+		ProjectID: project.ID,
+		PolicyUID: policy.UniqueID,
+		Name:      request.Name,
+	})
+
+	if err != nil {
+		// we need to delete the policy we just created
+		c.Repo().Policy().DeletePolicy(policy)
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if len(request.Users) > 0 {
+		err = c.Repo().ProjectRole().UpdateUsersInProjectRole(project.ID, role.UniqueID, request.Users)
+
+		if err != nil {
+			// we need to delete the policy and project role we just created
+			c.Repo().Policy().DeletePolicy(policy)
+			c.Repo().ProjectRole().DeleteProjectRole(role)
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	w.WriteHeader(http.StatusCreated)
+}

+ 88 - 0
api/server/handlers/project_role/delete.go

@@ -0,0 +1,88 @@
+package project_role
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type DeleteProjectRoleHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewDeleteProjectRoleHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeleteProjectRoleHandler {
+	return &DeleteProjectRoleHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *DeleteProjectRoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	roleUID, reqErr := requestutils.GetURLParamString(r, types.URLParamProjectRoleID)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	role, err := c.Repo().ProjectRole().ReadProjectRole(project.ID, roleUID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such project role exists")))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if role.IsDefaultRole() {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("cannot delete default project role"), http.StatusConflict,
+		))
+		return
+	}
+
+	if len(role.Users) > 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("role has one or more users assigned"), http.StatusPreconditionFailed,
+		))
+		return
+	}
+
+	policy, err := c.Repo().Policy().ReadPolicy(project.ID, role.PolicyUID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = c.Repo().Policy().DeletePolicy(policy)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = c.Repo().ProjectRole().DeleteProjectRole(role)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 69 - 0
api/server/handlers/project_role/get.go

@@ -0,0 +1,69 @@
+package project_role
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type GetProjectRoleHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewGetProjectRoleHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetProjectRoleHandler {
+	return &GetProjectRoleHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GetProjectRoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	roleUID, reqErr := requestutils.GetURLParamString(r, types.URLParamProjectRoleID)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	role, err := c.Repo().ProjectRole().ReadProjectRole(project.ID, roleUID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such project role exists")))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	policy, err := c.Repo().Policy().ReadPolicy(project.ID, role.PolicyUID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	policyType, err := policy.ToAPIPolicyType()
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, role.ToProjectRoleType(policyType.Policy[0]))
+}

+ 28 - 0
api/server/handlers/project_role/get_scope_hierarchy.go

@@ -0,0 +1,28 @@
+package project_role
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type GetProjectRoleScopeHierarchyHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewGetProjectRoleScopeHierarchyHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetProjectRoleScopeHierarchyHandler {
+	return &GetProjectRoleScopeHierarchyHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GetProjectRoleScopeHierarchyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	c.WriteResult(w, r, types.ScopeHeirarchy)
+}

+ 67 - 0
api/server/handlers/project_role/list.go

@@ -0,0 +1,67 @@
+package project_role
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type ListProjectRolesHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewListProjectRolesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListProjectRolesHandler {
+	return &ListProjectRolesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *ListProjectRolesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	roles, err := c.Repo().ProjectRole().ListProjectRoles(project.ID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("project has no roles")))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res []*types.ProjectRole
+
+	for _, role := range roles {
+		policy, err := c.Repo().Policy().ReadPolicy(project.ID, role.PolicyUID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		policyType, err := policy.ToAPIPolicyType()
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		res = append(res, role.ToProjectRoleType(policyType.Policy[0]))
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 121 - 0
api/server/handlers/project_role/update.go

@@ -0,0 +1,121 @@
+package project_role
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type UpdateProjectRoleHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewUpdateProjectRoleHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateProjectRoleHandler {
+	return &UpdateProjectRoleHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *UpdateProjectRoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	roleUID, reqErr := requestutils.GetURLParamString(r, types.URLParamProjectRoleID)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	request := &types.UpdateProjectRoleRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	role, err := c.Repo().ProjectRole().ReadProjectRole(project.ID, roleUID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such project role exists")))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if !role.IsDefaultRole() && request.Name != "" && request.Name != role.Name {
+		if request.Name == string(types.RoleAdmin) ||
+			request.Name == string(types.RoleDeveloper) ||
+			request.Name == string(types.RoleViewer) {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("default role names admin, developer, viewer are not allowed"), http.StatusConflict,
+			))
+			return
+		}
+
+		role.Name = request.Name
+
+		role, err = c.Repo().ProjectRole().UpdateProjectRole(role)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	if len(request.Users) == 0 {
+		err = c.Repo().ProjectRole().ClearUsersInProjectRole(project.ID, roleUID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else {
+		err = c.Repo().ProjectRole().UpdateUsersInProjectRole(project.ID, roleUID, request.Users)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	if !role.IsDefaultRole() && request.Policy != nil {
+		policy, err := c.Repo().Policy().ReadPolicy(project.ID, role.PolicyUID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		policyBytes, err := json.Marshal([]*types.PolicyDocument{request.Policy})
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		policy.PolicyBytes = policyBytes
+
+		_, err = c.Repo().Policy().UpdatePolicy(policy)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 89 - 8
api/server/handlers/user/create.go

@@ -1,6 +1,7 @@
 package user
 
 import (
+	"encoding/json"
 	"fmt"
 	"net/http"
 
@@ -11,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/analytics"
+	"github.com/porter-dev/porter/internal/encryption"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/crypto/bcrypt"
@@ -134,20 +136,99 @@ func addUserToDefaultProject(config *config.Config, user *models.User) error {
 				return err
 			}
 
-			// create a new Role with the user as the admin
-			_, err = config.Repo.Project().CreateProjectRole(project, &models.Role{
-				Role: types.Role{
-					UserID:    user.ID,
-					ProjectID: project.ID,
-					Kind:      types.RoleAdmin,
-				},
-			})
+			err = createNewRole(project.ID, types.RoleAdmin, config.Repo.ProjectRole(), config.Repo.Policy())
 
 			if err != nil {
 				return err
 			}
+
+			err = createNewRole(project.ID, types.RoleAdmin, config.Repo.ProjectRole(), config.Repo.Policy())
+
+			if err != nil {
+				return err
+			}
+
+			err = createNewRole(project.ID, types.RoleAdmin, config.Repo.ProjectRole(), config.Repo.Policy())
+
+			if err != nil {
+				return err
+			}
+
+			// attach user to admin role
+			err = config.Repo.ProjectRole().UpdateUsersInProjectRole(project.ID, fmt.Sprintf("%d-%s", project.ID, types.RoleAdmin), []uint{user.ID})
+
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+func createNewRole(
+	projectID uint,
+	kind types.RoleKind,
+	projectRoleRepo repository.ProjectRoleRepository,
+	policyRepo repository.PolicyRepository,
+) error {
+	// for legacy roles - admin, developer, viewer (kinds)
+	// default role name such as <project ID>-<kind> for uniqueness
+	// similarly, create policy for each new default role as <project ID>-<kind>-project-role-policy
+
+	uid, err := encryption.GenerateRandomBytes(16)
+
+	if err != nil {
+		return err
+	}
+
+	var policyBytes []byte
+
+	switch kind {
+	case types.RoleAdmin:
+		policyBytes, err = json.Marshal(types.AdminPolicy)
+
+		if err != nil {
+			return err
+		}
+	case types.RoleDeveloper:
+		policyBytes, err = json.Marshal(types.DeveloperPolicy)
+
+		if err != nil {
+			return err
+		}
+	case types.RoleViewer:
+		policyBytes, err = json.Marshal(types.ViewerPolicy)
+
+		if err != nil {
+			return err
 		}
 	}
 
+	newPolicy, err := policyRepo.CreatePolicy(&models.Policy{
+		UniqueID:    uid,
+		ProjectID:   projectID,
+		Name:        fmt.Sprintf("%s-project-role-policy", kind),
+		PolicyBytes: policyBytes,
+	})
+
+	if err != nil {
+		return err
+	}
+
+	_, err = projectRoleRepo.CreateProjectRole(&models.ProjectRole{
+		UniqueID:  fmt.Sprintf("%d-%s", projectID, kind),
+		ProjectID: projectID,
+		PolicyUID: newPolicy.UniqueID,
+		Name:      string(kind),
+	})
+
+	if err != nil {
+		// delete newly created policy first
+		policyRepo.DeletePolicy(newPolicy)
+
+		return err
+	}
+
 	return nil
 }

+ 233 - 0
api/server/router/project_role.go

@@ -0,0 +1,233 @@
+package router
+
+import (
+	"fmt"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/project_role"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+func NewProjectRoleScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetProjectRoleScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetProjectRoleScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getProjectRoleRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getProjectRoleRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/project_roles"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	// POST /api/projects/{project_id}/project_roles -> project_role.NewCreateProjectRoleHandler
+	createProjectRoleEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	createProjectRoleHandler := project_role.NewCreateProjectRoleHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createProjectRoleEndpoint,
+		Handler:  createProjectRoleHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/project_roles/{role_id} -> project_role.NewGetProjectRoleHandler
+	getProjectRoleEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamProjectRoleID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	getProjectRoleHandler := project_role.NewGetProjectRoleHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getProjectRoleEndpoint,
+		Handler:  getProjectRoleHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/project_roles -> project_role.NewListProjectRolesHandler
+	listProjectRolesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	listProjectRolesHandler := project_role.NewListProjectRolesHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listProjectRolesEndpoint,
+		Handler:  listProjectRolesHandler,
+		Router:   r,
+	})
+
+	// PATCH /api/projects/{project_id}/project_roles/{role_id} -> project_role.NewUpdateProjectRoleHandler
+	updateProjectRoleEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamProjectRoleID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	updateProjectRoleHandler := project_role.NewUpdateProjectRoleHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateProjectRoleEndpoint,
+		Handler:  updateProjectRoleHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/projects/{project_id}/project_roles/{role_id} -> project_role.NewDeleteProjectRoleHandler
+	deleteProjectRoleEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamProjectRoleID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	deleteProjectRoleHandler := project_role.NewDeleteProjectRoleHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteProjectRoleEndpoint,
+		Handler:  deleteProjectRoleHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/project_roles/scope_hierarchy -> project_role.NewGetProjectRoleScopeHierarchyHandler
+	getProjectRoleScopeHierarchyEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/scope_hierarchy", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	getProjectRoleScopeHierarchyHandler := project_role.NewGetProjectRoleScopeHierarchyHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getProjectRoleScopeHierarchyEndpoint,
+		Handler:  getProjectRoleScopeHierarchyHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 3 - 1
api/server/router/router.go

@@ -38,6 +38,7 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	inviteRegisterer := NewInviteScopedRegisterer()
 	projectIntegrationRegisterer := NewProjectIntegrationScopedRegisterer()
 	projectOAuthRegisterer := NewProjectOAuthScopedRegisterer()
+	projectRoleRegisterer := NewProjectRoleScopedRegisterer()
 	slackIntegrationRegisterer := NewSlackIntegrationScopedRegisterer()
 	projRegisterer := NewProjectScopedRegisterer(
 		clusterRegisterer,
@@ -49,6 +50,7 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 		projectIntegrationRegisterer,
 		projectOAuthRegisterer,
 		slackIntegrationRegisterer,
+		projectRoleRegisterer,
 	)
 	statusRegisterer := NewStatusScopedRegisterer()
 
@@ -221,7 +223,7 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 	stackFactory := authz.NewStackScopedFactory(config)
 
 	// Policy doc loader loads the policy documents for a specific project.
-	policyDocLoader := policy.NewBasicPolicyDocumentLoader(config.Repo.Project(), config.Repo.Policy())
+	policyDocLoader := policy.NewBasicPolicyDocumentLoader(config.Repo.ProjectRole(), config.Repo.Policy())
 
 	// set up logging middleware to log information about the request
 	loggerMw := middleware.NewRequestLoggerMiddleware(config.Logger)

+ 12 - 9
api/types/invite.go

@@ -5,19 +5,21 @@ const (
 )
 
 type Invite struct {
-	ID       uint   `json:"id"`
-	Token    string `json:"token"`
-	Expired  bool   `json:"expired"`
-	Email    string `json:"email"`
-	Accepted bool   `json:"accepted"`
-	Kind     string `json:"kind"`
+	ID       uint     `json:"id"`
+	Token    string   `json:"token"`
+	Expired  bool     `json:"expired"`
+	Email    string   `json:"email"`
+	Accepted bool     `json:"accepted"`
+	Kind     string   `json:"kind"`
+	Roles    []string `json:"roles"`
 }
 
 type GetInviteResponse Invite
 
 type CreateInviteRequest struct {
-	Email string `json:"email,required"`
-	Kind  string `json:"kind,required"`
+	Email    string   `json:"email" form:"required"`
+	Kind     string   `json:"kind"`
+	RoleUIDs []string `json:"roles"`
 }
 
 type CreateInviteResponse struct {
@@ -27,5 +29,6 @@ type CreateInviteResponse struct {
 type ListInvitesResponse []*Invite
 
 type UpdateInviteRoleRequest struct {
-	Kind string `json:"kind,required"`
+	Kind     string   `json:"kind"`
+	RoleUIDs []string `json:"roles"`
 }

+ 28 - 16
api/types/policy.go

@@ -5,20 +5,22 @@ import "time"
 type PermissionScope string
 
 const (
-	UserScope              PermissionScope = "user"
-	ProjectScope           PermissionScope = "project"
-	ClusterScope           PermissionScope = "cluster"
-	RegistryScope          PermissionScope = "registry"
-	InviteScope            PermissionScope = "invite"
-	HelmRepoScope          PermissionScope = "helm_repo"
-	InfraScope             PermissionScope = "infra"
-	OperationScope         PermissionScope = "operation"
-	GitInstallationScope   PermissionScope = "git_installation"
-	NamespaceScope         PermissionScope = "namespace"
-	SettingsScope          PermissionScope = "settings"
-	ReleaseScope           PermissionScope = "release"
-	StackScope             PermissionScope = "stack"
-	GitlabIntegrationScope PermissionScope = "gitlab_integration"
+	UserScope               PermissionScope = "user"
+	ProjectScope            PermissionScope = "project"
+	ClusterScope            PermissionScope = "cluster"
+	RegistryScope           PermissionScope = "registry"
+	InviteScope             PermissionScope = "invite"
+	HelmRepoScope           PermissionScope = "helm_repo"
+	InfraScope              PermissionScope = "infra"
+	OperationScope          PermissionScope = "operation"
+	GitInstallationScope    PermissionScope = "git_installation"
+	NamespaceScope          PermissionScope = "namespace"
+	SettingsScope           PermissionScope = "settings"
+	ReleaseScope            PermissionScope = "release"
+	StackScope              PermissionScope = "stack"
+	GitlabIntegrationScope  PermissionScope = "gitlab_integration"
+	PreviewEnvironmentScope PermissionScope = "preview_environment"
+	EnvironmentScope        PermissionScope = "environment"
 )
 
 type NameOrUInt struct {
@@ -52,7 +54,13 @@ var ScopeHeirarchy = ScopeTree{
 		InfraScope: {
 			OperationScope: {},
 		},
-		SettingsScope: {},
+		SettingsScope: {
+			InviteScope: {},
+		},
+		PreviewEnvironmentScope: {
+			EnvironmentScope: {},
+		},
+		GitlabIntegrationScope: {},
 	},
 }
 
@@ -157,11 +165,15 @@ var ViewerPolicy = []*PolicyDocument{
 	},
 }
 
-type CreatePolicy struct {
+type CreatePolicyRequest struct {
 	Name   string            `json:"name" form:"required"`
 	Policy []*PolicyDocument `json:"policy" form:"required"`
 }
 
+type UpdatePolicyRequest struct {
+	Policy []*PolicyDocument `json:"policy" form:"required"`
+}
+
 const URLParamPolicyID URLParam = "policy_id"
 
 type APIPolicyMeta struct {

+ 7 - 6
api/types/project.go

@@ -24,7 +24,7 @@ type CreateProjectRequest struct {
 
 type CreateProjectResponse Project
 
-type CreateProjectRoleRequest struct {
+type CreateLegacyProjectRoleRequest struct {
 	Kind   string `json:"kind" form:"required"`
 	UserID uint   `json:"user_id" form:"required"`
 }
@@ -48,11 +48,12 @@ type GetProjectPolicyResponse []*PolicyDocument
 type ListProjectRolesResponse []RoleKind
 
 type Collaborator struct {
-	ID        uint   `json:"id"`
-	Kind      string `json:"kind"`
-	UserID    uint   `json:"user_id"`
-	Email     string `json:"email"`
-	ProjectID uint   `json:"project_id"`
+	ID        uint     `json:"id,omitempty"`
+	Kind      string   `json:"kind,omitempty"`
+	RoleUIDs  []string `json:"roles"`
+	UserID    uint     `json:"user_id"`
+	Email     string   `json:"email"`
+	ProjectID uint     `json:"project_id"`
 }
 
 type ListCollaboratorsResponse []*Collaborator

+ 22 - 0
api/types/project_role.go

@@ -0,0 +1,22 @@
+package types
+
+const URLParamProjectRoleID URLParam = "role_id"
+
+type ProjectRole struct {
+	ID     string          `json:"id" form:"required"`
+	Name   string          `json:"name" form:"required"`
+	Users  []uint          `json:"users"`
+	Policy *PolicyDocument `json:"policy" form:"required"`
+}
+
+type CreateProjectRoleRequest struct {
+	Name   string          `json:"name" form:"required"`
+	Users  []uint          `json:"users"`
+	Policy *PolicyDocument `json:"policy" form:"required"`
+}
+
+type UpdateProjectRoleRequest struct {
+	Name   string          `json:"name"`
+	Users  []uint          `json:"users"`
+	Policy *PolicyDocument `json:"policy"`
+}

+ 28 - 0
cmd/migrate/main.go

@@ -5,6 +5,7 @@ import (
 
 	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/cmd/migrate/keyrotate"
+	migratelegacyrbac "github.com/porter-dev/porter/cmd/migrate/migrate_legacy_rbac"
 	"github.com/porter-dev/porter/cmd/migrate/populate_source_config_display_name"
 
 	adapter "github.com/porter-dev/porter/internal/adapter"
@@ -70,6 +71,14 @@ func main() {
 		}
 	}
 
+	if shouldMigrateFromLegacyRBAC() {
+		err := migratelegacyrbac.MigrateFromLegacyRBAC(db, logger)
+
+		if err != nil {
+			logger.Fatal().Err(err).Msg("failed to migrate legacy RBAC")
+		}
+	}
+
 	if err := InstanceMigrate(db, envConf.DBConf); err != nil {
 		logger.Fatal().Err(err).Msg("vault migration failed")
 	}
@@ -111,3 +120,22 @@ func shouldPopulateSourceConfigDisplayName() bool {
 
 	return c.PopulateSourceConfigDisplayName
 }
+
+type MigrateLegacyRBACConf struct {
+	// we add a dummy field to avoid empty struct issue with envdecode
+	DummyField string `env:"ASDF,default=asdf"`
+
+	// if true, will migrate away from legacy RBAC to advanced RBAC
+	MigrateLegacyRBAC bool `env:"MIGRATE_LEGACY_RBAC"`
+}
+
+func shouldMigrateFromLegacyRBAC() bool {
+	var c MigrateLegacyRBACConf
+
+	if err := envdecode.StrictDecode(&c); err != nil {
+		log.Fatalf("Failed to decode migration conf: %s", err)
+		return false
+	}
+
+	return c.MigrateLegacyRBAC
+}

+ 184 - 0
cmd/migrate/migrate_legacy_rbac/migrate.go

@@ -0,0 +1,184 @@
+package migrate_legacy_rbac
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/encryption"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	gorm "github.com/porter-dev/porter/internal/repository/gorm"
+	lr "github.com/porter-dev/porter/pkg/logger"
+
+	_gorm "gorm.io/gorm"
+)
+
+// process 100 records at a time
+const stepSize = 100
+
+func MigrateFromLegacyRBAC(db *_gorm.DB, logger *lr.Logger) error {
+	logger.Info().Msg("initiated migration from legacy RBAC")
+
+	var count int64
+
+	if err := db.Model(&models.Project{}).Count(&count).Error; err != nil {
+		return err
+	}
+
+	projectRepo := gorm.NewProjectRepository(db).(*gorm.ProjectRepository)
+	projectRoleRepo := gorm.NewProjectRoleRepository(db).(*gorm.ProjectRoleRepository)
+	policyRepo := gorm.NewPolicyRepository(db).(*gorm.PolicyRepository)
+
+	logger.Info().Msgf("found %d projects", count)
+
+	// iterate (count / stepSize) + 1 times using Limit and Offset
+	for i := 0; i < (int(count)/stepSize)+1; i++ {
+		projects := []*models.Project{}
+
+		if err := db.Preload("Roles").Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&projects).Error; err != nil {
+			return err
+		}
+
+		for _, project := range projects {
+			logger.Info().Msgf("starting migration for project ID %d", project.ID)
+
+			var legacyRoleCount int64
+
+			if err := db.Where("unique_id = ?", fmt.Sprintf("%d-%s", project.ID, types.RoleAdmin)).
+				Find(&models.ProjectRole{}).Count(&legacyRoleCount).Error; err != nil {
+				return err
+			} else if legacyRoleCount == 0 {
+				err := createNewRole(project.ID, types.RoleAdmin, projectRoleRepo, policyRepo)
+
+				if err != nil {
+					return err
+				}
+			}
+
+			if err := db.Where("unique_id = ?", fmt.Sprintf("%d-%s", project.ID, types.RoleDeveloper)).
+				Find(&models.ProjectRole{}).Count(&legacyRoleCount).Error; err != nil {
+				return err
+			} else if legacyRoleCount == 0 {
+				err := createNewRole(project.ID, types.RoleDeveloper, projectRoleRepo, policyRepo)
+
+				if err != nil {
+					return err
+				}
+			}
+
+			if err := db.Where("unique_id = ?", fmt.Sprintf("%d-%s", project.ID, types.RoleViewer)).
+				Find(&models.ProjectRole{}).Count(&legacyRoleCount).Error; err != nil {
+				return err
+			} else if legacyRoleCount == 0 {
+				err := createNewRole(project.ID, types.RoleViewer, projectRoleRepo, policyRepo)
+
+				if err != nil {
+					return err
+				}
+			}
+
+			legacyRoleKindUsersMap := map[types.RoleKind][]uint{
+				types.RoleAdmin:     make([]uint, 0),
+				types.RoleDeveloper: make([]uint, 0),
+				types.RoleViewer:    make([]uint, 0),
+				types.RoleCustom:    make([]uint, 0), // added this for possible cases of custom roles in the DB?
+			}
+
+			for _, legacyRole := range project.Roles {
+				legacyRoleKindUsersMap[legacyRole.Kind] = append(legacyRoleKindUsersMap[legacyRole.Kind], legacyRole.UserID)
+			}
+
+			delete(legacyRoleKindUsersMap, types.RoleCustom) // added just to make sure nothing goes wrong from here
+
+			for roleKind, users := range legacyRoleKindUsersMap {
+				if len(users) > 0 {
+					err := projectRoleRepo.UpdateUsersInProjectRole(project.ID, fmt.Sprintf("%d-%s", project.ID, roleKind), users)
+
+					if err != nil {
+						return err
+					}
+				}
+			}
+
+			for _, legacyRole := range project.Roles {
+				// delete legacy role from project
+				if _, err := projectRepo.DeleteLegacyProjectRole(project.ID, legacyRole.UserID); err != nil {
+					return fmt.Errorf("error encountered while deleting legacy project role: %w", err)
+				}
+			}
+
+			logger.Info().Msgf("finished migration for project ID %d", project.ID)
+		}
+	}
+
+	logger.Info().Msg("legacy RBAC migration completed")
+	return nil
+}
+
+func createNewRole(
+	projectID uint,
+	kind types.RoleKind,
+	projectRoleRepo repository.ProjectRoleRepository,
+	policyRepo repository.PolicyRepository,
+) error {
+	// for legacy roles - admin, developer, viewer (kinds)
+	// default role name such as <project ID>-<kind> for uniqueness
+	// similarly, create policy for each new default role as <project ID>-<kind>-project-role-policy
+
+	uid, err := encryption.GenerateRandomBytes(16)
+
+	if err != nil {
+		return err
+	}
+
+	var policyBytes []byte
+
+	switch kind {
+	case types.RoleAdmin:
+		policyBytes, err = json.Marshal(types.AdminPolicy)
+
+		if err != nil {
+			return err
+		}
+	case types.RoleDeveloper:
+		policyBytes, err = json.Marshal(types.DeveloperPolicy)
+
+		if err != nil {
+			return err
+		}
+	case types.RoleViewer:
+		policyBytes, err = json.Marshal(types.ViewerPolicy)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	newPolicy, err := policyRepo.CreatePolicy(&models.Policy{
+		UniqueID:    uid,
+		ProjectID:   projectID,
+		Name:        fmt.Sprintf("%s-project-role-policy", kind),
+		PolicyBytes: policyBytes,
+	})
+
+	if err != nil {
+		return err
+	}
+
+	_, err = projectRoleRepo.CreateProjectRole(&models.ProjectRole{
+		UniqueID:  fmt.Sprintf("%d-%s", projectID, kind),
+		ProjectID: projectID,
+		PolicyUID: newPolicy.UniqueID,
+		Name:      string(kind),
+	})
+
+	if err != nil {
+		// delete newly created policy first
+		policyRepo.DeletePolicy(newPolicy)
+
+		return err
+	}
+
+	return nil
+}

+ 7 - 9
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -179,16 +179,14 @@ class Dashboard extends Component<PropsType, StateType> {
                     </Overlay>
                   </DashboardIcon>
                   {currentProject && currentProject.name}
-                  {this.context.currentProject?.roles?.filter((obj: any) => {
+                  // TODO REPLACE WITH IS AUTH HOOK
+                  {/* {this.context.currentProject?.roles?.filter((obj: any) => {
                     return obj.user_id === this.context.user.userId;
-                  })[0].kind === "admin" || (
-                    <i
-                      className="material-icons"
-                      onClick={onShowProjectSettings}
-                    >
-                      more_vert
-                    </i>
-                  )}
+                  })[0].kind === "admin" || ( */}
+                  <i className="material-icons" onClick={onShowProjectSettings}>
+                    more_vert
+                  </i>
+                  {/* )} */}
                 </TitleSection>
                 <Br />
 

+ 38 - 5
dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx

@@ -1,11 +1,12 @@
 import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
-import close from "assets/close.png";
 import SaveButton from "components/SaveButton";
 import { Context } from "shared/Context";
-import RadioSelector from "components/RadioSelector";
 import api from "shared/api";
 import { setTimeout } from "timers";
+import { RoleList } from "../project-settings/roles-admin/types";
+import { RoleSelector } from "../project-settings/InviteList";
+import { AxiosError } from "axios";
 
 const EditCollaboratorModal = () => {
   const {
@@ -24,6 +25,9 @@ const EditCollaboratorModal = () => {
   const [selectedRole, setSelectedRole] = useState("");
   const [roleList, setRoleList] = useState([]);
 
+  const [roles, setRoles] = useState<RoleList>([]);
+  const [selectedRoles, setSelectedRoles] = useState<RoleList>([]);
+
   useEffect(() => {
     api
       .getAvailableRoles("<token>", {}, { project_id })
@@ -35,7 +39,17 @@ const EditCollaboratorModal = () => {
         setRoleList(availableRoleList);
         setSelectedRole(user?.kind || "developer");
       });
-  }, []);
+
+    api.listRoles<RoleList>("<token>", {}, { project_id }).then((res) => {
+      const newRoles = res.data;
+      setRoles(newRoles);
+
+      const selectedRoles = newRoles.filter((role) =>
+        user.roles.includes(role.id)
+      );
+      setSelectedRoles(selectedRoles);
+    });
+  }, [project_id]);
 
   const capitalizeFirstLetter = (string: string) => {
     return string.charAt(0).toUpperCase() + string.slice(1);
@@ -57,6 +71,7 @@ const EditCollaboratorModal = () => {
         {
           kind: selectedRole,
           user_id: user.id,
+          roles: selectedRoles.map((role) => role.id),
         },
         { project_id }
       );
@@ -65,6 +80,12 @@ const EditCollaboratorModal = () => {
         setTimeout(() => setCurrentModal(null, null), 500);
       });
     } catch (error) {
+      const axiosError: AxiosError = error;
+
+      if (axiosError?.response?.status === 400) {
+        setStatus("You must select at least one role");
+        return;
+      }
       setStatus("error");
     }
   };
@@ -74,7 +95,7 @@ const EditCollaboratorModal = () => {
     try {
       await api.updateInvite(
         "<token>",
-        { kind: selectedRole },
+        { kind: selectedRole, roles: selectedRoles.map((role) => role.id) },
         { project_id, invite_id: user.id }
       );
       setStatus("successful");
@@ -82,6 +103,13 @@ const EditCollaboratorModal = () => {
         setTimeout(() => setCurrentModal(null, null), 500);
       });
     } catch (error) {
+      const axiosError: AxiosError = error;
+
+      if (axiosError?.response?.status === 400) {
+        setStatus("You must select at least one role");
+        return;
+      }
+
       setStatus("error");
     }
   };
@@ -93,10 +121,15 @@ const EditCollaboratorModal = () => {
       </ModalTitle>
       <Subtitle>Specify a different role for this user.</Subtitle>
       <RoleSelectorWrapper>
-        <RadioSelector
+        {/* <RadioSelector
           selected={selectedRole}
           setSelected={setSelectedRole}
           options={roleList}
+        /> */}
+        <RoleSelector
+          onChange={(e) => setSelectedRoles(e)}
+          options={roles}
+          values={selectedRoles}
         />
       </RoleSelectorWrapper>
 

+ 1 - 0
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -3,6 +3,7 @@ import { CustomerProvider, PlanSelect } from "@ironplans/react";
 import api from "shared/api";
 import { Context } from "shared/Context";
 
+// @TODO: Deprecated, remove.
 function BillingPage() {
   const [customerToken, setCustomerToken] = useState("");
   const [teamID, setTeamID] = useState("");

+ 117 - 10
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -13,6 +13,8 @@ import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
 import Table from "components/Table";
 import RadioSelector from "components/RadioSelector";
+import { Role } from "./roles-admin/types";
+import SearchSelector from "components/SearchSelector";
 
 type Props = {};
 
@@ -22,6 +24,7 @@ export type Collaborator = {
   project_id: string;
   email: string;
   kind: string;
+  roles: string[];
 };
 
 const InvitePage: React.FunctionComponent<Props> = ({}) => {
@@ -42,6 +45,9 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
   const [isInvalidEmail, setIsInvalidEmail] = useState(false);
   const [isHTTPS] = useState(() => window.location.protocol === "https:");
 
+  const [roles, setRoles] = useState<Role[]>([]);
+  const [selectedRoles, setSelectedRoles] = useState<Role[]>([]);
+
   useEffect(() => {
     api
       .getAvailableRoles("<token>", {}, { project_id: currentProject?.id })
@@ -54,6 +60,10 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         setRole("developer");
       });
 
+    api
+      .listRoles("<token>", {}, { project_id: currentProject?.id })
+      .then((res) => setRoles(res.data));
+
     getData();
   }, [currentProject]);
 
@@ -114,15 +124,21 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
       kind: c.kind,
       accepted: true,
       token: "",
+      roles: c.roles,
     }));
   };
 
   const createInvite = () => {
     api
-      .createInvite("<token>", { email, kind: role }, { id: currentProject.id })
+      .createInvite(
+        "<token>",
+        { email, kind: role, roles: selectedRoles.map((role) => role.id) },
+        { id: currentProject.id }
+      )
       .then(() => {
         getData();
         setEmail("");
+        setSelectedRoles([]);
       })
       .catch((err) => {
         if (err.response.data?.error) {
@@ -150,12 +166,13 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
   const replaceInvite = (
     inviteEmail: string,
     inviteId: number,
-    kind: string
+    kind: string,
+    roles: string[]
   ) => {
     api
       .createInvite(
         "<token>",
-        { email: inviteEmail, kind },
+        { email: inviteEmail, kind, roles },
         { id: currentProject.id }
       )
       .then(() =>
@@ -219,6 +236,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
       status: string;
       invite_link: string;
       kind: string;
+      roles_names: string[];
     }>[]
   >(
     () => [
@@ -228,9 +246,11 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
       },
       {
         Header: "Role",
-        accessor: "kind",
+        accessor: "roles_names",
         Cell: ({ row }) => {
-          return <Role>{row.values.kind || "Developer"}</Role>;
+          return (
+            <RoleName>{row.values.roles_names?.join(", ") || "N/A"}</RoleName>
+          );
         },
       },
       {
@@ -253,7 +273,8 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
                   replaceInvite(
                     row.values.email,
                     row.original.id,
-                    row.values.kind
+                    row.values.kind,
+                    row.values.roles
                   )
                 }
               >
@@ -341,11 +362,17 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     const mappedInviteList = inviteList.map(
       ({ accepted, expired, token, ...rest }) => {
         const currentUser: boolean = user.email === rest.email;
+
+        const roles_names = rest.roles.map(
+          (roleId) => roles.find((role) => role.id === roleId)?.name
+        );
+
         if (accepted) {
           return {
             status: "accepted",
             invite_link: buildInviteLink(token),
             currentUser,
+            roles_names,
             ...rest,
           };
         }
@@ -355,6 +382,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
             status: "expired",
             invite_link: buildInviteLink(token),
             currentUser,
+            roles_names,
             ...rest,
           };
         }
@@ -363,13 +391,21 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           status: "pending",
           invite_link: buildInviteLink(token),
           currentUser,
+          roles_names,
           ...rest,
         };
       }
     );
 
     return mappedInviteList || [];
-  }, [invites, currentProject?.id, window?.location?.host, isHTTPS, user?.id]);
+  }, [
+    invites,
+    currentProject?.id,
+    window?.location?.host,
+    isHTTPS,
+    user?.id,
+    roles,
+  ]);
 
   const hasSeats = useMemo(() => {
     if (String(edition) === "dev-ee") {
@@ -406,12 +442,17 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
             placeholder="ex: mrp@getporter.dev"
           />
         </InputRowWrapper>
-        <Helper>Specify a role for this user.</Helper>
+        <Helper>Specify the roles for this user.</Helper>
         <RoleSelectorWrapper>
-          <RadioSelector
+          {/* <RadioSelector
             selected={role}
             setSelected={setRole}
             options={roleList}
+          /> */}
+          <RoleSelector
+            options={roles}
+            onChange={setSelectedRoles}
+            values={selectedRoles}
           />
         </RoleSelectorWrapper>
         <ButtonWrapper>
@@ -453,6 +494,71 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
 
 export default InvitePage;
 
+export const RoleSelector = ({
+  options,
+  onChange,
+  values,
+}: {
+  options: Role[];
+  onChange: (roles: Role[]) => void;
+  values: Role[];
+}) => {
+  const filteredOptions = options.filter(
+    (option) => !values.map((val) => val.name).includes(option.name)
+  );
+
+  return (
+    <>
+      <SearchSelector
+        options={filteredOptions}
+        onSelect={(newRole) => onChange([...values, newRole])}
+        filterBy={(role) => role?.name || ""}
+        getOptionLabel={(role) => role.name || ""}
+        placeholder="Select a role"
+      />
+      <RoleTagWrapper>
+        {values.map((role) => (
+          <RoleTag>
+            {role.name}
+            <i
+              className="material-icons-outlined"
+              onClick={() => {
+                onChange(values.filter((value) => value.id !== role.id));
+              }}
+            >
+              delete
+            </i>
+          </RoleTag>
+        ))}
+      </RoleTagWrapper>
+    </>
+  );
+};
+
+const RoleTagWrapper = styled.div`
+  margin-top: 15px;
+`;
+
+const RoleTag = styled.div`
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  background: #f2f2f2;
+  border-radius: 4px;
+  padding: 4px 8px;
+  margin-right: 8px;
+  font-size: 14px;
+  font-weight: 500;
+  color: #000000;
+
+  > i {
+    margin-left: 4px;
+    font-size: 14px;
+
+    cursor: pointer;
+  }
+`;
+
 const Flex = styled.div`
   display: flex;
   align-items: center;
@@ -487,13 +593,14 @@ const SettingsButton = styled(DeleteButton)`
   margin-right: -60px;
 `;
 
-const Role = styled.div`
+const RoleName = styled.div`
   text-transform: capitalize;
   margin-right: 50px;
 `;
 
 const RoleSelectorWrapper = styled.div`
   font-size: 14px;
+  max-width: 40%;
 `;
 
 const Placeholder = styled.div`

+ 96 - 92
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -9,30 +9,50 @@ import Heading from "components/form-components/Heading";
 import Helper from "components/form-components/Helper";
 import TitleSection from "components/TitleSection";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import { RouteComponentProps, withRouter, WithRouterProps } from "react-router";
+import { RouteComponentProps, withRouter } from "react-router";
 import { getQueryParam } from "shared/routing";
-import BillingPage from "./BillingPage";
 import APITokensSection from "./APITokensSection";
+import { RolesAdmin } from "./roles-admin";
 
 type PropsType = RouteComponentProps & WithAuthProps & {};
 
+const isValidTab = (tab: string): tab is AvailableTabs => {
+  return [
+    "invite",
+    "api-tokens",
+    "manage-access",
+    "billing",
+    "additional-settings",
+    "roles-admin",
+  ].includes(tab);
+};
+
+type AvailableTabs =
+  | "invite"
+  | "api-tokens"
+  | "manage-access"
+  | "billing"
+  | "additional-settings"
+  | "roles-admin";
+
 type StateType = {
   projectName: string;
-  currentTab: string;
-  tabOptions: { value: string; label: string }[];
+  currentTab: AvailableTabs;
+  tabOptions: { value: AvailableTabs; label: string }[];
 };
 
 class ProjectSettings extends Component<PropsType, StateType> {
   state = {
     projectName: "",
-    currentTab: "manage-access",
-    tabOptions: [] as { value: string; label: string }[],
+    currentTab: "manage-access" as StateType["currentTab"],
+    tabOptions: [] as StateType["tabOptions"],
   };
 
   componentDidUpdate(prevProps: PropsType) {
     const selectedTab = getQueryParam(this.props, "selected_tab");
+
     if (prevProps.location.search !== this.props.location.search) {
-      if (selectedTab) {
+      if (selectedTab && isValidTab(selectedTab)) {
         this.setState({ currentTab: selectedTab });
       } else {
         this.setState({ currentTab: "manage-access" });
@@ -43,27 +63,15 @@ class ProjectSettings extends Component<PropsType, StateType> {
       !this.state.tabOptions.find((t) => t.value === "billing")
     ) {
       const tabOptions = this.state.tabOptions;
-      // tabOptions.splice(1, 0, { value: "billing", label: "Billing" });
       this.setState({ tabOptions });
       return;
     }
-
-    if (
-      !this.context?.hasBillingEnabled &&
-      this.state.tabOptions.find((t) => t.value === "billing")
-    ) {
-      const tabOptions = this.state.tabOptions;
-      const billingIndex = this.state.tabOptions.findIndex(
-        (t) => t.value === "billing"
-      );
-      // tabOptions.splice(billingIndex, 1);
-    }
   }
 
   componentDidMount() {
     let { currentProject } = this.context;
     this.setState({ projectName: currentProject.name });
-    const tabOptions = [];
+    const tabOptions = [] as StateType["tabOptions"];
     tabOptions.push({ value: "manage-access", label: "Manage Access" });
     tabOptions.push({
       value: "billing",
@@ -71,13 +79,6 @@ class ProjectSettings extends Component<PropsType, StateType> {
     });
 
     if (this.props.isAuthorized("settings", "", ["get", "delete"])) {
-      // if (this.context?.hasBillingEnabled) {
-      //   tabOptions.push({
-      //     value: "billing",
-      //     label: "Billing",
-      //   });
-      // }
-
       if (currentProject?.api_tokens_enabled) {
         tabOptions.push({
           value: "api-tokens",
@@ -91,10 +92,15 @@ class ProjectSettings extends Component<PropsType, StateType> {
       });
     }
 
+    tabOptions.push({
+      value: "roles-admin",
+      label: "Roles Admin",
+    });
+
     this.setState({ tabOptions });
 
     const selectedTab = getQueryParam(this.props, "selected_tab");
-    if (selectedTab) {
+    if (selectedTab && isValidTab(selectedTab)) {
       this.setState({ currentTab: selectedTab });
     }
   }
@@ -104,70 +110,68 @@ class ProjectSettings extends Component<PropsType, StateType> {
       return <InvitePage />;
     }
 
-    // if (
-    //   this.state.currentTab === "billing" &&
-    //   this.context?.hasBillingEnabled
-    // ) {
-    //   return <BillingPage />;
-    // }
-
-    if (this.state.currentTab === "manage-access") {
-      return <InvitePage />;
-    } else if (this.state.currentTab === "api-tokens") {
-      return <APITokensSection />;
-    } else if (this.state.currentTab === "billing") {
-      return (
-        <Placeholder>
-          <Helper>
-            Visit the{" "}
-            <a
-              href={`/api/projects/${this.context.currentProject?.id}/billing/redirect`}
-            >
-              billing portal
-            </a>{" "}
-            to view plans.
-          </Helper>
-        </Placeholder>
-      );
-    } else {
-      return (
-        <>
-          <Heading isAtTop={true}>Delete Project</Heading>
-          <Helper>
-            Permanently delete this project. This will destroy all clusters tied
-            to this project that have been provisioned by Porter. Note that this
-            will not delete the image registries provisioned by Porter. To
-            delete the registries, please do so manually in your cloud console.
-          </Helper>
-
-          <Helper>
-            Destruction of resources sometimes results in dangling resources. To
-            ensure that everything has been properly destroyed, please visit
-            your cloud provider's console. Instructions to properly delete all
-            resources can be found
-            <a
-              target="none"
-              href="https://docs.getporter.dev/docs/deleting-dangling-resources"
+    switch (this.state.currentTab) {
+      case "roles-admin":
+        return <RolesAdmin />;
+      case "manage-access":
+        return <InvitePage />;
+      case "api-tokens":
+        return <APITokensSection />;
+      case "billing":
+        return (
+          <Placeholder>
+            <Helper>
+              Visit the{" "}
+              <a
+                href={`/api/projects/${this.context.currentProject?.id}/billing/redirect`}
+              >
+                billing portal
+              </a>{" "}
+              to view plans.
+            </Helper>
+          </Placeholder>
+        );
+      case "additional-settings":
+      default:
+        return (
+          <>
+            <Heading isAtTop={true}>Delete Project</Heading>
+            <Helper>
+              Permanently delete this project. This will destroy all clusters
+              tied to this project that have been provisioned by Porter. Note
+              that this will not delete the image registries provisioned by
+              Porter. To delete the registries, please do so manually in your
+              cloud console.
+            </Helper>
+
+            <Helper>
+              Destruction of resources sometimes results in dangling resources.
+              To ensure that everything has been properly destroyed, please
+              visit your cloud provider's console. Instructions to properly
+              delete all resources can be found
+              <a
+                target="none"
+                href="https://docs.getporter.dev/docs/deleting-dangling-resources"
+              >
+                {" "}
+                here
+              </a>
+              .
+            </Helper>
+
+            <Warning highlight={true}>This action cannot be undone.</Warning>
+
+            <DeleteButton
+              onClick={() => {
+                this.context.setCurrentModal("UpdateProjectModal", {
+                  currentProject: this.context.currentProject,
+                });
+              }}
             >
-              {" "}
-              here
-            </a>
-            .
-          </Helper>
-
-          <Warning highlight={true}>This action cannot be undone.</Warning>
-
-          <DeleteButton
-            onClick={() => {
-              this.context.setCurrentModal("UpdateProjectModal", {
-                currentProject: this.context.currentProject,
-              });
-            }}
-          >
-            Delete Project
-          </DeleteButton>
-        </>
-      );
+              Delete Project
+            </DeleteButton>
+          </>
+        );
     }
   };
 
@@ -177,7 +181,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
         <TitleSection>Project Settings</TitleSection>
         <TabRegion
           currentTab={this.state.currentTab}
-          setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+          setCurrentTab={(x: AvailableTabs) => this.setState({ currentTab: x })}
           options={this.state.tabOptions}
         >
           {this.renderTabContents()}

+ 30 - 0
dashboard/src/main/home/project-settings/roles-admin/RolesAdmin.tsx

@@ -0,0 +1,30 @@
+import React, { useContext, useEffect, useState } from "react";
+import { Context } from "shared/Context";
+import CreateRole from "./pages/CreateRole";
+import EditRole from "./pages/EditRole";
+import ListRoles from "./pages/ListRoles";
+import { RolesAdminProvider } from "./Store";
+
+const AVAILABLE_PAGES = ["index", "create-role", "edit-role"] as const;
+
+type AVAILABLE_PAGES_TYPE = typeof AVAILABLE_PAGES[number];
+
+export type Navigate = (page: AVAILABLE_PAGES_TYPE) => void;
+
+export const RolesAdmin = () => {
+  const [page, setPage] = useState<AVAILABLE_PAGES_TYPE>("index");
+
+  const navigate: Navigate = (page) => {
+    setPage(page);
+  };
+
+  return (
+    <>
+      <RolesAdminProvider>
+        {page === "index" ? <ListRoles navigate={navigate} /> : null}
+        {page === "create-role" ? <CreateRole navigate={navigate} /> : null}
+        {page === "edit-role" && <EditRole navigate={navigate} />}
+      </RolesAdminProvider>
+    </>
+  );
+};

+ 204 - 0
dashboard/src/main/home/project-settings/roles-admin/Store.tsx

@@ -0,0 +1,204 @@
+import React, { createContext, useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { CreateRoleBody, Role, RoleList, UpdateRoleBody } from "./types";
+
+import { Context as GlobalContext } from "shared/Context";
+
+export const RolesAdminContext = createContext({
+  loading: false,
+  error: null,
+
+  setLoading: (loading: boolean) => {},
+  setError: (error: string) => {},
+  clearError: () => {},
+
+  currentRole: {} as Role,
+  setCurrentRole: (role: Role) => {},
+  clearCurrentRole: () => {},
+});
+
+export const RolesAdminProvider: React.FC = ({ children }) => {
+  const [loading, setLoading] = useState<boolean>(false);
+  const [error, setError] = useState<string>(null);
+  const [currentRole, setCurrentRole] = useState<Role>(null);
+  const [defaultHierarchyTree, setDefaultHierarchyTree] = useState(null);
+
+  const clearError = () => {
+    setError(null);
+  };
+
+  const clearCurrentRole = () => {
+    setCurrentRole(null);
+  };
+
+  const { currentProject } = useContext(GlobalContext);
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    api
+      .getScopeHierarchy(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setDefaultHierarchyTree(res.data);
+      });
+  }, [currentProject?.id]);
+
+  return (
+    <RolesAdminContext.Provider
+      value={{
+        loading,
+        error,
+        setLoading,
+        setError,
+        clearError,
+        currentRole,
+        setCurrentRole,
+        clearCurrentRole,
+      }}
+    >
+      {children}
+    </RolesAdminContext.Provider>
+  );
+};
+
+export const useRoleList = () => {
+  const { currentProject } = useContext(GlobalContext);
+  const [isLoading, setIsLoading] = useState<boolean>(false);
+  const [error, setError] = useState<string>(null);
+  const [roles, setRoles] = useState<RoleList>([]);
+
+  const refetch = async () => {
+    setIsLoading(true);
+    setError(null);
+    try {
+      const res = await api.listRoles<RoleList>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject?.id,
+        }
+      );
+      setRoles(res.data);
+    } catch (err) {
+      setError(err.message);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    refetch();
+  }, []);
+
+  return {
+    refetch,
+    isLoading,
+    error,
+    roles,
+  };
+};
+
+export const useCreateRole = () => {
+  const { currentProject } = useContext(GlobalContext);
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState(null);
+
+  const mutate = async (body: CreateRoleBody) => {
+    setIsLoading(true);
+    try {
+      const { data } = await api.createRole<Role>(
+        "<token>",
+        {
+          ...body,
+        },
+        {
+          project_id: currentProject.id,
+        }
+      );
+      return data;
+    } catch (error) {
+      setError(error);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  return {
+    mutate,
+    loading: isLoading,
+    error,
+  };
+};
+
+export const useUpdateRole = () => {
+  const { currentProject } = useContext(GlobalContext);
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState(null);
+
+  const mutate = async (body: UpdateRoleBody) => {
+    setIsLoading(true);
+    try {
+      const { data } = await api.updateRole<Role>(
+        "<token>",
+        {
+          ...body,
+        },
+        {
+          project_id: currentProject.id,
+          role_id: body.id,
+        }
+      );
+      return data;
+    } catch (error) {
+      setError(error);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  return {
+    mutate,
+    loading: isLoading,
+    error,
+  };
+};
+
+export const useDeleteRole = () => {
+  const { currentProject } = useContext(GlobalContext);
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState(null);
+
+  const mutate = async (role_id: Role["id"]) => {
+    setIsLoading(true);
+    try {
+      await api.deleteRole(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          role_id,
+        }
+      );
+    } catch (error) {
+      setError(error);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  return {
+    mutate,
+    loading: isLoading,
+    error,
+  };
+};

+ 218 - 0
dashboard/src/main/home/project-settings/roles-admin/components/PolicyDocumentRenderer.tsx

@@ -0,0 +1,218 @@
+import { capitalize, get, set } from "lodash";
+import React, { useCallback, useContext, useEffect } from "react";
+import api from "shared/api";
+import {
+  POLICY_HIERARCHY_TREE,
+  populatePolicy,
+} from "shared/auth/authorization-helpers";
+import { PolicyDocType, ScopeType, Verbs } from "shared/auth/types";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+
+type VerbStore = {
+  data: PolicyDocType;
+  handleChangeVerbs: (path: string, values: string[]) => void;
+};
+
+// Store that will save the current state of the policy document,
+// only changes applied for the policy document are verb changes.
+const Store = React.createContext<VerbStore>({
+  data: null,
+  handleChangeVerbs: () => {},
+});
+
+const PolicyDocumentRenderer = ({
+  value,
+  onChange,
+  readOnly,
+}: {
+  value: PolicyDocType;
+  onChange: (data: PolicyDocType) => void;
+  readOnly?: boolean;
+}) => {
+  const { currentProject } = useContext(Context);
+  const [scopeHierarchy, setScopeHierarchy] = React.useState<any>(null);
+
+  useEffect(() => {
+    api
+      .getScopeHierarchy("<token>", {}, { project_id: currentProject.id })
+      .then((res) => {
+        setScopeHierarchy(res.data);
+        const newPolicyDoc = structuredClone(value);
+
+        onChange(populatePolicy(newPolicyDoc, res.data));
+      });
+  }, [currentProject?.id]);
+
+  const handleChangeVerbs = (dataPath: string, verbs: Verbs[]) => {
+    const newPolicyDoc = structuredClone(value) as PolicyDocType;
+
+    set(newPolicyDoc, dataPath, verbs);
+
+    onChange(newPolicyDoc);
+  };
+
+  if (!scopeHierarchy) {
+    return (
+      <>
+        <h1>Loading...</h1>
+      </>
+    );
+  }
+
+  return (
+    <Store.Provider
+      value={{ data: populatePolicy(value, scopeHierarchy), handleChangeVerbs }}
+    >
+      {RenderComponents(readOnly, value, scopeHierarchy)}
+    </Store.Provider>
+  );
+};
+
+export default PolicyDocumentRenderer;
+
+const RenderComponents = (
+  readOnly: boolean,
+  policyDocument: PolicyDocType,
+  tree = POLICY_HIERARCHY_TREE,
+  dataPath = "",
+  anidationLevel = 0
+) => {
+  const scope = policyDocument.scope;
+
+  const currTree = tree[scope];
+  const treeKeys = Object.keys(currTree) as Array<ScopeType>;
+
+  let components: React.ReactElement[] = [];
+
+  const newDataPath = anidationLevel === 0 ? "" : dataPath + "." + scope;
+
+  const verbsPath = newDataPath === "" ? "verbs" : newDataPath + ".verbs";
+
+  const childrenPath =
+    newDataPath === "" ? "children" : newDataPath + ".children";
+
+  for (const child of treeKeys) {
+    let childPolicy = policyDocument.children[child];
+
+    if (!childPolicy) {
+      continue;
+    }
+
+    const children = RenderComponents(
+      readOnly,
+      childPolicy,
+      currTree,
+      childrenPath,
+      anidationLevel + 1
+    );
+    components = [...components, children];
+  }
+
+  const Component = (
+    <>
+      <Card anidationLevel={anidationLevel}>
+        <ScopePermissionsHandler
+          name={scope}
+          dataPath={verbsPath}
+          readOnly={readOnly}
+        />
+      </Card>
+      {components.map((c) => c)}
+    </>
+  );
+  return Component;
+};
+
+const Card = styled.div<{ anidationLevel: number }>`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-around;
+  background-color: #2b2e3699;
+  margin-left: ${({ anidationLevel }) => `${anidationLevel * 20}px`};
+  margin-bottom: 15px;
+`;
+
+const ScopePermissionsHandler = ({
+  name,
+  dataPath,
+  readOnly,
+}: {
+  name: string;
+  dataPath: string;
+  readOnly: boolean;
+}) => {
+  const { handleChangeVerbs, data } = React.useContext(Store);
+
+  const verbs = get(data, dataPath);
+
+  return (
+    <>
+      {name}
+      {readOnly ? null : (
+        <Select
+          values={verbs}
+          onChange={(newVerbs) => handleChangeVerbs(dataPath, newVerbs)}
+        />
+      )}
+    </>
+  );
+};
+
+type SelectProps = {
+  values: Verbs[];
+  onChange: (newVerbs: Verbs[]) => void;
+  disabled?: boolean;
+};
+
+const Select = ({ values, onChange }: SelectProps) => {
+  const options = ["create", "read", "update", "delete"] as const;
+  const [open, setOpen] = React.useState(false);
+
+  const handleChange = (opt: typeof options[number], add: boolean) => {
+    const verbs: Verbs[] = opt === "read" ? ["get", "list"] : [opt];
+
+    if (add) {
+      handleAdd(verbs);
+    } else {
+      handleDelete(verbs);
+    }
+  };
+
+  const handleAdd = (verbs: Verbs[]) => {
+    const newValues = [...verbs, ...values];
+    onChange(newValues);
+  };
+
+  const handleDelete = (verbs: Verbs[]) => {
+    const newValues = values.filter((val) => !verbs.includes(val));
+
+    onChange(newValues);
+  };
+
+  return (
+    <div>
+      <i onClick={() => setOpen(!open)}>{capitalize(values.join(", "))}</i>
+      <div>
+        {options.map((opt) => {
+          const isChecked =
+            opt === "read"
+              ? values.find((val) => val === "get")
+              : values.find((val) => val === opt);
+          return (
+            <div>
+              <input
+                type="checkbox"
+                name={opt}
+                checked={!!isChecked}
+                onChange={(e) => handleChange(opt, e.target.checked)}
+              />
+              <label htmlFor={opt}>{capitalize(opt)}</label>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+};

+ 1 - 0
dashboard/src/main/home/project-settings/roles-admin/index.ts

@@ -0,0 +1 @@
+export { RolesAdmin } from "./RolesAdmin";

+ 130 - 0
dashboard/src/main/home/project-settings/roles-admin/pages/CreateRole.tsx

@@ -0,0 +1,130 @@
+import InputRow from "components/form-components/InputRow";
+import SaveButton from "components/SaveButton";
+import SearchSelector from "components/SearchSelector";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import {
+  populatePolicy,
+  VIEWER_POLICY_MOCK,
+} from "shared/auth/authorization-helpers";
+import { PolicyDocType } from "shared/auth/types";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import PolicyDocumentRenderer from "../components/PolicyDocumentRenderer";
+import { Navigate } from "../RolesAdmin";
+import { RolesAdminContext, useCreateRole } from "../Store";
+
+type PartialUser = {
+  user_id: number;
+  email: string;
+};
+
+const CreateRole = ({ navigate }: { navigate: Navigate }) => {
+  const { currentProject } = useContext(Context);
+  const { mutate, loading, error } = useCreateRole();
+  const [name, setName] = useState("");
+  const [policyDocument, setPolicyDocument] = useState<PolicyDocType>(
+    VIEWER_POLICY_MOCK
+  );
+  const [availableUsers, setAvailableUsers] = useState<PartialUser[]>([]);
+  const [users, setUsers] = useState<PartialUser[]>([]);
+
+  useEffect(() => {
+    api
+      .getCollaborators<PartialUser[]>(
+        "<token>",
+        {},
+        { project_id: currentProject.id }
+      )
+      .then((res) => {
+        setAvailableUsers(res.data);
+      });
+  }, [currentProject]);
+
+  const filteredUsers = availableUsers.filter(
+    (user) => !users.find((u) => u.user_id === user.user_id)
+  );
+
+  const handleSave = async () => {
+    await mutate({
+      name,
+      policy: policyDocument,
+      users: users.map((user) => user.user_id),
+    });
+    navigate("index");
+  };
+
+  return (
+    <div style={{ paddingBottom: "300px" }}>
+      <h1>CreateRole</h1>
+
+      <button onClick={() => navigate("index")}>Back</button>
+
+      <InputWrapper>
+        <InputRow
+          type="string"
+          setValue={(val) => setName(val as string)}
+          value={name}
+          label="Name"
+          width="100%"
+        />
+      </InputWrapper>
+
+      <PolicyDocumentRenderer
+        value={policyDocument}
+        onChange={setPolicyDocument}
+      />
+
+      <SearchSelector
+        options={filteredUsers}
+        onSelect={(user) => {
+          setUsers([...users, user]);
+        }}
+        label="Users"
+        filterBy={(user) => user.email}
+        getOptionLabel={(user) => user.email}
+        placeholder="Search for users"
+        noOptionsText="Seems like you selected all users available!"
+      />
+      <UserList>
+        {users.map((user) => (
+          <User key={user.user_id}>
+            {user.email}
+            {/* add Delete button */}
+            <button
+              onClick={() => {
+                setUsers(users.filter((u) => u.user_id !== user.user_id));
+              }}
+            >
+              X
+            </button>
+          </User>
+        ))}
+      </UserList>
+      <SaveButton
+        text="Save"
+        onClick={handleSave}
+        makeFlush
+        clearPosition
+        status={loading ? "loading" : ""}
+      />
+    </div>
+  );
+};
+
+export default CreateRole;
+
+const InputWrapper = styled.div`
+  max-width: 300px;
+`;
+
+const UserList = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-top: 10px;
+`;
+
+const User = styled.div`
+  margin-bottom: 10px;
+`;

+ 117 - 0
dashboard/src/main/home/project-settings/roles-admin/pages/EditRole.tsx

@@ -0,0 +1,117 @@
+import SearchSelector from "components/SearchSelector";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { PolicyDocType } from "shared/auth/types";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import PolicyDocumentRenderer from "../components/PolicyDocumentRenderer";
+import { Navigate } from "../RolesAdmin";
+import { RolesAdminContext, useUpdateRole } from "../Store";
+
+type PartialUser = {
+  user_id: number;
+  email: string;
+};
+
+const EditRole = ({ navigate }: { navigate: Navigate }) => {
+  const { currentProject } = useContext(Context);
+  const { currentRole } = useContext(RolesAdminContext);
+  const [policy, setPolicy] = useState<PolicyDocType>(() => {
+    return currentRole.policy;
+  });
+
+  const [availableUsers, setAvailableUsers] = useState<PartialUser[]>([]);
+  const [users, setUsers] = useState<PartialUser[]>([]);
+  const { mutate, loading: saving, error: saveError } = useUpdateRole();
+
+  useEffect(() => {
+    api
+      .getCollaborators<PartialUser[]>(
+        "<token>",
+        {},
+        { project_id: currentProject.id }
+      )
+      .then((res) => {
+        setAvailableUsers(
+          res.data.filter((user) => !currentRole.users?.includes(user.user_id))
+        );
+        setUsers(
+          res.data.filter((user) => currentRole.users?.includes(user.user_id))
+        );
+      });
+  }, [currentProject]);
+
+  const filteredUsers = availableUsers.filter(
+    (user) => !users.find((u) => u.user_id === user.user_id)
+  );
+
+  const handleSave = () => {
+    mutate({
+      id: currentRole.id,
+      name: currentRole.name,
+      policy,
+      users: users?.map((user) => user.user_id) || [],
+    });
+
+    navigate("index");
+  };
+
+  return (
+    <div
+      style={{
+        paddingBottom: "300px",
+      }}
+    >
+      EditRole <button onClick={() => navigate("index")}>Back</button>
+      <h1>{currentRole.name}</h1>
+      <PolicyDocumentRenderer
+        value={policy}
+        onChange={(policy) => {
+          setPolicy(policy);
+        }}
+        readOnly={currentRole.id.includes(`${currentProject.id}-`)}
+      />
+      <SearchSelector
+        options={filteredUsers}
+        onSelect={(user) => {
+          setUsers([...users, user]);
+        }}
+        label="Users"
+        filterBy={(user) => user.email}
+        getOptionLabel={(user) => user.email}
+        placeholder="Search for users"
+        noOptionsText="Seems like you selected all users available!"
+      />
+      <UserList>
+        {users.map((user) => (
+          <User key={user.user_id}>
+            {user.email}
+            {/* add Delete button */}
+
+            <button
+              onClick={() => {
+                setUsers(users.filter((u) => u.user_id !== user.user_id));
+              }}
+            >
+              X
+            </button>
+          </User>
+        ))}
+      </UserList>
+      <button onClick={() => handleSave()}>Save</button>
+    </div>
+  );
+};
+
+export default EditRole;
+
+const UserList = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-top: 10px;
+`;
+
+const User = styled.div`
+  margin-bottom: 10px;
+`;

+ 38 - 0
dashboard/src/main/home/project-settings/roles-admin/pages/ListRoles.tsx

@@ -0,0 +1,38 @@
+import React, { useContext } from "react";
+import { RolesAdminContext, useRoleList } from "../Store";
+import type { Navigate } from "../RolesAdmin";
+import { isEmpty } from "lodash";
+import { VIEWER_POLICY_MOCK } from "shared/auth/authorization-helpers";
+import { Role } from "../types";
+
+const ListRoles = ({ navigate }: { navigate: Navigate }) => {
+  const { setCurrentRole } = useContext(RolesAdminContext);
+
+  const { isLoading, roles, error, refetch } = useRoleList();
+
+  const handleEdit = (role: Role) => {
+    setCurrentRole(role);
+    navigate("edit-role");
+  };
+
+  return (
+    <div>
+      <button onClick={() => navigate("create-role")}>Create Role</button>
+      <h1>ListRoles</h1>
+      {isLoading ? (
+        <div>Loading...</div>
+      ) : (
+        <ul>
+          {roles.map((role) => (
+            <li key={role.id}>
+              {role.name}
+              <button onClick={() => handleEdit(role as Role)}>Edit</button>
+            </li>
+          ))}
+        </ul>
+      )}
+    </div>
+  );
+};
+
+export default ListRoles;

+ 14 - 0
dashboard/src/main/home/project-settings/roles-admin/types.ts

@@ -0,0 +1,14 @@
+import { PolicyDocType } from "shared/auth/types";
+
+export type Role = {
+  id: string; // role ID
+  name: string; // role name
+  users: number[]; // list of user IDs
+  policy: PolicyDocType;
+};
+
+export type RoleList = Role[];
+
+export type CreateRoleBody = Omit<Role, "id">;
+
+export type UpdateRoleBody = Role;

+ 89 - 1
dashboard/src/shared/api.tsx

@@ -7,6 +7,10 @@ import {
   CreateStackBody,
   SourceConfig,
 } from "main/home/cluster-dashboard/stacks/types";
+import {
+  CreateRoleBody,
+  UpdateRoleBody,
+} from "main/home/project-settings/roles-admin/types";
 
 /**
  * Generic api call format
@@ -274,6 +278,8 @@ const createGCPIntegration = baseApi<
 const createInvite = baseApi<
   {
     email: string;
+    roles: string[];
+    // legacy field
     kind: string;
   },
   {
@@ -1567,7 +1573,7 @@ const getAvailableRoles = baseApi<{}, { project_id: number }>(
 );
 
 const updateInvite = baseApi<
-  { kind: string },
+  { kind: string; roles: string[] },
   { project_id: number; invite_id: number }
 >(
   "POST",
@@ -1584,6 +1590,7 @@ const updateCollaborator = baseApi<
   {
     kind: string;
     user_id: number;
+    roles: string[];
   },
   { project_id: number }
 >("POST", ({ project_id }) => `/api/projects/${project_id}/roles`);
@@ -2182,6 +2189,79 @@ const removeStackEnvGroup = baseApi<
 
 const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 
+// ROLES
+
+/**
+  POST /api/projects/{project_id}/project_roles
+  GET /api/projects/{project_id}/project_roles/{role_id}
+  GET /api/projects/{project_id}/project_roles
+  PATCH /api/projects/{project_id}/project_roles/{role_id}
+  DELETE /api/projects/{project_id}/project_roles/{role_id}
+  PATCH /api/projects/{project_id}/invites/{invite_id}
+  POST /api/projects/{project_id}/invites
+ */
+
+const listRoles = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/project_roles`
+);
+
+const createRole = baseApi<
+  CreateRoleBody,
+  {
+    project_id: number;
+  }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/project_roles`);
+
+const getRole = baseApi<
+  {},
+  {
+    project_id: number;
+    role_id: string;
+  }
+>(
+  "GET",
+  ({ project_id, role_id }) =>
+    `/api/projects/${project_id}/project_roles/${role_id}`
+);
+
+const updateRole = baseApi<
+  UpdateRoleBody,
+  {
+    project_id: number;
+    role_id: string;
+  }
+>(
+  "PATCH",
+  ({ project_id, role_id }) =>
+    `/api/projects/${project_id}/project_roles/${role_id}`
+);
+
+const deleteRole = baseApi<
+  {},
+  {
+    project_id: number;
+    role_id: string;
+  }
+>(
+  "DELETE",
+  ({ project_id, role_id }) =>
+    `/api/projects/${project_id}/project_roles/${role_id}`
+);
+
+const getScopeHierarchy = baseApi<
+  {},
+  {
+    project_id: number;
+  }
+>(
+  "GET",
+  ({ project_id }) =>
+    `/api/projects/${project_id}/project_roles/scope_hierarchy`
+);
+
+// /ROLES
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -2386,4 +2466,12 @@ export default {
 
   // STATUS
   getGithubStatus,
+
+  // ROLES
+  listRoles,
+  getRole,
+  createRole,
+  updateRole,
+  deleteRole,
+  getScopeHierarchy,
 };

+ 8 - 5
dashboard/src/shared/auth/authorization-helpers.ts

@@ -93,11 +93,14 @@ export const isAuthorized = (
 
 export const populatePolicy = (
   currPolicy: PolicyDocType,
-  tree: HIERARCHY_TREE,
-  currScope: ScopeType,
-  parentVerbs: Array<Verbs>
+  tree: HIERARCHY_TREE = POLICY_HIERARCHY_TREE,
+  currentScope?: ScopeType,
+  parentVerbs?: Array<Verbs>
 ) => {
-  const currTree = tree[currScope];
+  const scope = currentScope || currPolicy.scope;
+  const verbs = parentVerbs || currPolicy.verbs;
+
+  const currTree = tree[scope];
   const treeKeys = Object.keys(currTree) as Array<ScopeType>;
 
   currPolicy.children = currPolicy?.children || {};
@@ -108,7 +111,7 @@ export const populatePolicy = (
     if (!childPolicy) {
       childPolicy = {
         scope: child,
-        verbs: parentVerbs,
+        verbs: verbs,
         resources: [],
         children: {},
       };

+ 2 - 0
dashboard/src/shared/types.tsx

@@ -293,6 +293,8 @@ export interface InviteType {
   email: string;
   accepted: boolean;
   id: number;
+  // List of roles ids
+  roles: string[];
 }
 
 export type ActionConfigType = {

+ 60 - 14
ee/api/server/handlers/invite/accept.go

@@ -15,6 +15,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 )
 
@@ -78,23 +79,36 @@ func (c *InviteAcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	kind := invite.Kind
+	inviteType := invite.ToInviteType()
 
-	if kind == "" {
-		kind = models.RoleDeveloper
-	}
+	if len(inviteType.Roles) > 0 {
+		for _, roleUID := range inviteType.Roles {
+			err := updateProjectRoleWithUser(c.Repo(), proj.ID, user.ID, roleUID)
 
-	role := &models.Role{
-		Role: types.Role{
-			UserID:    user.ID,
-			ProjectID: proj.ID,
-			Kind:      types.RoleKind(kind),
-		},
-	}
+			if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such role exists")))
+				return
+			} else if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		}
+	} else { // legacy operation
+		kind := invite.Kind
 
-	if role, err = c.Repo().Project().CreateProjectRole(proj, role); err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
+		if kind == "" {
+			kind = models.RoleDeveloper
+		}
+
+		err := updateProjectRoleWithUser(c.Repo(), proj.ID, user.ID, fmt.Sprintf("%d-%s", proj.ID, kind))
+
+		if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such role exists")))
+			return
+		} else if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 	}
 
 	// update the invite
@@ -107,3 +121,35 @@ func (c *InviteAcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	http.Redirect(w, r, "/dashboard", 302)
 }
+
+func updateProjectRoleWithUser(repo repository.Repository, projectID, userID uint, projectRoleUID string) error {
+	role, err := repo.ProjectRole().ReadProjectRole(projectID, projectRoleUID)
+
+	if err != nil {
+		return err
+	}
+
+	userAlreadyInRole := false
+	var userIDs []uint
+
+	for _, u := range role.Users {
+		if u.ID == userID {
+			userAlreadyInRole = true
+			break
+		}
+
+		userIDs = append(userIDs, u.ID)
+	}
+
+	if !userAlreadyInRole {
+		userIDs = append(userIDs, userID)
+
+		err := repo.ProjectRole().UpdateUsersInProjectRole(projectID, role.UniqueID, userIDs)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}

+ 22 - 2
ee/api/server/handlers/invite/create.go

@@ -1,10 +1,13 @@
+//go:build ee
 // +build ee
 
 package invite
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
+	"strings"
 	"time"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -15,6 +18,7 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/oauth"
+	"gorm.io/gorm"
 )
 
 type InviteCreateHandler struct {
@@ -41,6 +45,23 @@ func (c *InviteCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	if len(request.RoleUIDs) == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("roles cannot be empty"), http.StatusPreconditionFailed,
+		))
+		return
+	} else {
+		// check for valid project roles
+		for _, role := range request.RoleUIDs {
+			_, err := c.Repo().ProjectRole().ReadProjectRole(project.ID, role)
+
+			if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("role not found in project: %s", role)))
+				return
+			}
+		}
+	}
+
 	// create invite model
 	invite, err := CreateInviteWithProject(request, project.ID)
 
@@ -57,8 +78,6 @@ func (c *InviteCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	// app.Logger.Info().Msgf("New invite created: %d", invite.ID)
-
 	if err := c.Config().UserNotifier.SendProjectInviteEmail(
 		&notifier.SendProjectInviteEmailOpts{
 			InviteeEmail:      request.Email,
@@ -88,5 +107,6 @@ func CreateInviteWithProject(invite *types.CreateInviteRequest, projectID uint)
 		Expiry:    &expiry,
 		ProjectID: projectID,
 		Token:     oauth.CreateRandomState(),
+		Roles:     []byte(strings.Join(invite.RoleUIDs, ",")),
 	}, nil
 }

+ 2 - 1
ee/api/server/handlers/invite/list.go

@@ -1,3 +1,4 @@
+//go:build ee
 // +build ee
 
 package invite
@@ -36,7 +37,7 @@ func (c *InvitesListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	var res types.ListInvitesResponse = make([]*types.Invite, 0)
+	var res types.ListInvitesResponse
 
 	for _, invite := range invites {
 		res = append(res, invite.ToInviteType())

+ 37 - 4
ee/api/server/handlers/invite/update_role.go

@@ -1,9 +1,13 @@
+//go:build ee
 // +build ee
 
 package invite
 
 import (
+	"errors"
+	"fmt"
 	"net/http"
+	"strings"
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -11,6 +15,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
 )
 
 type InviteUpdateRoleHandler struct {
@@ -28,6 +33,7 @@ func NewInviteUpdateRoleHandler(
 
 func (c *InviteUpdateRoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	invite, _ := r.Context().Value(types.InviteScope).(*models.Invite)
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
 	request := &types.UpdateInviteRoleRequest{}
 
@@ -35,11 +41,38 @@ func (c *InviteUpdateRoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	invite.Kind = request.Kind
+	if request.Kind == "" && len(request.RoleUIDs) == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("roles cannot be empty"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	changed := false
+
+	if len(request.RoleUIDs) > 0 {
+		// check for valid project roles
+		for _, roleUID := range request.RoleUIDs {
+			_, err := c.Repo().ProjectRole().ReadProjectRole(project.ID, roleUID)
 
-	if _, err := c.Repo().Invite().UpdateInvite(invite); err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("role not found in project: %s", roleUID)))
+				return
+			}
+		}
+
+		invite.Roles = []byte(strings.Join(request.RoleUIDs, ","))
+
+		changed = true
+	} else if request.Kind != "" { // legacy invite
+		invite.Kind = request.Kind
+
+		changed = true
 	}
 
-	w.WriteHeader(http.StatusOK)
+	if changed {
+		if _, err := c.Repo().Invite().UpdateInvite(invite); err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		}
+	}
 }

+ 5 - 3
internal/models/invite.go

@@ -1,6 +1,7 @@
 package models
 
 import (
+	"strings"
 	"time"
 
 	"gorm.io/gorm"
@@ -16,11 +17,12 @@ type Invite struct {
 	Expiry *time.Time
 	Email  string
 
-	// Kind is the role kind that this refers to
+	// Kind is the role kind that this refers to (legacy field)
 	Kind string
 
 	ProjectID uint
 	UserID    uint
+	Roles     []byte // stored as a byte-array of comma-separated strings of role UIDs
 }
 
 // ToInviteType generates an external Invite to be shared over REST
@@ -32,12 +34,12 @@ func (i *Invite) ToInviteType() *types.Invite {
 		Expired:  i.IsExpired(),
 		Accepted: i.IsAccepted(),
 		Kind:     i.Kind,
+		Roles:    strings.Split(string(i.Roles), ","),
 	}
 }
 
 func (i *Invite) IsExpired() bool {
-	timeLeft := i.Expiry.Sub(time.Now())
-	return timeLeft < 0
+	return time.Until(*i.Expiry) < 0
 }
 
 func (i *Invite) IsAccepted() bool {

+ 41 - 0
internal/models/project_role.go

@@ -0,0 +1,41 @@
+package models
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+type ProjectRole struct {
+	gorm.Model
+
+	UniqueID string `gorm:"unique"`
+
+	ProjectID uint
+	PolicyUID string
+
+	Name string
+
+	Users []User `gorm:"many2many:user_roles"`
+}
+
+func (role *ProjectRole) ToProjectRoleType(policy *types.PolicyDocument) *types.ProjectRole {
+	res := &types.ProjectRole{
+		ID:     role.UniqueID,
+		Name:   role.Name,
+		Policy: policy,
+	}
+
+	for _, user := range role.Users {
+		res.Users = append(res.Users, user.ID)
+	}
+
+	return res
+}
+
+func (role *ProjectRole) IsDefaultRole() bool {
+	return role.UniqueID == fmt.Sprintf("%d-%s", role.ProjectID, types.RoleAdmin) ||
+		role.UniqueID == fmt.Sprintf("%d-%s", role.ProjectID, types.RoleDeveloper) ||
+		role.UniqueID == fmt.Sprintf("%d-%s", role.ProjectID, types.RoleViewer)
+}

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

@@ -50,6 +50,7 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&models.Allowlist{},
 		&models.APIToken{},
 		&models.Policy{},
+		&models.ProjectRole{},
 		&models.Tag{},
 		&models.Stack{},
 		&models.StackRevision{},

+ 7 - 7
internal/repository/gorm/project.go

@@ -26,8 +26,8 @@ func (repo *ProjectRepository) CreateProject(project *models.Project) (*models.P
 	return project, nil
 }
 
-// CreateProjectRole appends a role to the existing array of roles
-func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *models.Role) (*models.Role, error) {
+// CreateLegacyProjectRole appends a role to the existing array of roles
+func (repo *ProjectRepository) CreateLegacyProjectRole(project *models.Project, role *models.Role) (*models.Role, error) {
 	assoc := repo.db.Model(&project).Association("Roles")
 
 	if assoc.Error != nil {
@@ -50,7 +50,7 @@ func (repo *ProjectRepository) UpdateProject(project *models.Project) (*models.P
 	return project, nil
 }
 
-func (repo *ProjectRepository) UpdateProjectRole(projID uint, role *models.Role) (*models.Role, error) {
+func (repo *ProjectRepository) UpdateLegacyProjectRole(projID uint, role *models.Role) (*models.Role, error) {
 	foundRole := &models.Role{}
 
 	if err := repo.db.Where("project_id = ? AND user_id = ?", projID, role.UserID).First(&foundRole).Error; err != nil {
@@ -78,7 +78,7 @@ func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 }
 
 // ReadProject gets a projects specified by a unique id
-func (repo *ProjectRepository) ReadProjectRole(projID, userID uint) (*models.Role, error) {
+func (repo *ProjectRepository) ReadLegacyProjectRole(projID, userID uint) (*models.Role, error) {
 	// find the role
 	role := &models.Role{}
 
@@ -93,7 +93,7 @@ func (repo *ProjectRepository) ReadProjectRole(projID, userID uint) (*models.Rol
 func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Project, error) {
 	projects := make([]*models.Project, 0)
 
-	subQuery := repo.db.Model(&models.Role{}).Where("user_id = ?", userID).Select("project_id")
+	subQuery := repo.db.Model(&models.ProjectRole{}).Joins("JOIN user_roles ON user_roles.project_role_id = project_roles.id").Where("user_id = ?", userID).Select("project_id")
 
 	if err := repo.db.Preload("Roles").Model(&models.Project{}).Where("id IN (?)", subQuery).Find(&projects).Error; err != nil {
 		return nil, err
@@ -103,7 +103,7 @@ func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Proj
 }
 
 // ReadProject gets a projects specified by a unique id
-func (repo *ProjectRepository) ListProjectRoles(projID uint) ([]models.Role, error) {
+func (repo *ProjectRepository) ListLegacyProjectRoles(projID uint) ([]models.Role, error) {
 	project := &models.Project{}
 
 	if err := repo.db.Preload("Roles").Where("id = ?", projID).First(&project).Error; err != nil {
@@ -121,7 +121,7 @@ func (repo *ProjectRepository) DeleteProject(project *models.Project) (*models.P
 	return project, nil
 }
 
-func (repo *ProjectRepository) DeleteProjectRole(projID, userID uint) (*models.Role, error) {
+func (repo *ProjectRepository) DeleteLegacyProjectRole(projID, userID uint) (*models.Role, error) {
 	// find the role
 	role := &models.Role{}
 

+ 128 - 0
internal/repository/gorm/project_role.go

@@ -0,0 +1,128 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// ProjectRoleRepository uses gorm.DB for querying the database
+type ProjectRoleRepository struct {
+	db *gorm.DB
+}
+
+// NewProjectRoleRepository returns a ProjectRoleRepository which uses
+// gorm.DB for querying the database
+func NewProjectRoleRepository(db *gorm.DB) repository.ProjectRoleRepository {
+	return &ProjectRoleRepository{db}
+}
+
+func (repo *ProjectRoleRepository) CreateProjectRole(role *models.ProjectRole) (*models.ProjectRole, error) {
+	if err := repo.db.Create(role).Error; err != nil {
+		return nil, err
+	}
+
+	return role, nil
+}
+
+func (repo *ProjectRoleRepository) ReadProjectRole(projectID uint, roleUID string) (*models.ProjectRole, error) {
+	role := &models.ProjectRole{}
+
+	if err := repo.db.Preload("Users").Where("project_id = ? AND unique_id = ?", projectID, roleUID).First(role).Error; err != nil {
+		return nil, err
+	}
+
+	return role, nil
+}
+
+func (repo *ProjectRoleRepository) ListProjectRoles(projectID uint) ([]*models.ProjectRole, error) {
+	roles := []*models.ProjectRole{}
+
+	if err := repo.db.Preload("Users").Where("project_id = ?", projectID).Find(&roles).Error; err != nil {
+		return nil, err
+	}
+
+	return roles, nil
+}
+
+func (repo *ProjectRoleRepository) ListAllRolesForUser(projectID, userID uint) ([]*models.ProjectRole, error) {
+	projectRoles := []*models.ProjectRole{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&projectRoles).Error; err != nil {
+		return nil, err
+	}
+
+	var res []*models.ProjectRole
+
+	for _, role := range projectRoles {
+		if count := repo.db.Model(role).Where("id = ?", userID).Association("Users").Count(); count == 0 {
+			continue
+		} else {
+			res = append(res, role)
+		}
+	}
+
+	return res, nil
+}
+
+func (repo *ProjectRoleRepository) UpdateUsersInProjectRole(projectID uint, roleUID string, userIDs []uint) error {
+	users := []*models.User{}
+
+	if err := repo.db.Find(&users, userIDs).Error; err != nil {
+		return err
+	}
+
+	role := &models.ProjectRole{}
+
+	if err := repo.db.Where("project_id = ? AND unique_id = ?", projectID, roleUID).First(role).Error; err != nil {
+		return err
+	}
+
+	assoc := repo.db.Model(&role).Association("Users")
+
+	if assoc.Error != nil {
+		return assoc.Error
+	}
+
+	if err := assoc.Replace(users); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (repo *ProjectRoleRepository) ClearUsersInProjectRole(projectID uint, roleUID string) error {
+	role := &models.ProjectRole{}
+
+	if err := repo.db.Where("project_id = ? AND unique_id = ?", projectID, roleUID).First(role).Error; err != nil {
+		return err
+	}
+
+	assoc := repo.db.Model(&role).Association("Users")
+
+	if assoc.Error != nil {
+		return assoc.Error
+	}
+
+	if err := assoc.Clear(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (repo *ProjectRoleRepository) UpdateProjectRole(role *models.ProjectRole) (*models.ProjectRole, error) {
+	if err := repo.db.Save(role).Error; err != nil {
+		return nil, err
+	}
+
+	return role, nil
+}
+
+func (repo *ProjectRoleRepository) DeleteProjectRole(role *models.ProjectRole) (*models.ProjectRole, error) {
+	if err := repo.db.Delete(role).Error; err != nil {
+		return nil, err
+	}
+
+	return role, nil
+}

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

@@ -10,6 +10,7 @@ type GormRepository struct {
 	user                      repository.UserRepository
 	session                   repository.SessionRepository
 	project                   repository.ProjectRepository
+	projectRole               repository.ProjectRoleRepository
 	cluster                   repository.ClusterRepository
 	database                  repository.DatabaseRepository
 	helmRepo                  repository.HelmRepoRepository
@@ -62,6 +63,10 @@ func (t *GormRepository) Project() repository.ProjectRepository {
 	return t.project
 }
 
+func (t *GormRepository) ProjectRole() repository.ProjectRoleRepository {
+	return t.projectRole
+}
+
 func (t *GormRepository) Cluster() repository.ClusterRepository {
 	return t.cluster
 }
@@ -221,6 +226,7 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		user:                      NewUserRepository(db),
 		session:                   NewSessionRepository(db),
 		project:                   NewProjectRepository(db),
+		projectRole:               NewProjectRoleRepository(db),
 		cluster:                   NewClusterRepository(db, key),
 		database:                  NewDatabaseRepository(db, key),
 		helmRepo:                  NewHelmRepoRepository(db, key),

+ 5 - 5
internal/repository/project.go

@@ -10,13 +10,13 @@ type WriteProject func(project *models.Project) (*models.Project, error)
 // ProjectRepository represents the set of queries on the Project model
 type ProjectRepository interface {
 	CreateProject(project *models.Project) (*models.Project, error)
-	CreateProjectRole(project *models.Project, role *models.Role) (*models.Role, error)
+	CreateLegacyProjectRole(project *models.Project, role *models.Role) (*models.Role, error)
 	UpdateProject(project *models.Project) (*models.Project, error)
-	UpdateProjectRole(projID uint, role *models.Role) (*models.Role, error)
+	UpdateLegacyProjectRole(projID uint, role *models.Role) (*models.Role, error)
 	ReadProject(id uint) (*models.Project, error)
-	ReadProjectRole(projID, userID uint) (*models.Role, error)
-	ListProjectRoles(projID uint) ([]models.Role, error)
+	ReadLegacyProjectRole(projID, userID uint) (*models.Role, error)
+	ListLegacyProjectRoles(projID uint) ([]models.Role, error)
 	ListProjectsByUserID(userID uint) ([]*models.Project, error)
 	DeleteProject(project *models.Project) (*models.Project, error)
-	DeleteProjectRole(projID, userID uint) (*models.Role, error)
+	DeleteLegacyProjectRole(projID, userID uint) (*models.Role, error)
 }

+ 15 - 0
internal/repository/project_role.go

@@ -0,0 +1,15 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// ProjectRoleRepository represents the set of queries on the ProjectRole model
+type ProjectRoleRepository interface {
+	CreateProjectRole(role *models.ProjectRole) (*models.ProjectRole, error)
+	ReadProjectRole(projectID uint, roleUID string) (*models.ProjectRole, error)
+	ListProjectRoles(projectID uint) ([]*models.ProjectRole, error)
+	ListAllRolesForUser(projectID, userID uint) ([]*models.ProjectRole, error)
+	UpdateUsersInProjectRole(projectID uint, roleUID string, userIDs []uint) error
+	ClearUsersInProjectRole(projectID uint, roleUID string) error
+	UpdateProjectRole(role *models.ProjectRole) (*models.ProjectRole, error)
+	DeleteProjectRole(role *models.ProjectRole) (*models.ProjectRole, error)
+}

+ 1 - 0
internal/repository/repository.go

@@ -3,6 +3,7 @@ package repository
 type Repository interface {
 	User() UserRepository
 	Project() ProjectRepository
+	ProjectRole() ProjectRoleRepository
 	Release() ReleaseRepository
 	Environment() EnvironmentRepository
 	Session() SessionRepository

+ 5 - 5
internal/repository/test/project.go

@@ -43,7 +43,7 @@ func (repo *ProjectRepository) CreateProject(project *models.Project) (*models.P
 }
 
 // CreateProjectRole appends a role to the existing array of roles
-func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *models.Role) (*models.Role, error) {
+func (repo *ProjectRepository) CreateLegacyProjectRole(project *models.Project, role *models.Role) (*models.Role, error) {
 	if !repo.canQuery || strings.Contains(repo.failingMethods, CreateProjectRoleMethod) {
 		return nil, errors.New("Cannot write database")
 	}
@@ -65,7 +65,7 @@ func (repo *ProjectRepository) UpdateProject(project *models.Project) (*models.P
 }
 
 // CreateProjectRole appends a role to the existing array of roles
-func (repo *ProjectRepository) UpdateProjectRole(projID uint, role *models.Role) (*models.Role, error) {
+func (repo *ProjectRepository) UpdateLegacyProjectRole(projID uint, role *models.Role) (*models.Role, error) {
 	if !repo.canQuery {
 		return nil, errors.New("Cannot read from database")
 	}
@@ -100,7 +100,7 @@ func (repo *ProjectRepository) UpdateProjectRole(projID uint, role *models.Role)
 }
 
 // ReadProject gets a projects specified by a unique id
-func (repo *ProjectRepository) ReadProjectRole(userID, projID uint) (*models.Role, error) {
+func (repo *ProjectRepository) ReadLegacyProjectRole(userID, projID uint) (*models.Role, error) {
 	if !repo.canQuery {
 		return nil, errors.New("Cannot read from database")
 	}
@@ -156,7 +156,7 @@ func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Proj
 }
 
 // ListProjectRoles returns a list of roles for the project
-func (repo *ProjectRepository) ListProjectRoles(projID uint) ([]models.Role, error) {
+func (repo *ProjectRepository) ListLegacyProjectRoles(projID uint) ([]models.Role, error) {
 	if !repo.canQuery {
 		return nil, errors.New("Cannot read from database")
 	}
@@ -187,7 +187,7 @@ func (repo *ProjectRepository) DeleteProject(project *models.Project) (*models.P
 	return project, nil
 }
 
-func (repo *ProjectRepository) DeleteProjectRole(projID, userID uint) (*models.Role, error) {
+func (repo *ProjectRepository) DeleteLegacyProjectRole(projID, userID uint) (*models.Role, error) {
 	if !repo.canQuery {
 		return nil, errors.New("Cannot write database")
 	}

+ 45 - 0
internal/repository/test/project_role.go

@@ -0,0 +1,45 @@
+package test
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type ProjectRoleRepository struct {
+}
+
+func NewProjectRoleRepository() repository.ProjectRoleRepository {
+	return &ProjectRoleRepository{}
+}
+
+func (repo *ProjectRoleRepository) CreateProjectRole(role *models.ProjectRole) (*models.ProjectRole, error) {
+	panic("not implemented")
+}
+
+func (repo *ProjectRoleRepository) ReadProjectRole(projectID uint, roleUID string) (*models.ProjectRole, error) {
+	panic("not implemented")
+}
+
+func (repo *ProjectRoleRepository) ListProjectRoles(projectID uint) ([]*models.ProjectRole, error) {
+	panic("not implemented")
+}
+
+func (repo *ProjectRoleRepository) ListAllRolesForUser(projectID, userID uint) ([]*models.ProjectRole, error) {
+	panic("not implemented")
+}
+
+func (repo *ProjectRoleRepository) UpdateUsersInProjectRole(projectID uint, roleUID string, userIDs []uint) error {
+	panic("not implemented")
+}
+
+func (repo *ProjectRoleRepository) ClearUsersInProjectRole(projectID uint, roleUID string) error {
+	panic("not implemented")
+}
+
+func (repo *ProjectRoleRepository) UpdateProjectRole(role *models.ProjectRole) (*models.ProjectRole, error) {
+	panic("not implemented")
+}
+
+func (repo *ProjectRoleRepository) DeleteProjectRole(role *models.ProjectRole) (*models.ProjectRole, error) {
+	panic("not implemented")
+}

+ 5 - 0
internal/repository/test/repository.go

@@ -8,6 +8,7 @@ type TestRepository struct {
 	user                      repository.UserRepository
 	session                   repository.SessionRepository
 	project                   repository.ProjectRepository
+	projectRole               repository.ProjectRoleRepository
 	cluster                   repository.ClusterRepository
 	helmRepo                  repository.HelmRepoRepository
 	registry                  repository.RegistryRepository
@@ -60,6 +61,10 @@ func (t *TestRepository) Project() repository.ProjectRepository {
 	return t.project
 }
 
+func (t *TestRepository) ProjectRole() repository.ProjectRoleRepository {
+	return t.projectRole
+}
+
 func (t *TestRepository) Cluster() repository.ClusterRepository {
 	return t.cluster
 }

+ 1 - 1
internal/usage/usage.go

@@ -47,7 +47,7 @@ func GetUsage(opts *GetUsageOpts) (
 	}
 
 	// query for the linked user counts
-	roles, err := opts.Repo.Project().ListProjectRoles(opts.Project.ID)
+	roles, err := opts.Repo.Project().ListLegacyProjectRoles(opts.Project.ID)
 
 	if err != nil {
 		return nil, nil, nil, err