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

pulled api package out to unify types + started writing utils to clean up handlers + auth policies

Alexander Belanger 5 лет назад
Родитель
Сommit
31ba856b42
96 измененных файлов с 3001 добавлено и 746 удалено
  1. 0 0
      api/client/api.go
  2. 0 0
      api/client/deploy.go
  3. 0 0
      api/client/git_repo.go
  4. 0 0
      api/client/github_action.go
  5. 0 0
      api/client/helm_repo.go
  6. 0 0
      api/client/helper_test.go
  7. 0 0
      api/client/integration.go
  8. 0 0
      api/client/k8s.go
  9. 0 0
      api/client/project.go
  10. 431 0
      api/client/project_test.go
  11. 0 0
      api/client/registry.go
  12. 0 0
      api/client/user.go
  13. 164 0
      api/client/user_test.go
  14. 48 0
      api/server/auth/param.go
  15. 129 0
      api/server/auth/param_test.go
  16. 12 0
      api/server/auth/policy/doc.go
  17. 85 0
      api/server/auth/policy/loader.go
  18. 191 0
      api/server/auth/policy/loader_test.go
  19. 159 0
      api/server/auth/policy/policy.go
  20. 329 0
      api/server/auth/policy/policy_test.go
  21. 42 4
      api/server/auth/project.go
  22. 0 18
      api/server/auth/user.go
  23. 27 0
      api/server/handlers/project/create.go
  24. 109 0
      api/server/requestutils/decoder.go
  25. 263 0
      api/server/requestutils/decoder_test.go
  26. 154 0
      api/server/requestutils/validator.go
  27. 243 0
      api/server/requestutils/validator_test.go
  28. 66 0
      api/server/router/project.go
  29. 1 15
      api/server/server.go
  30. 123 0
      api/server/shared/apierrors/errors.go
  31. 11 0
      api/server/shared/capabilities.go
  32. 18 0
      api/server/shared/config.go
  33. 59 0
      api/server/shared/endpoints.go
  34. 48 0
      api/server/shared/reader.go
  35. 28 0
      api/server/shared/router.go
  36. 22 0
      api/server/shared/writer.go
  37. 0 23
      api/types/permissions.go
  38. 49 0
      api/types/policy.go
  39. 8 11
      api/types/request.go
  40. 16 0
      api/types/role.go
  41. 1 1
      cli/cmd/auth.go
  42. 1 1
      cli/cmd/cluster.go
  43. 1 1
      cli/cmd/connect.go
  44. 1 1
      cli/cmd/connect/actions.go
  45. 1 1
      cli/cmd/connect/dockerhub.go
  46. 1 1
      cli/cmd/connect/docr.go
  47. 1 1
      cli/cmd/connect/ecr.go
  48. 1 1
      cli/cmd/connect/gcr.go
  49. 1 1
      cli/cmd/connect/helm.go
  50. 1 1
      cli/cmd/connect/kubeconfig.go
  51. 1 1
      cli/cmd/connect/registry.go
  52. 1 1
      cli/cmd/create/create.go
  53. 1 1
      cli/cmd/deploy.go
  54. 1 1
      cli/cmd/deploy/deploy.go
  55. 1 1
      cli/cmd/docker.go
  56. 1 1
      cli/cmd/docker/auth.go
  57. 1 1
      cli/cmd/docker/config.go
  58. 1 1
      cli/cmd/errors.go
  59. 1 1
      cli/cmd/helm_repo.go
  60. 1 1
      cli/cmd/project.go
  61. 1 1
      cli/cmd/registry.go
  62. 1 1
      cli/cmd/root.go
  63. 1 1
      cli/cmd/run.go
  64. 0 431
      client/project_test.go
  65. 0 164
      client/user_test.go
  66. 6 3
      cmd/migrate/keyrotate/helpers_test.go
  67. 1 1
      internal/auth/sessionstore/sessionstore_test.go
  68. 6 3
      internal/forms/helper_test.go
  69. 10 18
      internal/models/role.go
  70. 1 1
      internal/models/session.go
  71. 6 3
      internal/repository/gorm/helpers_test.go
  72. 11 0
      internal/repository/gorm/project.go
  73. 55 11
      internal/repository/gorm/project_test.go
  74. 0 2
      internal/repository/gorm/session_test.go
  75. 1 0
      internal/repository/project.go
  76. 0 0
      internal/repository/test/auth.go
  77. 0 0
      internal/repository/test/auth_code.go
  78. 0 0
      internal/repository/test/cluster.go
  79. 0 0
      internal/repository/test/dns_record.go
  80. 0 0
      internal/repository/test/gitrepo.go
  81. 0 0
      internal/repository/test/helm_repo.go
  82. 0 0
      internal/repository/test/infra.go
  83. 0 0
      internal/repository/test/invite.go
  84. 22 0
      internal/repository/test/project.go
  85. 0 0
      internal/repository/test/pw_reset_token.go
  86. 0 0
      internal/repository/test/registry.go
  87. 0 0
      internal/repository/test/release.go
  88. 0 0
      internal/repository/test/repository.go
  89. 0 0
      internal/repository/test/session.go
  90. 0 0
      internal/repository/test/user.go
  91. 2 2
      server/api/api.go
  92. 2 2
      server/api/helpers_test.go
  93. 6 3
      server/api/invite_handler.go
  94. 6 3
      server/api/project_handler.go
  95. 6 3
      server/api/project_handler_test.go
  96. 2 2
      server/middleware/auth.go

+ 0 - 0
client/api.go → api/client/api.go


+ 0 - 0
client/deploy.go → api/client/deploy.go


+ 0 - 0
client/git_repo.go → api/client/git_repo.go


+ 0 - 0
client/github_action.go → api/client/github_action.go


+ 0 - 0
client/helm_repo.go → api/client/helm_repo.go


+ 0 - 0
client/helper_test.go → api/client/helper_test.go


+ 0 - 0
client/integration.go → api/client/integration.go


+ 0 - 0
client/k8s.go → api/client/k8s.go


+ 0 - 0
client/project.go → api/client/project.go


+ 431 - 0
api/client/project_test.go

@@ -0,0 +1,431 @@
+package client_test
+
+// import (
+// 	"context"
+// 	"testing"
+
+// 	api "github.com/porter-dev/porter/api/client"
+// 	"github.com/porter-dev/porter/internal/models"
+
+// 	"github.com/porter-dev/porter/client"
+// )
+
+// func initProject(name string, client *client.Client, t *testing.T) *client.CreateProjectResponse {
+// 	t.Helper()
+
+// 	resp, err := client.CreateProject(context.Background(), &client.CreateProjectRequest{
+// 		Name: name,
+// 	})
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	return resp
+// }
+
+// func initProjectCandidate(
+// 	projectID uint,
+// 	kubeconfig string,
+// 	client *api.Client,
+// 	t *testing.T,
+// ) *models.ClusterCandidateExternal {
+// 	t.Helper()
+
+// 	resp, err := client.CreateProjectCandidates(
+// 		context.Background(),
+// 		projectID,
+// 		&api.CreateProjectCandidatesRequest{
+// 			Kubeconfig: kubeconfig,
+// 		},
+// 	)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	return resp[0]
+// }
+
+// func initProjectCluster(
+// 	projectID uint,
+// 	candidateID uint,
+// 	client *api.Client,
+// 	t *testing.T,
+// ) *api.CreateProjectClusterResponse {
+// 	t.Helper()
+
+// 	resp, err := client.CreateProjectCluster(
+// 		context.Background(),
+// 		projectID,
+// 		candidateID,
+// 		&models.ClusterResolverAll{
+// 			OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
+// 		},
+// 	)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	return resp
+// }
+
+// func TestCreateProject(t *testing.T) {
+// 	email := "create_project_test@example.com"
+// 	client := api.NewClient(baseURL, "cookie_create_project_test.json")
+// 	user := initUser(email, client, t)
+// 	client.Login(context.Background(), &api.LoginRequest{
+// 		Email:    user.Email,
+// 		Password: "hello1234",
+// 	})
+
+// 	resp, err := client.CreateProject(context.Background(), &api.CreateProjectRequest{
+// 		Name: "project-test",
+// 	})
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	// make sure user is admin and project name is correct
+// 	if resp.Name != "project-test" {
+// 		t.Errorf("project name incorrect: expected %s, got %s\n", "project-test", resp.Name)
+// 	}
+
+// 	if len(resp.Roles) != 1 {
+// 		t.Fatalf("project role length is not 1")
+// 	}
+
+// 	if resp.Roles[0].Kind != models.RoleAdmin {
+// 		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
+// 	}
+
+// 	if resp.Roles[0].UserID != user.ID {
+// 		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
+// 	}
+// }
+
+// func TestGetProject(t *testing.T) {
+// 	email := "get_project_test@example.com"
+// 	client := api.NewClient(baseURL, "cookie_get_project_test.json")
+// 	user := initUser(email, client, t)
+// 	client.Login(context.Background(), &api.LoginRequest{
+// 		Email:    user.Email,
+// 		Password: "hello1234",
+// 	})
+// 	project := initProject("project-test", client, t)
+
+// 	resp, err := client.GetProject(context.Background(), project.ID)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	// make sure user is admin and project name is correct
+// 	if resp.Name != "project-test" {
+// 		t.Errorf("project name incorrect: expected %s, got %s\n", "project-test", resp.Name)
+// 	}
+
+// 	if len(resp.Roles) != 1 {
+// 		t.Fatalf("project role length is not 1")
+// 	}
+
+// 	if resp.Roles[0].Kind != models.RoleAdmin {
+// 		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
+// 	}
+
+// 	if resp.Roles[0].UserID != user.ID {
+// 		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
+// 	}
+// }
+
+// func TestGetProjectServiceAccount(t *testing.T) {
+// 	email := "get_project_sa_test@example.com"
+// 	client := api.NewClient(baseURL, "cookie_get_project_sa_test.json")
+// 	user := initUser(email, client, t)
+// 	client.Login(context.Background(), &api.LoginRequest{
+// 		Email:    user.Email,
+// 		Password: "hello1234",
+// 	})
+// 	project := initProject("project-test", client, t)
+// 	cc := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
+// 	cluster := initProjectCluster(project.ID, cc.ID, client, t)
+
+// 	resp, err := client.GetProjectCluster(context.Background(), project.ID, cluster.ID)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	// ensure project id and metadata is correct
+// 	if resp.ProjectID != project.ID {
+// 		t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp.ProjectID)
+// 	}
+
+// 	// verify clusters
+// 	if resp.Name != "cluster-test" {
+// 		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "cluster-test", resp.Name)
+// 	}
+
+// 	if resp.Server != "https://10.10.10.10" {
+// 		t.Errorf("cluster's server is incorrect: expected %s, got %s\n", "https://10.10.10.10", resp.Server)
+// 	}
+// }
+
+// func TestCreateProjectCandidates(t *testing.T) {
+// 	email := "create_project_candidates_test@example.com"
+// 	client := api.NewClient(baseURL, "cookie_create_project_candidates_test.json")
+// 	user := initUser(email, client, t)
+// 	client.Login(context.Background(), &api.LoginRequest{
+// 		Email:    user.Email,
+// 		Password: "hello1234",
+// 	})
+// 	project := initProject("project-test", client, t)
+
+// 	resp, err := client.CreateProjectCandidates(
+// 		context.Background(),
+// 		project.ID,
+// 		&api.CreateProjectCandidatesRequest{
+// 			Kubeconfig: OIDCAuthWithoutData,
+// 		},
+// 	)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	// make sure length is 1
+// 	if len(resp) != 1 {
+// 		t.Fatalf("candidates length is not 1\n")
+// 	}
+
+// 	// make sure auth mechanism is OIDC, project id is correct, and cluster info is correct
+// 	if resp[0].ProjectID != project.ID {
+// 		t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp[0].ProjectID)
+// 	}
+
+// 	if resp[0].Name != "cluster-test" {
+// 		t.Errorf("cluster name incorrect: expected %s, got %s\n", "cluster-test", resp[0].Name)
+// 	}
+
+// 	if resp[0].Server != "https://10.10.10.10" {
+// 		t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://10.10.10.10", resp[0].Server)
+// 	}
+
+// 	// make sure correct resolvers need to be performed
+// 	if len(resp[0].Resolvers) != 1 {
+// 		t.Fatalf("actions length is not 1\n")
+// 	}
+// }
+
+// func TestGetProjectCandidates(t *testing.T) {
+// 	email := "get_project_candidates_test@example.com"
+// 	client := api.NewClient(baseURL, "cookie_get_project_candidates_test.json")
+// 	user := initUser(email, client, t)
+// 	client.Login(context.Background(), &api.LoginRequest{
+// 		Email:    user.Email,
+// 		Password: "hello1234",
+// 	})
+// 	project := initProject("project-test", client, t)
+// 	initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
+
+// 	resp, err := client.GetProjectCandidates(context.Background(), project.ID)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	// make sure length is 1
+// 	if len(resp) != 1 {
+// 		t.Fatalf("candidates length is not 1\n")
+// 	}
+
+// 	// make sure auth mechanism is OIDC, project id is correct, and cluster info is correct
+// 	// if resp[0].Integration != models.OIDC {
+// 	// 	t.Errorf("oidc auth mechanism incorrect: expected %s, got %s\n", models.OIDC, resp[0].Integration)
+// 	// }
+
+// 	// if resp[0].ProjectID != project.ID {
+// 	// 	t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp[0].ProjectID)
+// 	// }
+
+// 	// if resp[0].ClusterName != "cluster-test" {
+// 	// 	t.Errorf("cluster name incorrect: expected %s, got %s\n", "cluster-test", resp[0].ClusterName)
+// 	// }
+
+// 	// if resp[0].ClusterEndpoint != "https://10.10.10.10" {
+// 	// 	t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://10.10.10.10", resp[0].ClusterEndpoint)
+// 	// }
+
+// 	// // make sure correct actions need to be performed
+// 	// if len(resp[0].Actions) != 1 {
+// 	// 	t.Fatalf("actions length is not 1\n")
+// 	// }
+
+// 	// if resp[0].Actions[0].Name != models.OIDCIssuerDataAction {
+// 	// 	t.Errorf("action name incorrect: expected %s, got %s\n", models.OIDCIssuerDataAction, resp[0].Actions[0].Name)
+// 	// }
+
+// 	// if resp[0].Actions[0].Filename != "/fake/path/to/ca.pem" {
+// 	// 	t.Errorf("action filename incorrect: expected %s, got %s\n", "/fake/path/to/ca.pem", resp[0].Actions[0].Filename)
+// 	// }
+// }
+
+// func TestCreateProjectServiceAccount(t *testing.T) {
+// 	email := "create_project_sa_test@example.com"
+// 	client := api.NewClient(baseURL, "cookie_create_project_sa_test.json")
+// 	user := initUser(email, client, t)
+// 	client.Login(context.Background(), &api.LoginRequest{
+// 		Email:    user.Email,
+// 		Password: "hello1234",
+// 	})
+// 	project := initProject("project-test", client, t)
+// 	saCandidate := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
+
+// 	resp, err := client.CreateProjectCluster(
+// 		context.Background(),
+// 		project.ID,
+// 		saCandidate.ID,
+// 		&models.ClusterResolverAll{
+// 			OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
+// 		},
+// 	)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	// ensure project id and metadata is correct
+// 	if resp.ProjectID != project.ID {
+// 		t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp.ProjectID)
+// 	}
+
+// 	// if resp.Kind != "connector" {
+// 	// 	t.Errorf("service account kind incorrect: expected %s, got %s\n", "connector", resp.Kind)
+// 	// }
+
+// 	// if resp.Integration != models.OIDC {
+// 	// 	t.Errorf("service account auth mechanism incorrect: expected %s, got %s\n", models.OIDC, resp.Integration)
+// 	// }
+
+// 	// // verify clusters
+// 	// if len(resp.Clusters) != 1 {
+// 	// 	t.Fatalf("length of clusters is not 1")
+// 	// }
+
+// 	// if resp.Clusters[0].ServiceAccountID != resp.ID {
+// 	// 	t.Errorf("cluster's sa id is incorrect: expected %d, got %d\n", resp.ID, resp.Clusters[0].ServiceAccountID)
+// 	// }
+
+// 	// if resp.Clusters[0].Name != "cluster-test" {
+// 	// 	t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "cluster-test", resp.Clusters[0].Name)
+// 	// }
+
+// 	// if resp.Clusters[0].Server != "https://10.10.10.10" {
+// 	// 	t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://10.10.10.10", resp.Clusters[0].Server)
+// 	// }
+// }
+
+// func TestListProjectClusters(t *testing.T) {
+// 	email := "list_project_clusters_test@example.com"
+// 	client := api.NewClient(baseURL, "cookie_list_project_clusters_test.json")
+// 	user := initUser(email, client, t)
+// 	client.Login(context.Background(), &api.LoginRequest{
+// 		Email:    user.Email,
+// 		Password: "hello1234",
+// 	})
+// 	project := initProject("project-test", client, t)
+// 	cc := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
+// 	initProjectCluster(project.ID, cc.ID, client, t)
+
+// 	resp, err := client.ListProjectClusters(
+// 		context.Background(),
+// 		project.ID,
+// 	)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	// verify clusters
+// 	if len(resp) != 1 {
+// 		t.Fatalf("length of clusters is not 1")
+// 	}
+
+// 	if resp[0].Name != "cluster-test" {
+// 		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "cluster-test", resp[0].Name)
+// 	}
+
+// 	if resp[0].Server != "https://10.10.10.10" {
+// 		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://10.10.10.10", resp[0].Server)
+// 	}
+// }
+
+// func TestDeleteProject(t *testing.T) {
+// 	email := "delete_project_test@example.com"
+// 	client := api.NewClient(baseURL, "cookie_delete_project_test.json")
+// 	user := initUser(email, client, t)
+// 	client.Login(context.Background(), &api.LoginRequest{
+// 		Email:    user.Email,
+// 		Password: "hello1234",
+// 	})
+// 	project := initProject("project-test", client, t)
+
+// 	resp, err := client.DeleteProject(context.Background(), project.ID)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	// make sure user is admin and project name is correct
+// 	if resp.Name != "project-test" {
+// 		t.Errorf("project name incorrect: expected %s, got %s\n", "project-test", resp.Name)
+// 	}
+
+// 	if len(resp.Roles) != 1 {
+// 		t.Fatalf("project role length is not 1")
+// 	}
+
+// 	if resp.Roles[0].Kind != models.RoleAdmin {
+// 		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
+// 	}
+
+// 	if resp.Roles[0].UserID != user.ID {
+// 		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
+// 	}
+
+// 	// make sure that project can no longer be found
+// 	_, err = client.GetProject(context.Background(), project.ID)
+
+// 	if err == nil {
+// 		t.Fatalf("no error returned\n")
+// 	}
+// }
+
+// const OIDCAuthWithoutData string = `
+// apiVersion: v1
+// clusters:
+// - cluster:
+//     server: https://10.10.10.10
+//     certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+//   name: cluster-test
+// contexts:
+// - context:
+//     cluster: cluster-test
+//     user: test-admin
+//   name: context-test
+// current-context: context-test
+// kind: Config
+// preferences: {}
+// users:
+// - name: test-admin
+//   user:
+//     auth-provider:
+//       config:
+//         client-id: porter-api
+//         id-token: token
+//         idp-issuer-url: https://10.10.10.10
+//         idp-certificate-authority: /fake/path/to/ca.pem
+//       name: oidc
+// `

+ 0 - 0
client/registry.go → api/client/registry.go


+ 0 - 0
client/user.go → api/client/user.go


+ 164 - 0
api/client/user_test.go

@@ -0,0 +1,164 @@
+package client_test
+
+// import (
+// 	"context"
+// 	"strings"
+// 	"testing"
+
+// 	api "github.com/porter-dev/porter/api/client"
+// 	"github.com/porter-dev/porter/internal/models"
+// )
+
+// func initUser(email string, client *api.Client, t *testing.T) *api.CreateUserResponse {
+// 	t.Helper()
+
+// 	resp, err := client.CreateUser(context.Background(), &api.CreateUserRequest{
+// 		Email:    email,
+// 		Password: "hello1234",
+// 	})
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	return resp
+// }
+
+// func TestLogin(t *testing.T) {
+// 	email := "login_test@example.com"
+// 	client := api.NewClient(baseURL, "cookie_login_test.json")
+// 	user := initUser(email, client, t)
+
+// 	resp, err := client.Login(context.Background(), &api.LoginRequest{
+// 		Email:    user.Email,
+// 		Password: "hello1234",
+// 	})
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	if resp.Email != user.Email {
+// 		t.Errorf("incorrect email: expected %s, got %s\n", user.Email, resp.Email)
+// 	}
+// }
+
+// func TestLogout(t *testing.T) {
+// 	email := "logout_test@example.com"
+// 	client := api.NewClient(baseURL, "cookie_logout_test.json")
+// 	user := initUser(email, client, t)
+
+// 	client.Login(context.Background(), &api.LoginRequest{
+// 		Email:    user.Email,
+// 		Password: "hello1234",
+// 	})
+
+// 	err := client.Logout(context.Background())
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	// try to get the user and ensure 403
+// 	_, err = client.AuthCheck(context.Background())
+
+// 	if err != nil && !strings.Contains(err.Error(), "403") {
+// 		t.Fatalf("%v\n", err)
+// 	}
+// }
+
+// func TestAuthCheck(t *testing.T) {
+// 	email := "auth_check_test@example.com"
+// 	client := api.NewClient(baseURL, "cookie_auth_check_test.json")
+// 	user := initUser(email, client, t)
+// 	client.Login(context.Background(), &api.LoginRequest{
+// 		Email:    user.Email,
+// 		Password: "hello1234",
+// 	})
+
+// 	resp, err := client.AuthCheck(context.Background())
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	if resp.Email != user.Email {
+// 		t.Errorf("incorrect email: expected %s, got %s\n", user.Email, resp.Email)
+// 	}
+// }
+
+// func TestGetUser(t *testing.T) {
+// 	email := "get_user_test@example.com"
+// 	client := api.NewClient(baseURL, "cookie_get_user_test.json")
+// 	user := initUser(email, client, t)
+
+// 	resp, err := client.GetUser(context.Background(), user.ID)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	if resp.Email != user.Email {
+// 		t.Errorf("incorrect email: expected %s, got %s\n", user.Email, resp.Email)
+// 	}
+// }
+
+// func TestListUserProjects(t *testing.T) {
+// 	email := "list_user_projects@example.com"
+// 	client := api.NewClient(baseURL, "cookie_list_user_projects.json")
+// 	user := initUser(email, client, t)
+// 	client.Login(context.Background(), &api.LoginRequest{
+// 		Email:    user.Email,
+// 		Password: "hello1234",
+// 	})
+// 	project := initProject("project-test", client, t)
+
+// 	projects, err := client.ListUserProjects(context.Background(), user.ID)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	if len(projects) != 1 {
+// 		t.Fatalf("length of projects is not 1")
+// 	}
+
+// 	resp := projects[0]
+
+// 	// make sure user is admin and project name is correct
+// 	if resp.Name != project.Name {
+// 		t.Errorf("project name incorrect: expected %s, got %s\n", project.Name, resp.Name)
+// 	}
+
+// 	if len(resp.Roles) != 1 {
+// 		t.Fatalf("project role length is not 1")
+// 	}
+
+// 	if resp.Roles[0].Kind != models.RoleAdmin {
+// 		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
+// 	}
+
+// 	if resp.Roles[0].UserID != user.ID {
+// 		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
+// 	}
+// }
+
+// func TestDeleteUser(t *testing.T) {
+// 	email := "delete_user_test@example.com"
+// 	client := api.NewClient(baseURL, "cookie_delete_user_test.json")
+// 	user := initUser(email, client, t)
+
+// 	err := client.DeleteUser(context.Background(), user.ID, &api.DeleteUserRequest{
+// 		Password: "hello1234",
+// 	})
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	_, err = client.GetUser(context.Background(), user.ID)
+
+// 	if err != nil && !strings.Contains(err.Error(), "could not find requested object") {
+// 		t.Fatalf("%v\n", err)
+// 	}
+// }

+ 48 - 0
api/server/auth/param.go

@@ -0,0 +1,48 @@
+package auth
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+)
+
+const urlParamNotFoundFmt = "could not find url param %s"
+const urlParamErrUintConvFmt = "could not convert url parameter %s to uint, got %s"
+
+// GetURLParamString returns a specific URL parameter as a string using
+// chi.URLParam. It returns an internal server error if the URL parameter is not found.
+func GetURLParamString(r *http.Request, param string) (string, apierrors.RequestError) {
+	urlParam := chi.URLParam(r, param)
+
+	if urlParam == "" {
+		// this is an internal server error, since it means the handler requested an
+		// invalid url parameter
+		return "", apierrors.NewErrInternal(fmt.Errorf(urlParamNotFoundFmt, param))
+	}
+
+	return urlParam, nil
+}
+
+// GetURLParamUint returns a URL parameter as a uint. It returns
+// an internal server error if the URL parameter is not found.
+func GetURLParamUint(r *http.Request, param string) (uint, apierrors.RequestError) {
+	urlParam, reqErr := GetURLParamString(r, param)
+
+	if reqErr != nil {
+		return 0, reqErr
+	}
+
+	res64, err := strconv.ParseUint(urlParam, 10, 64)
+
+	if err != nil {
+		return 0, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf(urlParamErrUintConvFmt, param, urlParam),
+			http.StatusBadRequest,
+		)
+	}
+
+	return uint(res64), nil
+}

+ 129 - 0
api/server/auth/param_test.go

@@ -0,0 +1,129 @@
+package auth_test
+
+import (
+	"context"
+	"fmt"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/auth"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/stretchr/testify/assert"
+)
+
+type getURLParamTest struct {
+	description string
+	route       string
+	routeParams map[string]string
+	paramReq    string
+}
+
+type getURLParamErrTest struct {
+	getURLParamTest
+	expErrStr string
+}
+
+const urlParamNotFoundFmt = "could not find url param %s"
+const urlParamErrUintConvFmt = "could not convert url parameter %s to uint, got %s"
+
+var getURLUintParamErrTests = []getURLParamErrTest{
+	{
+		getURLParamTest: getURLParamTest{
+			description: "should fail when not found",
+			route:       "/api",
+			routeParams: map[string]string{},
+			paramReq:    "project_id",
+		},
+		expErrStr: fmt.Sprintf(urlParamNotFoundFmt, "project_id"),
+	},
+	{
+		getURLParamTest: getURLParamTest{
+			description: "should fail when not uint",
+			route:       "/api/notuint",
+			routeParams: map[string]string{
+				"project_id": "notuint",
+			},
+			paramReq: "project_id",
+		},
+		expErrStr: fmt.Sprintf(urlParamErrUintConvFmt, "project_id", "notuint"),
+	},
+}
+
+func TestGetURLUintParamsErrors(t *testing.T) {
+	for _, test := range getURLUintParamErrTests {
+		r := httptest.NewRequest("POST", test.route, nil)
+
+		// set the context for testing
+		rctx := chi.NewRouteContext()
+		routeParams := &chi.RouteParams{}
+
+		for key, val := range test.routeParams {
+			routeParams.Add(key, val)
+		}
+
+		rctx.URLParams = *routeParams
+
+		r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
+
+		_, err := auth.GetURLParamUint(r, test.paramReq)
+
+		if err == nil {
+			t.Fatalf("[ %s ] did not return an error when error was expected", test.description)
+		}
+
+		assert.EqualError(t, err, test.expErrStr)
+
+		var expErrTarget apierrors.RequestError
+		assert.ErrorAs(t, err, &expErrTarget)
+	}
+}
+
+type getURLParamStringTest struct {
+	getURLParamTest
+	expStr string
+}
+
+func TestGetURLParamString(t *testing.T) {
+	r := httptest.NewRequest("POST", "/api/test", nil)
+
+	// set the context for testing
+	rctx := chi.NewRouteContext()
+	routeParams := &chi.RouteParams{}
+
+	routeParams.Add("name", "test")
+
+	rctx.URLParams = *routeParams
+
+	r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
+
+	res, err := auth.GetURLParamString(r, "name")
+
+	if err != nil {
+		t.Fatalf("[ GetURLParamString ] returneed an error when no error was expected, %v", err.Error())
+	}
+
+	assert.Equal(t, "test", res)
+}
+
+func TestGetURLParamUint(t *testing.T) {
+	r := httptest.NewRequest("POST", "/api/1", nil)
+
+	// set the context for testing
+	rctx := chi.NewRouteContext()
+	routeParams := &chi.RouteParams{}
+
+	routeParams.Add("name", "1")
+
+	rctx.URLParams = *routeParams
+
+	r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
+
+	res, err := auth.GetURLParamUint(r, "name")
+
+	if err != nil {
+		t.Fatalf("[ GetURLParamUint ] returneed an error when no error was expected, %v", err.Error())
+	}
+
+	assert.Equal(t, uint(1), res)
+}

+ 12 - 0
api/server/auth/policy/doc.go

@@ -0,0 +1,12 @@
+/*
+Package policy provides methods for parsing RBAC policies to determine if a user
+has access to a given resource.
+
+TODO: more details about policy trees + "MostRestrictiveParent" + "LeastRestrictiveSibling"
+
+Caveats:
+- one policy document to match the entire action
+- list/create are not resource-specific actions, so granting list/create permissions for a scope
+means that a user can list all resources or create a new resource in that scope.
+*/
+package policy

+ 85 - 0
api/server/auth/policy/loader.go

@@ -0,0 +1,85 @@
+package policy
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+type PolicyDocumentLoader interface {
+	LoadPolicyDocuments(userID, projectID uint) ([]*types.PolicyDocument, apierrors.RequestError)
+}
+
+// BasicPolicyDocumentLoader loads policy documents simply depending on the
+type BasicPolicyDocumentLoader struct {
+	projRepo repository.ProjectRepository
+}
+
+func NewBasicPolicyDocumentLoader(projRepo repository.ProjectRepository) *BasicPolicyDocumentLoader {
+	return &BasicPolicyDocumentLoader{projRepo}
+}
+
+func (b *BasicPolicyDocumentLoader) LoadPolicyDocuments(
+	userID, projectID uint,
+) ([]*types.PolicyDocument, apierrors.RequestError) {
+	// read role and case on role "kind"
+	role, err := b.projRepo.ReadProjectRole(userID, projectID)
+
+	if err != nil && err == gorm.ErrRecordNotFound {
+		return nil, apierrors.NewErrForbidden(
+			fmt.Errorf("user %d does not have a role in project %d", userID, projectID),
+		)
+	} else if err != nil {
+		return nil, apierrors.NewErrInternal(err)
+	}
+
+	// load role based on role kind
+	switch role.Kind {
+	case types.RoleAdmin:
+		return AdminPolicy, nil
+	case types.RoleDeveloper:
+		return DeveloperPolicy, nil
+	case types.RoleViewer:
+		return ViewerPolicy, nil
+	default:
+		return nil, apierrors.NewErrForbidden(
+			fmt.Errorf("%s role not supported for user %d, project %d", string(role.Kind), userID, projectID),
+		)
+	}
+}
+
+var AdminPolicy = []*types.PolicyDocument{
+	{
+		Scope: types.ProjectScope,
+		Verbs: types.ReadWriteVerbGroup(),
+	},
+}
+
+var DeveloperPolicy = []*types.PolicyDocument{
+	{
+		Scope: types.ProjectScope,
+		Verbs: types.ReadWriteVerbGroup(),
+		Children: map[types.PermissionScope]*types.PolicyDocument{
+			types.SettingsScope: {
+				Scope: types.SettingsScope,
+				Verbs: types.ReadVerbGroup(),
+			},
+		},
+	},
+}
+
+var ViewerPolicy = []*types.PolicyDocument{
+	{
+		Scope: types.ProjectScope,
+		Verbs: types.ReadVerbGroup(),
+		Children: map[types.PermissionScope]*types.PolicyDocument{
+			types.SettingsScope: {
+				Scope: types.SettingsScope,
+				Verbs: []types.APIVerb{},
+			},
+		},
+	},
+}

+ 191 - 0
api/server/auth/policy/loader_test.go

@@ -0,0 +1,191 @@
+package policy_test
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/api/server/auth/policy"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository/test"
+	"github.com/stretchr/testify/assert"
+)
+
+type basicLoaderTest struct {
+	description      string
+	roleKind         types.RoleKind
+	expErr           bool
+	expErrString     string
+	expErrStatusCode int
+	expPolicy        []*types.PolicyDocument
+}
+
+var basicLoaderTests = []basicLoaderTest{
+	{
+		description: "should load admin policy",
+		roleKind:    types.RoleAdmin,
+		expPolicy:   policy.AdminPolicy,
+	},
+	{
+		description: "should load developer policy",
+		roleKind:    types.RoleDeveloper,
+		expPolicy:   policy.DeveloperPolicy,
+	},
+	{
+		description: "should load viewer policy",
+		roleKind:    types.RoleViewer,
+		expPolicy:   policy.ViewerPolicy,
+	},
+	{
+		description:      "should not load custom policy for basic loader",
+		roleKind:         types.RoleCustom,
+		expErr:           true,
+		expErrStatusCode: http.StatusForbidden,
+		expErrString:     "custom role not supported for user 1, project 1",
+	},
+}
+
+func TestBasicPolicyDocumentLoader(t *testing.T) {
+	assert := assert.New(t)
+
+	for _, basicTest := range basicLoaderTests {
+		// use the in-memory project repo
+		projRepo := test.NewProjectRepository(true)
+		loader := policy.NewBasicPolicyDocumentLoader(projRepo)
+
+		project := &models.Project{
+			Name: "test-project",
+		}
+
+		var err error
+
+		project, err = projRepo.CreateProject(project)
+
+		if err != nil {
+			t.Fatalf("%v", err)
+		}
+
+		_, err = projRepo.CreateProjectRole(project, &models.Role{
+			Role: types.Role{
+				UserID:    1,
+				ProjectID: 1,
+				Kind:      basicTest.roleKind,
+			},
+		})
+
+		if err != nil {
+			t.Fatalf("%v", err)
+		}
+
+		docs, reqErr := loader.LoadPolicyDocuments(1, 1)
+
+		assert.Equal(
+			reqErr != nil,
+			basicTest.expErr,
+			"[ %s ]: expected error was %t, got %t",
+			basicTest.description,
+			reqErr != nil,
+			basicTest.expErr,
+		)
+
+		if reqErr != nil && basicTest.expErr {
+			readableStr := reqErr.Error()
+			expReadableStr := basicTest.expErrString
+
+			assert.Equal(
+				expReadableStr,
+				readableStr,
+				"[ %s ]: readable string not equal",
+				basicTest.description,
+			)
+
+			// check that external and internal errors are returned as well
+			assert.Equal(
+				basicTest.expErrStatusCode,
+				reqErr.GetStatusCode(),
+				"[ %s ]: status code not equal",
+				basicTest.description,
+			)
+		} else if !basicTest.expErr {
+			if diff := deep.Equal(basicTest.expPolicy, docs); diff != nil {
+				t.Errorf("[ %s ]: policy documents not equal:", basicTest.description)
+				t.Error(diff)
+			}
+		}
+
+	}
+}
+
+func TestErrorForbiddenInvalidRole(t *testing.T) {
+	assert := assert.New(t)
+
+	// use the in-memory project repo
+	projRepo := test.NewProjectRepository(true)
+	loader := policy.NewBasicPolicyDocumentLoader(projRepo)
+
+	project := &models.Project{
+		Name: "test-project",
+	}
+
+	var err error
+
+	project, err = projRepo.CreateProject(project)
+
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+
+	_, err = projRepo.CreateProjectRole(project, &models.Role{
+		Role: types.Role{
+			UserID:    1,
+			ProjectID: 1,
+			Kind:      types.RoleAdmin,
+		},
+	})
+
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+
+	_, reqErr := loader.LoadPolicyDocuments(2, 1)
+
+	if reqErr == nil {
+		t.Fatalf("Expected forbidden error for invalid project role")
+	}
+
+	// check that external and internal errors are returned as well
+	assert.Equal(
+		http.StatusForbidden,
+		reqErr.GetStatusCode(),
+		"status is not status forbidden",
+	)
+
+	assert.Equal(
+		fmt.Sprintf("user %d does not have a role in project %d", 2, 1),
+		reqErr.Error(),
+		"error message is not correct",
+	)
+}
+
+func TestErrorCannotQuery(t *testing.T) {
+	assert := assert.New(t)
+
+	// use the in-memory project repo
+	projRepo := test.NewProjectRepository(false)
+	loader := policy.NewBasicPolicyDocumentLoader(projRepo)
+
+	_, reqErr := loader.LoadPolicyDocuments(2, 1)
+
+	if reqErr == nil {
+		t.Fatalf("Expected internal error for failing to query")
+	}
+
+	// check that external and internal errors are returned as well
+	assert.Equal(
+		http.StatusInternalServerError,
+		reqErr.GetStatusCode(),
+		"status is not status internal",
+	)
+}

+ 159 - 0
api/server/auth/policy/policy.go

@@ -0,0 +1,159 @@
+package policy
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+type RequestAction struct {
+	Verb     types.APIVerb
+	Resource types.NameOrUInt
+}
+
+// HasScopeAccess checks that a user can perform an action (`verb`) against a specific
+// resource (`resource+scope`) according to a `policy`.
+func HasScopeAccess(
+	policy []*types.PolicyDocument,
+	reqScopes map[types.PermissionScope]RequestAction,
+) bool {
+	// iterate through policy documents until a match is found
+	for _, policyDoc := range policy {
+		// check that policy document is valid for current API server
+		isValid, matchDocs := populateAndVerifyPolicyDocument(
+			policyDoc,
+			types.ScopeHeirarchy,
+			types.ProjectScope,
+			types.ReadWriteVerbGroup(),
+			reqScopes,
+			nil,
+		)
+
+		if !isValid {
+			continue
+		}
+
+		for matchScope, matchDoc := range matchDocs {
+			// for the matching scope, make sure it matches the allowed resources if the
+			// resource list is explicitly set
+			if len(matchDoc.Resources) > 0 && reqScopes[matchScope].Verb != types.APIVerbList {
+				if !isResourceAllowed(matchDoc, reqScopes[matchScope].Resource) {
+					isValid = false
+				}
+			}
+
+			// for the matching scope, make sure it matches the allowed verbs
+			if !isVerbAllowed(matchDoc, reqScopes[matchScope].Verb) {
+				isValid = false
+			}
+		}
+
+		if isValid {
+			return true
+		}
+	}
+
+	return false
+}
+
+func isResourceAllowed(
+	matchDoc *types.PolicyDocument,
+	resource types.NameOrUInt,
+) bool {
+	valid := false
+
+	for _, allowedResource := range matchDoc.Resources {
+		if allowedResource == resource {
+			valid = true
+			break
+		}
+	}
+
+	return valid
+}
+
+func isVerbAllowed(
+	matchDoc *types.PolicyDocument,
+	verb types.APIVerb,
+) bool {
+	valid := false
+
+	for _, allowedVerb := range matchDoc.Verbs {
+		if allowedVerb == verb {
+			valid = true
+		}
+	}
+
+	return valid
+}
+
+// populateAndVerifyPolicyDocument makes sure that the policy document is valid, and populates
+// the policy document with values based on the parent permissions. Since we only want to
+// iterate through the PolicyDocument once, we also search for a matching doc and return it.
+// See test cases for examples.
+func populateAndVerifyPolicyDocument(
+	policyDoc *types.PolicyDocument,
+	tree types.ScopeTree,
+	currScope types.PermissionScope,
+	parentVerbs []types.APIVerb,
+	reqScopes map[types.PermissionScope]RequestAction,
+	currMatchDocs map[types.PermissionScope]*types.PolicyDocument,
+) (ok bool, matchDocs map[types.PermissionScope]*types.PolicyDocument) {
+	if currMatchDocs == nil {
+		currMatchDocs = make(map[types.PermissionScope]*types.PolicyDocument)
+	}
+
+	matchDocs = currMatchDocs
+	currDoc := policyDoc
+
+	if policyDoc == nil {
+		currDoc = &types.PolicyDocument{
+			Scope: currScope,
+			// we only set the verbs to the parentVerbs when the policy document is nil
+			// in the first place. We don't case on res.Verbs being empty, since this
+			// may be desired.
+			Verbs: parentVerbs,
+		}
+	}
+
+	subTree, ok := tree[currDoc.Scope]
+
+	fmt.Println(currDoc.Scope, tree, currDoc.Scope, currScope)
+
+	if !ok || currDoc.Scope != currScope {
+		return false, matchDocs
+	}
+
+	processedChildren := 0
+
+	for currScope := range subTree {
+		if _, exists := currDoc.Children[currScope]; exists {
+			processedChildren++
+		}
+
+		ok, matchDocs = populateAndVerifyPolicyDocument(
+			currDoc.Children[currScope],
+			subTree,
+			currScope,
+			currDoc.Verbs,
+			reqScopes,
+			matchDocs,
+		)
+
+		if !ok {
+			break
+		}
+	}
+
+	// make sure all children of the current document were actually processed: if not,
+	// the policy document is invalid
+	if processedChildren != len(currDoc.Children) {
+		return false, matchDocs
+	}
+
+	if _, ok := reqScopes[currScope]; ok && currDoc.Scope == currScope {
+		matchDocs[currScope] = currDoc
+	}
+
+	return ok, matchDocs
+}

+ 329 - 0
api/server/auth/policy/policy_test.go

@@ -0,0 +1,329 @@
+package policy_test
+
+import (
+	"testing"
+
+	"github.com/porter-dev/porter/api/server/auth/policy"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/stretchr/testify/assert"
+)
+
+type testHasScopeAccess struct {
+	description string
+	policy      []*types.PolicyDocument
+	reqScopes   map[types.PermissionScope]policy.RequestAction
+	expRes      bool
+}
+
+var hasScopeAccessTests = []testHasScopeAccess{
+	{
+		description: "admin access to project",
+		policy:      policy.AdminPolicy,
+		reqScopes: map[types.PermissionScope]policy.RequestAction{
+			types.ProjectScope: {
+				Verb: types.APIVerbGet,
+				Resource: types.NameOrUInt{
+					UInt: 1,
+				},
+			},
+		},
+		expRes: true,
+	},
+	{
+		description: "viewer access cannot perform write operation",
+		policy:      policy.ViewerPolicy,
+		reqScopes: map[types.PermissionScope]policy.RequestAction{
+			types.ClusterScope: {
+				Verb: types.APIVerbCreate,
+				Resource: types.NameOrUInt{
+					UInt: 1,
+				},
+			},
+		},
+		expRes: false,
+	},
+	{
+		description: "developer access cannot write settings",
+		policy:      policy.DeveloperPolicy,
+		reqScopes: map[types.PermissionScope]policy.RequestAction{
+			types.SettingsScope: {
+				Verb: types.APIVerbUpdate,
+				Resource: types.NameOrUInt{
+					UInt: 1,
+				},
+			},
+		},
+		expRes: false,
+	},
+	{
+		description: "custom policy for cluster 1 can write cluster 1",
+		policy:      testPolicySpecificClusters,
+		reqScopes: map[types.PermissionScope]policy.RequestAction{
+			types.ClusterScope: {
+				Verb: types.APIVerbUpdate,
+				Resource: types.NameOrUInt{
+					UInt: 1,
+				},
+			},
+		},
+		expRes: true,
+	},
+	{
+		description: "custom policy for cluster 1 cannot write cluster 2",
+		policy:      testPolicySpecificClusters,
+		reqScopes: map[types.PermissionScope]policy.RequestAction{
+			types.ClusterScope: {
+				Verb: types.APIVerbUpdate,
+				Resource: types.NameOrUInt{
+					UInt: 2,
+				},
+			},
+		},
+		expRes: false,
+	},
+	{
+		description: "cannot access wrong namespace + cluster combination",
+		policy:      testPolicyNamespaceSpecific,
+		reqScopes: map[types.PermissionScope]policy.RequestAction{
+			types.ClusterScope: {
+				Verb: types.APIVerbGet,
+				Resource: types.NameOrUInt{
+					UInt: 500,
+				},
+			},
+			types.NamespaceScope: {
+				Verb: types.APIVerbGet,
+				Resource: types.NameOrUInt{
+					Name: "default",
+				},
+			},
+		},
+		expRes: false,
+	},
+	{
+		description: "can access set namespace + cluster combination",
+		policy:      testPolicyNamespaceSpecific,
+		reqScopes: map[types.PermissionScope]policy.RequestAction{
+			types.ClusterScope: {
+				Verb: types.APIVerbGet,
+				Resource: types.NameOrUInt{
+					UInt: 500,
+				},
+			},
+			types.NamespaceScope: {
+				Verb: types.APIVerbGet,
+				Resource: types.NameOrUInt{
+					Name: "abelanger",
+				},
+			},
+		},
+		expRes: true,
+	},
+	{
+		description: "cannot write the set namespace + cluster combination",
+		policy:      testPolicyNamespaceSpecific,
+		reqScopes: map[types.PermissionScope]policy.RequestAction{
+			types.ClusterScope: {
+				Verb: types.APIVerbGet,
+				Resource: types.NameOrUInt{
+					UInt: 500,
+				},
+			},
+			types.NamespaceScope: {
+				Verb: types.APIVerbDelete,
+				Resource: types.NameOrUInt{
+					Name: "abelanger",
+				},
+			},
+		},
+		expRes: false,
+	},
+	{
+		description: "test invalid policy document",
+		policy:      testInvalidPolicyDocument,
+		reqScopes: map[types.PermissionScope]policy.RequestAction{
+			types.ProjectScope: {
+				Verb: types.APIVerbGet,
+				Resource: types.NameOrUInt{
+					UInt: 1,
+				},
+			},
+		},
+		expRes: false,
+	},
+	{
+		description: "test invalid policy document nested",
+		policy:      testInvalidPolicyDocumentNested,
+		reqScopes: map[types.PermissionScope]policy.RequestAction{
+			types.ProjectScope: {
+				Verb: types.APIVerbGet,
+				Resource: types.NameOrUInt{
+					UInt: 1,
+				},
+			},
+		},
+		expRes: false,
+	},
+}
+
+func TestHasScopeAccess(t *testing.T) {
+	assert := assert.New(t)
+
+	for _, test := range hasScopeAccessTests {
+		res := policy.HasScopeAccess(
+			test.policy,
+			test.reqScopes,
+		)
+
+		assert.Equal(test.expRes, res, test.description)
+	}
+}
+
+func BenchmarkSimpleHasScopeAccess(b *testing.B) {
+	for i := 0; i < b.N; i++ {
+		res := policy.HasScopeAccess(
+			testPolicySpecificClusters,
+			map[types.PermissionScope]policy.RequestAction{
+				types.ClusterScope: {
+					Verb: types.APIVerbCreate,
+					Resource: types.NameOrUInt{
+						UInt: 1,
+					},
+				},
+			},
+		)
+
+		// we expect all results to be true, so fatal if not
+		if !res {
+			b.Fatalf("benchmark failed correctness: expected true")
+		}
+	}
+}
+
+var testPolicySpecificClusters = []*types.PolicyDocument{
+	{
+		Scope: types.ProjectScope,
+		Verbs: types.ReadWriteVerbGroup(),
+		Children: map[types.PermissionScope]*types.PolicyDocument{
+			types.ClusterScope: {
+				Scope: types.ClusterScope,
+				Verbs: types.ReadWriteVerbGroup(),
+				Resources: []types.NameOrUInt{
+					{
+						UInt: 1,
+					},
+				},
+			},
+		},
+	},
+}
+
+var testPolicyNamespaceSpecific = []*types.PolicyDocument{
+	// This document allows a user to view the namespace "abelanger" in the cluster
+	// with id 500.
+	{
+		Scope: types.ProjectScope,
+		Verbs: types.ReadWriteVerbGroup(),
+		Children: map[types.PermissionScope]*types.PolicyDocument{
+			types.ClusterScope: {
+				Scope: types.ClusterScope,
+				Verbs: types.ReadVerbGroup(),
+				Resources: []types.NameOrUInt{
+					{
+						UInt: 500,
+					},
+				},
+				Children: map[types.PermissionScope]*types.PolicyDocument{
+					types.NamespaceScope: {
+						Scope: types.NamespaceScope,
+						Verbs: types.ReadVerbGroup(),
+						Resources: []types.NameOrUInt{
+							{
+								Name: "abelanger",
+							},
+						},
+					},
+				},
+			},
+		},
+	},
+	// This document allows a user to view the namespace "default" in the cluster
+	// with id 501.
+	{
+		Scope: types.ProjectScope,
+		Verbs: types.ReadWriteVerbGroup(),
+		Children: map[types.PermissionScope]*types.PolicyDocument{
+			types.ClusterScope: {
+				Scope: types.ClusterScope,
+				Verbs: types.ReadVerbGroup(),
+				Resources: []types.NameOrUInt{
+					{
+						UInt: 501,
+					},
+				},
+				Children: map[types.PermissionScope]*types.PolicyDocument{
+					types.NamespaceScope: {
+						Scope: types.NamespaceScope,
+						Verbs: types.ReadVerbGroup(),
+						Resources: []types.NameOrUInt{
+							{
+								Name: "default",
+							},
+						},
+					},
+				},
+			},
+		},
+	},
+}
+
+// NOTE: these are invalid policy documents that don't follow the accepted heirarchy
+// for scopes. Don't use this as a model for a valid doc.
+var testInvalidPolicyDocument = []*types.PolicyDocument{
+	{
+		// invalid because cluster above project
+		Scope: types.ClusterScope,
+		Verbs: types.ReadWriteVerbGroup(),
+		Children: map[types.PermissionScope]*types.PolicyDocument{
+			types.ProjectScope: {
+				Scope: types.ProjectScope,
+				Verbs: types.ReadWriteVerbGroup(),
+				Resources: []types.NameOrUInt{
+					{
+						UInt: 1,
+					},
+				},
+			},
+		},
+	},
+}
+
+var testInvalidPolicyDocumentNested = []*types.PolicyDocument{
+	{
+		// invalid because release is a child of cluster, not namespace scope
+		Scope: types.ProjectScope,
+		Verbs: types.ReadWriteVerbGroup(),
+		Children: map[types.PermissionScope]*types.PolicyDocument{
+			types.ClusterScope: {
+				Scope: types.ClusterScope,
+				Verbs: types.ReadWriteVerbGroup(),
+				Resources: []types.NameOrUInt{
+					{
+						UInt: 1,
+					},
+				},
+				Children: map[types.PermissionScope]*types.PolicyDocument{
+					types.ReleaseScope: {
+						Scope: types.ReleaseScope,
+						Verbs: types.ReadWriteVerbGroup(),
+						Resources: []types.NameOrUInt{
+							{
+								UInt: 1,
+							},
+						},
+					},
+				},
+			},
+		},
+	},
+}

+ 42 - 4
api/server/auth/project.go

@@ -2,21 +2,59 @@ package auth
 
 import (
 	"context"
+	"fmt"
 	"net/http"
 
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/repository"
 )
 
-func NewProjectContext(ctx context.Context, project types.Project) context.Context {
-	return context.WithValue(ctx, types.ProjectScope, project)
+type ProjectScopedFactory struct {
+	projectRepo repository.ProjectRepository
+	config      *shared.Config
+}
+
+func NewProjectScopedFactory(
+	projectRepo repository.ProjectRepository,
+	config *shared.Config,
+) *ProjectScopedFactory {
+	return &ProjectScopedFactory{projectRepo, config}
+}
+
+func (f *ProjectScopedFactory) NewProjectScoped(next http.Handler) http.Handler {
+	return &ProjectScoped{next, f.projectRepo, f.config}
+}
+
+type ProjectScoped struct {
+	next        http.Handler
+	projectRepo repository.ProjectRepository
+	config      *shared.Config
 }
 
-func ProjectScoped(h http.Handler, w http.ResponseWriter, r *http.Request) {
+func (scope *ProjectScoped) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	// read the project id from the request
+	projID, reqErr := GetURLParamUint(r, "project_id")
+
+	if reqErr != nil {
+		apierrors.HandleAPIError(w, scope.config.Logger, reqErr)
+		return
+	}
+
+	fmt.Println("PROJECT ID IS", projID)
 
 	// find a set of roles for this user and compute a policy document
 
 	// determine if policy document allows for project scope
 
-	// create a new project-scoped context
+	project := types.Project{}
+
+	// create a new project-scoped context and serve
+	req := r.Clone(NewProjectContext(r.Context(), project))
+	scope.next.ServeHTTP(w, req)
+}
+
+func NewProjectContext(ctx context.Context, project types.Project) context.Context {
+	return context.WithValue(ctx, types.ProjectScope, project)
 }

+ 0 - 18
api/server/auth/user.go

@@ -1,18 +0,0 @@
-package auth
-
-import (
-	"context"
-	"net/http"
-
-	"github.com/porter-dev/porter/api/types"
-)
-
-func NewUserContext(ctx context.Context, user types.User) context.Context {
-	return context.WithValue(ctx, types.UserScope, user)
-}
-
-func UserScoped(h http.Handler, w http.ResponseWriter, r *http.Request) {
-	// find the user based on the request header
-
-	// create a new user-scoped context
-}

+ 27 - 0
api/server/handlers/project/create.go

@@ -0,0 +1,27 @@
+package project
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared"
+)
+
+type ProjectCreateHandler struct {
+	config *shared.Config
+
+	endpoint *shared.APIEndpoint
+}
+
+func NewProjectCreateHandler(config *shared.Config, endpoint *shared.APIEndpoint) *ProjectCreateHandler {
+	return &ProjectCreateHandler{config, endpoint}
+}
+
+func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// request := &types.CreateProjectRequest{}
+
+	// ok := p.endpoint.Reader(r.Body, request)
+
+	// if !ok {
+	// 	return
+	// }
+}

+ 109 - 0
api/server/requestutils/decoder.go

@@ -0,0 +1,109 @@
+package requestutils
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+
+	"github.com/gorilla/schema"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+)
+
+// Decoder populates a request form from the request body and URL.
+type Decoder interface {
+	// Decode accepts a target struct, a reader for the request body, and a URL
+	// for the request endpoint
+	Decode(s interface{}, r *http.Request) apierrors.RequestError
+}
+
+// DefaultDecoder decodes the request body with `json` and the URL query params with gorilla/schema,
+type DefaultDecoder struct {
+	// we set the schema.Decoder on the global (shared across all endpoints) decoder
+	// because it caches metadata about structs, but does not cache values
+	schemaDecoder *schema.Decoder
+}
+
+// NewDefaultDecoder returns an implementation of Decoder that uses `json` and `gorilla/schema`.
+func NewDefaultDecoder() Decoder {
+	decoder := schema.NewDecoder()
+
+	return &DefaultDecoder{decoder}
+}
+
+// Decode reads the request and populates the target request object.
+func (d *DefaultDecoder) Decode(
+	s interface{},
+	r *http.Request,
+) (reqErr apierrors.RequestError) {
+	if r == nil || r.URL == nil {
+		return apierrors.NewErrInternal(fmt.Errorf("decode: request or request.URL cannot be nil"))
+	}
+
+	// read query values from URL and decode using schema library
+	vals := r.URL.Query()
+
+	if err := d.schemaDecoder.Decode(s, vals); err != nil {
+		return requestErrorFromSchemaErr(err)
+	}
+
+	// decode into the request object
+	// a nil body is not a fatal error
+	if err := json.NewDecoder(r.Body).Decode(s); err != nil && !errors.Is(err, io.EOF) {
+		return requestErrorFromJSONErr(err)
+	}
+
+	return nil
+}
+
+func requestErrorFromJSONErr(err error) apierrors.RequestError {
+	var syntaxErr *json.SyntaxError
+	var typeErr *json.UnmarshalTypeError
+	var clientErr error
+
+	if errors.As(err, &syntaxErr) {
+		clientErr = fmt.Errorf("JSON syntax error at character %d", syntaxErr.Offset)
+	} else if errors.As(err, &typeErr) {
+		clientErr = fmt.Errorf("Invalid type for body param %s: expected %s, got %s", typeErr.Field, typeErr.Type.Kind().String(), typeErr.Value)
+	} else {
+		clientErr = fmt.Errorf("Could not parse JSON request")
+	}
+
+	return apierrors.NewErrPassThroughToClient(clientErr, http.StatusBadRequest)
+}
+
+func requestErrorFromSchemaErr(err error) apierrors.RequestError {
+	if multiErr := (schema.MultiError{}); errors.As(err, &multiErr) {
+		errMap := map[string]error(multiErr)
+
+		resStrArr := make([]string, 0)
+
+		for _, err := range errMap {
+			resStrArr = append(resStrArr, readableStringFromSchemaErr(err))
+		}
+
+		clientErr := fmt.Errorf(strings.Join(resStrArr, ","))
+
+		return apierrors.NewErrPassThroughToClient(clientErr, http.StatusBadRequest)
+	}
+
+	// if not castable to multi-error, this is likely a server-side error, such as the
+	// passed struct being nil; thus, we throw an internal server error
+	return apierrors.NewErrInternal(err)
+}
+
+func readableStringFromSchemaErr(err error) string {
+	var str string
+
+	if typeErr := (schema.ConversionError{}); errors.As(err, &typeErr) {
+		str = fmt.Sprintf("Invalid type for query param %s: expected %s", typeErr.Key, typeErr.Type.Kind().String())
+	} else if emptyFieldErr := (schema.EmptyFieldError{}); errors.As(err, &emptyFieldErr) {
+		str = fmt.Sprintf("Query param %s cannot be empty", emptyFieldErr.Key)
+	} else if unknownKeyErr := (schema.UnknownKeyError{}); errors.As(err, &unknownKeyErr) {
+		str = fmt.Sprintf("Unknown query param %s", unknownKeyErr.Key)
+	}
+
+	return str
+}

+ 263 - 0
api/server/requestutils/decoder_test.go

@@ -0,0 +1,263 @@
+package requestutils_test
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/api/server/requestutils"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/stretchr/testify/assert"
+)
+
+type decoderJSONTest struct {
+	description  string
+	decodeObj    interface{}
+	getBody      func() io.ReadCloser
+	expErr       bool
+	expErrString string
+	expObj       interface{}
+}
+
+type decoderTestObj struct {
+	ID   uint   `json:"id"`
+	Name string `json:"name"`
+}
+
+const (
+	jsonFieldErrFmt  = "Invalid type for body param %s: expected %s, got %s"
+	jsonSyntaxErrFmt = "JSON syntax error at character %d"
+	jsonGenericErr   = "Could not parse JSON request"
+)
+
+func getSuccessfulJSONBody() io.ReadCloser {
+	return ioutil.NopCloser(bytes.NewReader([]byte("{\"id\":2,\"name\":\"ok\"}")))
+}
+
+func getUnreadableJSONBody() io.ReadCloser {
+	return ioutil.NopCloser(bytes.NewReader([]byte("{\"bad\":\"json\"")))
+}
+
+func getMalformedJSONBody() io.ReadCloser {
+	return ioutil.NopCloser(bytes.NewReader([]byte("{\"bad\":json}")))
+}
+
+func getTypeErrorJSONBody() io.ReadCloser {
+	return ioutil.NopCloser(bytes.NewReader([]byte("{\"id\":\"string\",\"name\":\"ok\"}")))
+}
+
+var decoderJSONTests = []decoderJSONTest{
+	{
+		description:  "Should throw error on malformed JSON with failing offset chart",
+		decodeObj:    &decoderTestObj{},
+		getBody:      getMalformedJSONBody,
+		expErr:       true,
+		expErrString: fmt.Sprintf(jsonSyntaxErrFmt, 8),
+	},
+	{
+		description:  "Should throw error on un-parsable JSON (curly bracket missing)",
+		decodeObj:    &decoderTestObj{},
+		getBody:      getUnreadableJSONBody,
+		expErr:       true,
+		expErrString: fmt.Sprintf(jsonGenericErr),
+	},
+	{
+		description:  "Should throw descriptive type error",
+		decodeObj:    &decoderTestObj{},
+		getBody:      getTypeErrorJSONBody,
+		expErr:       true,
+		expErrString: fmt.Sprintf(jsonFieldErrFmt, "id", "uint", "string"),
+	},
+	{
+		description: "Should decode successfully",
+		decodeObj:   &decoderTestObj{},
+		getBody:     getSuccessfulJSONBody,
+		expErr:      false,
+		expObj: &decoderTestObj{
+			ID:   2,
+			Name: "ok",
+		},
+	},
+}
+
+func TestJSONDecoding(t *testing.T) {
+	assert := assert.New(t)
+	decoder := requestutils.NewDefaultDecoder()
+
+	for _, test := range decoderJSONTests {
+		testReq := httptest.NewRequest("POST", "/test/post", test.getBody())
+		err := decoder.Decode(test.decodeObj, testReq)
+
+		assert.Equal(
+			err != nil,
+			test.expErr,
+			"[ %s ]: expected error was %t, got %t",
+			test.description,
+			err != nil,
+			test.expErr,
+		)
+
+		if err != nil && test.expErr {
+			readableStr := err.Error()
+			expReadableStr := test.expErrString
+
+			assert.Equal(
+				expReadableStr,
+				readableStr,
+				"[ %s ]: readable string not equal",
+				test.description,
+			)
+
+			// check that external and internal errors are returned as well
+			assert.Equal(
+				400,
+				err.GetStatusCode(),
+				"[ %s ]: status code not equal",
+				test.description,
+			)
+		} else if !test.expErr {
+			if diff := deep.Equal(test.expObj, test.decodeObj); diff != nil {
+				t.Errorf("request object not equal:")
+				t.Error(diff)
+			}
+		}
+	}
+}
+
+type decoderSchemaTest struct {
+	description  string
+	decodeObj    interface{}
+	queryStr     string
+	expErr       bool
+	expErrString string
+	expObj       interface{}
+}
+
+type decoderSchemaTestObj struct {
+	ClusterID uint   `schema:"cluster_id,required"`
+	Storage   string `schema:"storage"`
+}
+
+const (
+	invalidSchemaTypeErrFmt = "Invalid type for query param %s: expected %s"
+	emptySchemaErrFmt       = "Query param %s cannot be empty"
+	unknownQueryErrFmt      = "Unknown query param %s"
+)
+
+var decoderSchemaTests = []decoderSchemaTest{
+	{
+		description:  "Should throw error with malformed type",
+		decodeObj:    &decoderSchemaTestObj{},
+		queryStr:     "cluster_id=notid",
+		expErr:       true,
+		expErrString: fmt.Sprintf(invalidSchemaTypeErrFmt, "cluster_id", "uint"),
+	},
+	{
+		description:  "Should throw error if param is empty",
+		decodeObj:    &decoderSchemaTestObj{},
+		queryStr:     "",
+		expErr:       true,
+		expErrString: fmt.Sprintf(emptySchemaErrFmt, "cluster_id"),
+	},
+	{
+		description:  "Should throw error if query param is unknown",
+		decodeObj:    &decoderSchemaTestObj{},
+		queryStr:     "unknown=yes&cluster_id=2",
+		expErr:       true,
+		expErrString: fmt.Sprintf(unknownQueryErrFmt, "unknown"),
+	},
+	{
+		description: "Should throw multiple errors",
+		decodeObj:   &decoderSchemaTestObj{},
+		queryStr:    "unknown=yes&cluster_id=notid",
+		expErr:      true,
+		expErrString: strings.Join([]string{
+			fmt.Sprintf(unknownQueryErrFmt, "unknown"),
+			fmt.Sprintf(invalidSchemaTypeErrFmt, "cluster_id", "uint"),
+		}, ","),
+	},
+	{
+		description: "Should decode successfully",
+		decodeObj:   &decoderSchemaTestObj{},
+		queryStr:    "cluster_id=2&storage=secret",
+		expErr:      false,
+		expObj: &decoderSchemaTestObj{
+			ClusterID: 2,
+			Storage:   "secret",
+		},
+	},
+}
+
+func TestSchemaDecoding(t *testing.T) {
+	assert := assert.New(t)
+	decoder := requestutils.NewDefaultDecoder()
+
+	for _, test := range decoderSchemaTests {
+		testReq := httptest.NewRequest("POST", "/test/post?"+test.queryStr, nil)
+		err := decoder.Decode(test.decodeObj, testReq)
+
+		assert.Equal(
+			err != nil,
+			test.expErr,
+			"[ %s ]: expected error was %t, got %t",
+			test.description,
+			err != nil,
+			test.expErr,
+		)
+
+		if err != nil && test.expErr {
+			readableStrArr := strings.Split(err.Error(), ",")
+			expReadableStrArr := strings.Split(test.expErrString, ",")
+
+			assert.ElementsMatch(
+				expReadableStrArr,
+				readableStrArr,
+				"[ %s ]: readable string not equal",
+				test.description,
+			)
+
+			// check that external and internal errors are returned as well
+			assert.Equal(
+				400,
+				err.GetStatusCode(),
+				"[ %s ]: status code not equal",
+				test.description,
+			)
+		} else if !test.expErr {
+			if diff := deep.Equal(test.expObj, test.decodeObj); diff != nil {
+				t.Errorf("request object not equal:")
+				t.Error(diff)
+			}
+		}
+	}
+}
+
+func TestDecodingNilParams(t *testing.T) {
+	decoder := requestutils.NewDefaultDecoder()
+
+	err := decoder.Decode(nil, nil)
+	expErr := apierrors.NewErrInternal(fmt.Errorf("decode: request or request.URL cannot be nil"))
+
+	// check that error type is of type apierrors.RequestError and that
+	// message is correct
+	assert.EqualError(t, err, expErr.Error(), "nil param error not internal server error")
+
+	var expErrTarget apierrors.RequestError
+	assert.ErrorAs(t, err, &expErrTarget)
+
+	testReq := httptest.NewRequest("POST", "/test/post", nil)
+	err = decoder.Decode(nil, testReq)
+	expErr = apierrors.NewErrInternal(fmt.Errorf("schema: interface must be a pointer to struct"))
+
+	// check that error type is of type apierrors.RequestError and that
+	// message is correct
+	assert.EqualError(t, err, expErr.Error(), "nil param error not internal server error")
+
+	var expErrTarget2 apierrors.RequestError
+	assert.ErrorAs(t, err, &expErrTarget2)
+}

+ 154 - 0
api/server/requestutils/validator.go

@@ -0,0 +1,154 @@
+package requestutils
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+
+	"github.com/go-playground/validator/v10"
+)
+
+// Validator will validate the fields for a request object to ensure that
+// the request is well-formed. For example, it searches for required fields
+// or verifies that fields are of a semantic type (like email)
+type Validator interface {
+	// Validate accepts a generic struct for validating. It returns a request
+	// error that is meant to be shown to the end user as a readable string.
+	Validate(s interface{}) apierrors.RequestError
+}
+
+// DefaultValidator uses the go-playground v10 validator for verifying that
+// request objects are well-formed
+type DefaultValidator struct {
+	v10 *validator.Validate
+}
+
+// NewDefaultValidator returns a Validator constructed from the go-playground v10
+// validator
+func NewDefaultValidator() Validator {
+	v10 := validator.New()
+
+	// set tag name to "form" since the request structs are used on both
+	// the client and server side
+	v10.SetTagName("form")
+
+	return &DefaultValidator{v10}
+}
+
+// Validate uses the go-playground v10 validator and checks struct fields against
+// a `form:"<validator>"` tag.
+func (v *DefaultValidator) Validate(s interface{}) apierrors.RequestError {
+	err := v.v10.Struct(s)
+
+	if err == nil {
+		return nil
+	}
+
+	// translate all validator errors
+	errs, ok := err.(validator.ValidationErrors)
+
+	if !ok {
+		return apierrors.NewErrInternal(fmt.Errorf("could not cast err to validator.ValidationErrors"))
+	}
+
+	// convert all validator errors to error strings
+	errorStrs := make([]string, len(errs))
+
+	for i, field := range errs {
+		errObj := NewValidationErrObject(field)
+
+		errorStrs[i] = errObj.SafeExternalError()
+	}
+
+	return NewErrFailedRequestValidation(strings.Join(errorStrs, ","))
+}
+
+func NewErrFailedRequestValidation(valError string) apierrors.RequestError {
+	// return 400 error since a validation error indicates an issue with the user request
+	return apierrors.NewErrPassThroughToClient(fmt.Errorf(valError), http.StatusBadRequest)
+}
+
+// ValidationErrObject represents an error referencing a specific field in a struct that
+// must match a specific condition. This object is modeled off of the go-playground v10
+// validator `FieldError` type, but can be used generically for any request validation
+// issues that occur downstream.
+type ValidationErrObject struct {
+	// Field is the request field that has a validation error.
+	Field string
+
+	// Condition is the condition that was not satisfied, resulting in the validation
+	// error
+	Condition string
+
+	// Param is an optional field that shows a parameter that was not satisfied. For example,
+	// the field value was not found in the set [ "value1", "value2" ], so "value1", "value2"
+	// is the parameter in this case.
+	Param string
+
+	// ActualValue is the actual value of the field that failed validation.
+	ActualValue interface{}
+}
+
+// NewValidationErrObject simply returns a ValidationErrObject from a go-playground v10
+// validator `FieldError`
+func NewValidationErrObject(fieldErr validator.FieldError) *ValidationErrObject {
+	return &ValidationErrObject{
+		Field:       fieldErr.Field(),
+		Condition:   fieldErr.ActualTag(),
+		Param:       fieldErr.Param(),
+		ActualValue: fieldErr.Value(),
+	}
+}
+
+// SafeExternalError converts the ValidationErrObject to a string that is readable and safe
+// to send externally. In this case, "safe" means that when the `ActualValue` field is cast
+// to a string, it is type-checked so that only certain types are passed to the user. We
+// don't want an upstream command accidentally setting a complex object in the request field
+// that could leak sensitive information to the user. To limit this, we only support sending
+// static `ActualValue` types: `string`, `int`, `[]string`, and `[]int`. Otherwise, we say that
+// the actual value is "invalid type".
+//
+// Note: the test cases split on "," to parse out the different errors. Don't add commas to the
+// safe external error.
+func (obj *ValidationErrObject) SafeExternalError() string {
+	var sb strings.Builder
+
+	sb.WriteString(fmt.Sprintf("validation failed on field '%s' on condition '%s'", obj.Field, obj.Condition))
+
+	if obj.Param != "" {
+		sb.WriteString(fmt.Sprintf(" [ %s ]: got %s", obj.Param, obj.getActualValueString()))
+	}
+
+	return sb.String()
+}
+
+func (obj *ValidationErrObject) getActualValueString() string {
+	// we translate to "json-readable" form for nil values, since clients may not be Golang
+	if obj.ActualValue == nil {
+		return "null"
+	}
+
+	// create type switch statement to make sure that we don't accidentally leak
+	// data. we only want to write strings, numbers, or slices of strings/numbers.
+	// different data types can be added if necessary, as long as they are checked
+	switch v := obj.ActualValue.(type) {
+	case int:
+		return fmt.Sprintf("%d", v)
+	case string:
+		return fmt.Sprintf("'%s'", v)
+	case []string:
+		return fmt.Sprintf("[ %s ]", strings.Join(v, " "))
+	case []int:
+		strArr := make([]string, len(v))
+
+		for i, intItem := range v {
+			strArr[i] = fmt.Sprintf("%d", intItem)
+		}
+
+		return fmt.Sprintf("[ %s ]", strings.Join(strArr, " "))
+	default:
+		return "invalid type"
+	}
+}

+ 243 - 0
api/server/requestutils/validator_test.go

@@ -0,0 +1,243 @@
+package requestutils_test
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/porter-dev/porter/api/server/requestutils"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+)
+
+const (
+	requiredErrorFmt        = "validation failed on field '%s' on condition 'required'"
+	simpleConditionErrorFmt = "validation failed on field '%s' on condition '%s'"
+	paramErrorFmt           = "validation failed on field '%s' on condition '%s' [ %s ]: got %s"
+)
+
+type validationErrObjectTest struct {
+	valErrObj *requestutils.ValidationErrObject
+	expStr    string
+}
+
+var validationErrObjectTests = []validationErrObjectTest{
+	{
+		valErrObj: &requestutils.ValidationErrObject{
+			Field:       "username",
+			Condition:   "required",
+			Param:       "",
+			ActualValue: nil,
+		},
+		expStr: fmt.Sprintf(requiredErrorFmt, "username"),
+	},
+	{
+		valErrObj: &requestutils.ValidationErrObject{
+			Field:       "storage",
+			Condition:   "oneof",
+			Param:       "secret configmap",
+			ActualValue: "notsecret",
+		},
+		expStr: fmt.Sprintf(paramErrorFmt, "storage", "oneof", "secret configmap", "'notsecret'"),
+	},
+	{
+		valErrObj: &requestutils.ValidationErrObject{
+			Field:       "storage",
+			Condition:   "oneof",
+			Param:       "secret configmap",
+			ActualValue: 1,
+		},
+		expStr: fmt.Sprintf(paramErrorFmt, "storage", "oneof", "secret configmap", "1"),
+	},
+	{
+		valErrObj: &requestutils.ValidationErrObject{
+			Field:       "storage",
+			Condition:   "oneof",
+			Param:       "secret configmap",
+			ActualValue: []string{"secret1", "secret2"},
+		},
+		expStr: fmt.Sprintf(paramErrorFmt, "storage", "oneof", "secret configmap", "[ secret1 secret2 ]"),
+	},
+	{
+		valErrObj: &requestutils.ValidationErrObject{
+			Field:       "storage",
+			Condition:   "oneof",
+			Param:       "secret configmap",
+			ActualValue: []int{1, 2},
+		},
+		expStr: fmt.Sprintf(paramErrorFmt, "storage", "oneof", "secret configmap", "[ 1 2 ]"),
+	},
+	{
+		valErrObj: &requestutils.ValidationErrObject{
+			Field:     "storage",
+			Condition: "oneof",
+			Param:     "secret configmap",
+			// for nil values, we convert the actual value to null
+			ActualValue: nil,
+		},
+		expStr: fmt.Sprintf(paramErrorFmt, "storage", "oneof", "secret configmap", "null"),
+	},
+	{
+		valErrObj: &requestutils.ValidationErrObject{
+			Field:     "storage",
+			Condition: "oneof",
+			Param:     "secret configmap",
+			// for unrecognized types, we don't cast to value
+			ActualValue: map[string]string{
+				"not": "cast",
+			},
+		},
+		expStr: fmt.Sprintf(paramErrorFmt, "storage", "oneof", "secret configmap", "invalid type"),
+	},
+}
+
+func TestValidationErrObject(t *testing.T) {
+	assert := assert.New(t)
+
+	for _, test := range validationErrObjectTests {
+		// test that the function outputs the expected readable error message
+		readableStr := test.valErrObj.SafeExternalError()
+		expReadableStr := test.expStr
+
+		assert.Equal(
+			expReadableStr,
+			readableStr,
+			"readable string not equal: expected %s, got %s",
+			expReadableStr,
+			readableStr,
+		)
+	}
+}
+
+type validationTest struct {
+	description   string
+	valObj        interface{}
+	expErr        bool
+	expErrStrings []string
+}
+
+type validationTestObj struct {
+	ID      uint   `form:"required"`
+	Name    string `form:"required"`
+	Email   string `form:"email"`
+	Storage string `form:"oneof=sqlite postgres"`
+}
+
+var validationTests = []validationTest{
+	{
+		description: "Missing all fields",
+		valObj:      &validationTestObj{},
+		expErr:      true,
+		expErrStrings: []string{
+			fmt.Sprintf(requiredErrorFmt, "ID"),
+			fmt.Sprintf(requiredErrorFmt, "Name"),
+			fmt.Sprintf(simpleConditionErrorFmt, "Email", "email"),
+			fmt.Sprintf(paramErrorFmt, "Storage", "oneof", "sqlite postgres", "''"),
+		},
+	},
+	{
+		description: "Fails email validation",
+		valObj: &validationTestObj{
+			ID:      1,
+			Name:    "whatever",
+			Email:   "notanemail",
+			Storage: "postgres",
+		},
+		expErr: true,
+		expErrStrings: []string{
+			fmt.Sprintf(simpleConditionErrorFmt, "Email", "email"),
+		},
+	},
+	{
+		description: "Should pass all",
+		valObj: &validationTestObj{
+			ID:      1,
+			Name:    "whatever",
+			Email:   "anemail@gmail.com",
+			Storage: "postgres",
+		},
+		expErr:        false,
+		expErrStrings: []string{},
+	},
+}
+
+func TestValidation(t *testing.T) {
+	assert := assert.New(t)
+	validator := requestutils.NewDefaultValidator()
+
+	for _, test := range validationTests {
+		// test that the function outputs the expected readable error message
+		err := validator.Validate(test.valObj)
+
+		assert.Equal(
+			err != nil,
+			test.expErr,
+			"[ %s ]: expected error was %t, got %t",
+			test.description,
+			err != nil,
+			test.expErr,
+		)
+
+		if err != nil && test.expErr {
+			readableStrArr := strings.Split(err.Error(), ",")
+			expReadableStrArr := test.expErrStrings
+
+			assert.ElementsMatch(
+				expReadableStrArr,
+				readableStrArr,
+				"[ %s ]: readable string not equal",
+				test.description,
+			)
+
+			// check that external and internal errors are returned as well
+			assert.Equal(
+				400,
+				err.GetStatusCode(),
+				"[ %s ]: status code not equal",
+				test.description,
+			)
+		}
+	}
+}
+
+func TestValidationNilParam(t *testing.T) {
+	validator := requestutils.NewDefaultValidator()
+
+	err := validator.Validate(nil)
+	expErr := apierrors.NewErrInternal(fmt.Errorf("could not cast err to validator.ValidationErrors"))
+
+	// check that error type is of type apierrors.RequestError and that
+	// message is correct
+	assert.EqualError(t, err, expErr.Error(), "nil param error not internal server error")
+
+	var expErrTarget apierrors.RequestError
+	assert.ErrorAs(t, err, &expErrTarget)
+}
+
+func TestErrFailedRequestValidation(t *testing.T) {
+	assert := assert.New(t)
+
+	// just check that status code is 400 and all errors are set
+	expErrStr := "readable error"
+	err := requestutils.NewErrFailedRequestValidation(expErrStr)
+
+	assert.Equal(
+		expErrStr,
+		err.Error(),
+		"incorrect value for Error() method",
+	)
+
+	assert.Equal(
+		expErrStr,
+		err.ExternalError(),
+		"incorrect value for ExternalError() method",
+	)
+
+	// check that the status code is 400
+	assert.Equal(
+		expErrStr,
+		err.InternalError(),
+		"incorrect value for InternalError() method",
+	)
+}

+ 66 - 0
api/server/router/project.go

@@ -0,0 +1,66 @@
+package server
+
+import (
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/auth"
+	"github.com/porter-dev/porter/api/server/handlers/project"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/types"
+)
+
+func NewProjectRouter(
+	config *shared.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) *shared.Router {
+	router := chi.NewRouter()
+
+	// Create a new "project-scoped" factory which will create a new project-scoped request
+	// after authorization. Each subsequent http.Handler can lookup the project in context.
+	authFactory := auth.NewProjectScopedFactory(config.Repo.Project, config)
+
+	// attach middleware to router
+	router.Use(authFactory.NewProjectScoped)
+
+	routes := registerProjectEndpoints(config, basePath, factory, router)
+
+	return &shared.Router{
+		Router: router,
+		Routes: routes,
+		Config: config,
+	}
+}
+
+func registerProjectEndpoints(
+	config *shared.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	router *chi.Mux,
+) (routes []*shared.Route) {
+	routes = make([]*shared.Route, 0)
+
+	projectPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: "/projects",
+	}
+
+	createEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       projectPath,
+				RelativePath: "",
+			},
+		},
+	)
+
+	createHandler := project.NewProjectCreateHandler(config, createEndpoint)
+
+	routes = append(routes, &shared.Route{
+		Endpoint: createEndpoint,
+		Handler:  createHandler,
+	})
+
+	return routes
+}

+ 1 - 15
api/server/server.go

@@ -1,17 +1,3 @@
 package server
 
-import (
-	"io"
-
-	"github.com/porter-dev/porter/api/types"
-)
-
-type RequestReader func(r io.Reader, v interface{}) error
-
-type ResponseWriter func(w io.Writer, v interface{}) error
-
-type APIRequest struct {
-	Metadata *types.APIRequestMetadata
-	Reader   RequestReader
-	Writer   ResponseWriter
-}
+// register all routes to create top-level server

+ 123 - 0
api/server/shared/apierrors/errors.go

@@ -0,0 +1,123 @@
+package apierrors
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/logger"
+)
+
+type RequestError interface {
+	Error() string
+	ExternalError() string
+	InternalError() string
+	GetStatusCode() int
+}
+
+type ErrInternal struct {
+	err error
+}
+
+func NewErrInternal(err error) RequestError {
+	return &ErrInternal{err}
+}
+
+func (e *ErrInternal) Error() string {
+	return e.err.Error()
+}
+
+func (e *ErrInternal) InternalError() string {
+	return e.err.Error()
+}
+
+func (e *ErrInternal) ExternalError() string {
+	return "An internal error occurred."
+}
+
+func (e *ErrInternal) GetStatusCode() int {
+	return http.StatusInternalServerError
+}
+
+type ErrForbidden struct {
+	err error
+}
+
+func NewErrForbidden(err error) RequestError {
+	return &ErrForbidden{err}
+}
+
+func (e *ErrForbidden) Error() string {
+	return e.err.Error()
+}
+
+func (e *ErrForbidden) InternalError() string {
+	return e.err.Error()
+}
+
+func (e *ErrForbidden) ExternalError() string {
+	return "Forbidden"
+}
+
+func (e *ErrForbidden) GetStatusCode() int {
+	return http.StatusForbidden
+}
+
+// errors that should be passed directly, with no filter
+type ErrPassThroughToClient struct {
+	err        error
+	statusCode int
+}
+
+func NewErrPassThroughToClient(err error, statusCode int) RequestError {
+	return &ErrPassThroughToClient{err, statusCode}
+}
+
+func (e *ErrPassThroughToClient) Error() string {
+	return e.err.Error()
+}
+
+func (e *ErrPassThroughToClient) InternalError() string {
+	return e.err.Error()
+}
+
+func (e *ErrPassThroughToClient) ExternalError() string {
+	return e.err.Error()
+}
+
+func (e *ErrPassThroughToClient) GetStatusCode() int {
+	return e.statusCode
+}
+
+type externalError struct {
+	Error string `json:"error"`
+}
+
+func HandleAPIError(
+	w http.ResponseWriter,
+	logger *logger.Logger,
+	err RequestError,
+) {
+	extErrorStr := err.ExternalError()
+
+	// log the internal error
+	logger.Warn().
+		Str("internal_error", err.InternalError()).
+		Str("external_error", extErrorStr).
+		Msg("")
+
+	// send the external error
+	resp := &externalError{extErrorStr}
+
+	// write the status code
+	w.WriteHeader(err.GetStatusCode())
+
+	writerErr := json.NewEncoder(w).Encode(resp)
+
+	if writerErr != nil {
+		logger.Error().
+			Err(writerErr).
+			Msg("")
+	}
+
+	return
+}

+ 11 - 0
api/server/shared/capabilities.go

@@ -0,0 +1,11 @@
+package shared
+
+type Capabilities struct {
+	Provisioning bool `json:"provisioner"`
+	Github       bool `json:"github"`
+	BasicLogin   bool `json:"basic_login"`
+	GithubLogin  bool `json:"github_login"`
+	GoogleLogin  bool `json:"google_login"`
+	Email        bool `json:"email"`
+	Analytics    bool `json:"analytics"`
+}

+ 18 - 0
api/server/shared/config.go

@@ -0,0 +1,18 @@
+package shared
+
+import (
+	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type Config struct {
+	// Logger for logging
+	Logger *logger.Logger
+
+	// Repo implements a query repository
+	Repo *repository.Repository
+
+	// Capabilities is a description object for the server capabilities, used
+	// to determine which endpoints to register
+	Capabilities *Capabilities
+}

+ 59 - 0
api/server/shared/endpoints.go

@@ -0,0 +1,59 @@
+package shared
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/requestutils"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type APIEndpoint struct {
+	Metadata         *types.APIRequestMetadata
+	DecoderValidator RequestDecoderValidator
+	Writer           ResultWriter
+}
+
+type APIEndpointFactory interface {
+	NewAPIEndpoint(metadata *types.APIRequestMetadata) *APIEndpoint
+}
+
+type APIObjectEndpointFactory struct {
+	decoderValidator RequestDecoderValidator
+	resultWriter     ResultWriter
+}
+
+func NewAPIObjectEndpointFactory(config *Config) APIEndpointFactory {
+	validator := requestutils.NewDefaultValidator()
+	decoder := requestutils.NewDefaultDecoder()
+
+	decoderValidator := NewDefaultRequestDecoderValidator(config, validator, decoder)
+	resultWriter := NewDefaultResultWriter(config)
+
+	return &APIObjectEndpointFactory{
+		decoderValidator: decoderValidator,
+		resultWriter:     resultWriter,
+	}
+}
+
+func (factory *APIObjectEndpointFactory) NewAPIEndpoint(
+	metadata *types.APIRequestMetadata,
+) *APIEndpoint {
+	return &APIEndpoint{
+		Metadata:         metadata,
+		DecoderValidator: factory.decoderValidator,
+		Writer:           factory.resultWriter,
+	}
+}
+
+type JSONResponseWriter struct {
+	config *Config
+}
+
+func NewJSONResponseWriter(config *Config) *JSONResponseWriter {
+	return &JSONResponseWriter{config}
+}
+
+func (j *JSONResponseWriter) Write(w http.ResponseWriter, v interface{}) {
+	json.NewEncoder(w).Encode(v)
+}

+ 48 - 0
api/server/shared/reader.go

@@ -0,0 +1,48 @@
+package shared
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/requestutils"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+)
+
+type RequestDecoderValidator interface {
+	DecodeAndValidate(w http.ResponseWriter, r *http.Request, v interface{}) bool
+}
+
+type DefaultRequestDecoderValidator struct {
+	config    *Config
+	validator requestutils.Validator
+	decoder   requestutils.Decoder
+}
+
+func NewDefaultRequestDecoderValidator(
+	config *Config,
+	validator requestutils.Validator,
+	decoder requestutils.Decoder,
+) RequestDecoderValidator {
+	return &DefaultRequestDecoderValidator{config, validator, decoder}
+}
+
+func (j *DefaultRequestDecoderValidator) DecodeAndValidate(
+	w http.ResponseWriter,
+	r *http.Request,
+	v interface{},
+) (ok bool) {
+	var requestErr apierrors.RequestError
+
+	// decode the request parameters (body and query)
+	if requestErr = j.decoder.Decode(v, r); requestErr != nil {
+		apierrors.HandleAPIError(w, j.config.Logger, requestErr)
+		return false
+	}
+
+	// validate the request object
+	if requestErr = j.validator.Validate(v); requestErr != nil {
+		apierrors.HandleAPIError(w, j.config.Logger, requestErr)
+		return false
+	}
+
+	return true
+}

+ 28 - 0
api/server/shared/router.go

@@ -0,0 +1,28 @@
+package shared
+
+import (
+	"net/http"
+
+	"github.com/go-chi/chi"
+)
+
+type Route struct {
+	Endpoint *APIEndpoint
+	Handler  http.Handler
+}
+
+type Router struct {
+	Router *chi.Mux
+	Routes []*Route
+	Config *Config
+}
+
+func (r *Router) RegisterRoutes() {
+	for _, route := range r.Routes {
+		r.Router.Method(
+			string(route.Endpoint.Metadata.Method),
+			route.Endpoint.Metadata.Path.RelativePath,
+			route.Handler,
+		)
+	}
+}

+ 22 - 0
api/server/shared/writer.go

@@ -0,0 +1,22 @@
+package shared
+
+import "net/http"
+
+type ResultWriter interface {
+	WriteResult(w http.ResponseWriter, v interface{})
+}
+
+// default generalizes response codes for common operations
+// (http.StatusOK, http.StatusCreated, etc)
+type DefaultResultWriter struct {
+	config *Config
+}
+
+func NewDefaultResultWriter(config *Config) ResultWriter {
+	return &DefaultResultWriter{config}
+}
+
+func (j *DefaultResultWriter) WriteResult(w http.ResponseWriter, v interface{}) {
+	// TODO: unimplemented
+	return
+}

+ 0 - 23
api/types/permissions.go

@@ -1,23 +0,0 @@
-package types
-
-type PermissionObject string
-
-const (
-	UserScope      PermissionObject = "user"
-	ProjectScope   PermissionObject = "project"
-	ClusterScope   PermissionObject = "cluster"
-	NamespaceScope PermissionObject = "namespace"
-)
-
-type Permission struct {
-	Object PermissionObject
-	Verb   APIVerb
-}
-
-type PolicyObjectReference struct {
-	Resource   PermissionObject `json:"resource"`
-	Verbs      []APIVerb        `json:"verbs"`
-	VerbGroups []APIVerbGroup   `json:"verb_groups"`
-}
-
-type Policy []PolicyObjectReference

+ 49 - 0
api/types/policy.go

@@ -0,0 +1,49 @@
+package types
+
+type PermissionScope string
+
+const (
+	UserScope      PermissionScope = "user"
+	ProjectScope   PermissionScope = "project"
+	ClusterScope   PermissionScope = "cluster"
+	NamespaceScope PermissionScope = "namespace"
+	SettingsScope  PermissionScope = "settings"
+	ReleaseScope   PermissionScope = "release"
+)
+
+type NameOrUInt struct {
+	Name string `json:"name"`
+	UInt uint   `json:"uint"`
+}
+
+type PolicyDocument struct {
+	Scope     PermissionScope                     `json:"scope"`
+	Resources []NameOrUInt                        `json:"resources"`
+	Verbs     []APIVerb                           `json:"verbs"`
+	Children  map[PermissionScope]*PolicyDocument `json:"children"`
+}
+
+type ScopeTree map[PermissionScope]ScopeTree
+
+/* ScopeHeirarchy describes the scope tree:
+
+			Project
+		   /	   \
+		Cluster   Settings
+		/
+	Namespace
+       |
+	 Release
+*/
+var ScopeHeirarchy = ScopeTree{
+	ProjectScope: {
+		ClusterScope: {
+			NamespaceScope: {
+				ReleaseScope: {},
+			},
+		},
+		SettingsScope: {},
+	},
+}
+
+type Policy []*PolicyDocument

+ 8 - 11
api/types/request.go

@@ -16,8 +16,8 @@ func ReadVerbGroup() APIVerbGroup {
 	return []APIVerb{APIVerbGet, APIVerbList}
 }
 
-func WriteVerbGroup() APIVerbGroup {
-	return []APIVerb{APIVerbCreate, APIVerbUpdate, APIVerbDelete}
+func ReadWriteVerbGroup() APIVerbGroup {
+	return []APIVerb{APIVerbGet, APIVerbList, APIVerbCreate, APIVerbUpdate, APIVerbDelete}
 }
 
 type HTTPVerb string
@@ -30,16 +30,13 @@ const (
 	HTTPVerbDelete HTTPVerb = "DELETE"
 )
 
-type EndpointPath struct{}
-
-type Endpoint struct {
-	Parent       *Endpoint
-	RelativePath EndpointPath
-	Permissions  []Permission
+type Path struct {
+	Parent       *Path
+	RelativePath string
 }
 
 type APIRequestMetadata struct {
-	Verb     APIVerb
-	Method   HTTPVerb
-	Endpoint *Endpoint
+	Verb   APIVerb
+	Method HTTPVerb
+	Path   *Path
 }

+ 16 - 0
api/types/role.go

@@ -0,0 +1,16 @@
+package types
+
+type RoleKind string
+
+const (
+	RoleAdmin     RoleKind = "admin"
+	RoleDeveloper RoleKind = "developer"
+	RoleViewer    RoleKind = "viewer"
+	RoleCustom    RoleKind = "custom"
+)
+
+type Role struct {
+	Kind      RoleKind `json:"kind"`
+	UserID    uint     `json:"user_id"`
+	ProjectID uint     `json:"project_id"`
+}

+ 1 - 1
cli/cmd/auth.go

@@ -7,7 +7,7 @@ import (
 
 	"github.com/fatih/color"
 
-	"github.com/porter-dev/porter/api"
+	api "github.com/porter-dev/porter/api/client"
 	loginBrowser "github.com/porter-dev/porter/cli/cmd/login"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"

+ 1 - 1
cli/cmd/cluster.go

@@ -9,7 +9,7 @@ import (
 	"text/tabwriter"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
 )

+ 1 - 1
cli/cmd/connect.go

@@ -3,7 +3,7 @@ package cmd
 import (
 	"os"
 
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/connect"
 	"github.com/spf13/cobra"
 )

+ 1 - 1
cli/cmd/connect/actions.go

@@ -6,7 +6,7 @@ import (
 	"strconv"
 	"time"
 
-	"github.com/porter-dev/porter/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 
 	ints "github.com/porter-dev/porter/internal/models/integrations"

+ 1 - 1
cli/cmd/connect/dockerhub.go

@@ -5,7 +5,7 @@ import (
 	"fmt"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 )
 

+ 1 - 1
cli/cmd/connect/docr.go

@@ -6,7 +6,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 
 	ints "github.com/porter-dev/porter/internal/models/integrations"

+ 1 - 1
cli/cmd/connect/ecr.go

@@ -8,7 +8,7 @@ import (
 
 	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/internal/models/integrations"
 

+ 1 - 1
cli/cmd/connect/gcr.go

@@ -7,7 +7,7 @@ import (
 	"os"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 )
 

+ 1 - 1
cli/cmd/connect/helm.go

@@ -6,7 +6,7 @@ import (
 	"strings"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 )
 

+ 1 - 1
cli/cmd/connect/kubeconfig.go

@@ -15,7 +15,7 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/internal/kubernetes/local"
 
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/internal/models"
 )
 

+ 1 - 1
cli/cmd/connect/registry.go

@@ -5,7 +5,7 @@ import (
 	"fmt"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 )
 

+ 1 - 1
cli/cmd/create/create.go

@@ -1,7 +1,7 @@
 package create
 
 import (
-	"github.com/porter-dev/porter/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 )
 

+ 1 - 1
cli/cmd/deploy.go

@@ -5,7 +5,7 @@ import (
 	"os"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/spf13/cobra"
 )

+ 1 - 1
cli/cmd/deploy/deploy.go

@@ -9,7 +9,7 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/porter-dev/porter/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/github"
 	"github.com/porter-dev/porter/cli/cmd/pack"

+ 1 - 1
cli/cmd/docker.go

@@ -13,7 +13,7 @@ import (
 	"strings"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/github"
 	"github.com/spf13/cobra"
 

+ 1 - 1
cli/cmd/docker/auth.go

@@ -12,7 +12,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/porter-dev/porter/api"
+	api "github.com/porter-dev/porter/api/client"
 	"k8s.io/client-go/util/homedir"
 )
 

+ 1 - 1
cli/cmd/docker/config.go

@@ -4,7 +4,7 @@ import (
 	"context"
 
 	"github.com/docker/docker/client"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 )
 
 const label = "CreatedByPorterCLI"

+ 1 - 1
cli/cmd/errors.go

@@ -5,7 +5,7 @@ import (
 	"strings"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 )
 
 func checkLoginAndRun(args []string, runner func(user *api.AuthCheckResponse, client *api.Client, args []string) error) error {

+ 1 - 1
cli/cmd/helm_repo.go

@@ -8,7 +8,7 @@ import (
 	"text/tabwriter"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/spf13/cobra"
 )
 

+ 1 - 1
cli/cmd/project.go

@@ -9,7 +9,7 @@ import (
 	"text/tabwriter"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
 )

+ 1 - 1
cli/cmd/registry.go

@@ -9,7 +9,7 @@ import (
 	"text/tabwriter"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
 )

+ 1 - 1
cli/cmd/root.go

@@ -4,7 +4,7 @@ import (
 	"os"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/spf13/cobra"
 	"k8s.io/client-go/util/homedir"
 )

+ 1 - 1
cli/cmd/run.go

@@ -7,7 +7,7 @@ import (
 	"strings"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/api"
+	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
 	"k8s.io/apimachinery/pkg/runtime"

+ 0 - 431
client/project_test.go

@@ -1,431 +0,0 @@
-package client_test
-
-import (
-	"context"
-	"testing"
-
-	"github.com/porter-dev/porter/cli/cmd/api"
-	"github.com/porter-dev/porter/internal/models"
-
-	"github.com/porter-dev/porter/client"
-)
-
-func initProject(name string, client *client.Client, t *testing.T) *client.CreateProjectResponse {
-	t.Helper()
-
-	resp, err := client.CreateProject(context.Background(), &client.CreateProjectRequest{
-		Name: name,
-	})
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	return resp
-}
-
-func initProjectCandidate(
-	projectID uint,
-	kubeconfig string,
-	client *api.Client,
-	t *testing.T,
-) *models.ClusterCandidateExternal {
-	t.Helper()
-
-	resp, err := client.CreateProjectCandidates(
-		context.Background(),
-		projectID,
-		&api.CreateProjectCandidatesRequest{
-			Kubeconfig: kubeconfig,
-		},
-	)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	return resp[0]
-}
-
-func initProjectCluster(
-	projectID uint,
-	candidateID uint,
-	client *api.Client,
-	t *testing.T,
-) *api.CreateProjectClusterResponse {
-	t.Helper()
-
-	resp, err := client.CreateProjectCluster(
-		context.Background(),
-		projectID,
-		candidateID,
-		&models.ClusterResolverAll{
-			OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
-		},
-	)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	return resp
-}
-
-func TestCreateProject(t *testing.T) {
-	email := "create_project_test@example.com"
-	client := api.NewClient(baseURL, "cookie_create_project_test.json")
-	user := initUser(email, client, t)
-	client.Login(context.Background(), &api.LoginRequest{
-		Email:    user.Email,
-		Password: "hello1234",
-	})
-
-	resp, err := client.CreateProject(context.Background(), &api.CreateProjectRequest{
-		Name: "project-test",
-	})
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// make sure user is admin and project name is correct
-	if resp.Name != "project-test" {
-		t.Errorf("project name incorrect: expected %s, got %s\n", "project-test", resp.Name)
-	}
-
-	if len(resp.Roles) != 1 {
-		t.Fatalf("project role length is not 1")
-	}
-
-	if resp.Roles[0].Kind != models.RoleAdmin {
-		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
-	}
-
-	if resp.Roles[0].UserID != user.ID {
-		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
-	}
-}
-
-func TestGetProject(t *testing.T) {
-	email := "get_project_test@example.com"
-	client := api.NewClient(baseURL, "cookie_get_project_test.json")
-	user := initUser(email, client, t)
-	client.Login(context.Background(), &api.LoginRequest{
-		Email:    user.Email,
-		Password: "hello1234",
-	})
-	project := initProject("project-test", client, t)
-
-	resp, err := client.GetProject(context.Background(), project.ID)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// make sure user is admin and project name is correct
-	if resp.Name != "project-test" {
-		t.Errorf("project name incorrect: expected %s, got %s\n", "project-test", resp.Name)
-	}
-
-	if len(resp.Roles) != 1 {
-		t.Fatalf("project role length is not 1")
-	}
-
-	if resp.Roles[0].Kind != models.RoleAdmin {
-		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
-	}
-
-	if resp.Roles[0].UserID != user.ID {
-		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
-	}
-}
-
-func TestGetProjectServiceAccount(t *testing.T) {
-	email := "get_project_sa_test@example.com"
-	client := api.NewClient(baseURL, "cookie_get_project_sa_test.json")
-	user := initUser(email, client, t)
-	client.Login(context.Background(), &api.LoginRequest{
-		Email:    user.Email,
-		Password: "hello1234",
-	})
-	project := initProject("project-test", client, t)
-	cc := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
-	cluster := initProjectCluster(project.ID, cc.ID, client, t)
-
-	resp, err := client.GetProjectCluster(context.Background(), project.ID, cluster.ID)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// ensure project id and metadata is correct
-	if resp.ProjectID != project.ID {
-		t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp.ProjectID)
-	}
-
-	// verify clusters
-	if resp.Name != "cluster-test" {
-		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "cluster-test", resp.Name)
-	}
-
-	if resp.Server != "https://10.10.10.10" {
-		t.Errorf("cluster's server is incorrect: expected %s, got %s\n", "https://10.10.10.10", resp.Server)
-	}
-}
-
-func TestCreateProjectCandidates(t *testing.T) {
-	email := "create_project_candidates_test@example.com"
-	client := api.NewClient(baseURL, "cookie_create_project_candidates_test.json")
-	user := initUser(email, client, t)
-	client.Login(context.Background(), &api.LoginRequest{
-		Email:    user.Email,
-		Password: "hello1234",
-	})
-	project := initProject("project-test", client, t)
-
-	resp, err := client.CreateProjectCandidates(
-		context.Background(),
-		project.ID,
-		&api.CreateProjectCandidatesRequest{
-			Kubeconfig: OIDCAuthWithoutData,
-		},
-	)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// make sure length is 1
-	if len(resp) != 1 {
-		t.Fatalf("candidates length is not 1\n")
-	}
-
-	// make sure auth mechanism is OIDC, project id is correct, and cluster info is correct
-	if resp[0].ProjectID != project.ID {
-		t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp[0].ProjectID)
-	}
-
-	if resp[0].Name != "cluster-test" {
-		t.Errorf("cluster name incorrect: expected %s, got %s\n", "cluster-test", resp[0].Name)
-	}
-
-	if resp[0].Server != "https://10.10.10.10" {
-		t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://10.10.10.10", resp[0].Server)
-	}
-
-	// make sure correct resolvers need to be performed
-	if len(resp[0].Resolvers) != 1 {
-		t.Fatalf("actions length is not 1\n")
-	}
-}
-
-func TestGetProjectCandidates(t *testing.T) {
-	email := "get_project_candidates_test@example.com"
-	client := api.NewClient(baseURL, "cookie_get_project_candidates_test.json")
-	user := initUser(email, client, t)
-	client.Login(context.Background(), &api.LoginRequest{
-		Email:    user.Email,
-		Password: "hello1234",
-	})
-	project := initProject("project-test", client, t)
-	initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
-
-	resp, err := client.GetProjectCandidates(context.Background(), project.ID)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// make sure length is 1
-	if len(resp) != 1 {
-		t.Fatalf("candidates length is not 1\n")
-	}
-
-	// make sure auth mechanism is OIDC, project id is correct, and cluster info is correct
-	// if resp[0].Integration != models.OIDC {
-	// 	t.Errorf("oidc auth mechanism incorrect: expected %s, got %s\n", models.OIDC, resp[0].Integration)
-	// }
-
-	// if resp[0].ProjectID != project.ID {
-	// 	t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp[0].ProjectID)
-	// }
-
-	// if resp[0].ClusterName != "cluster-test" {
-	// 	t.Errorf("cluster name incorrect: expected %s, got %s\n", "cluster-test", resp[0].ClusterName)
-	// }
-
-	// if resp[0].ClusterEndpoint != "https://10.10.10.10" {
-	// 	t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://10.10.10.10", resp[0].ClusterEndpoint)
-	// }
-
-	// // make sure correct actions need to be performed
-	// if len(resp[0].Actions) != 1 {
-	// 	t.Fatalf("actions length is not 1\n")
-	// }
-
-	// if resp[0].Actions[0].Name != models.OIDCIssuerDataAction {
-	// 	t.Errorf("action name incorrect: expected %s, got %s\n", models.OIDCIssuerDataAction, resp[0].Actions[0].Name)
-	// }
-
-	// if resp[0].Actions[0].Filename != "/fake/path/to/ca.pem" {
-	// 	t.Errorf("action filename incorrect: expected %s, got %s\n", "/fake/path/to/ca.pem", resp[0].Actions[0].Filename)
-	// }
-}
-
-func TestCreateProjectServiceAccount(t *testing.T) {
-	email := "create_project_sa_test@example.com"
-	client := api.NewClient(baseURL, "cookie_create_project_sa_test.json")
-	user := initUser(email, client, t)
-	client.Login(context.Background(), &api.LoginRequest{
-		Email:    user.Email,
-		Password: "hello1234",
-	})
-	project := initProject("project-test", client, t)
-	saCandidate := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
-
-	resp, err := client.CreateProjectCluster(
-		context.Background(),
-		project.ID,
-		saCandidate.ID,
-		&models.ClusterResolverAll{
-			OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
-		},
-	)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// ensure project id and metadata is correct
-	if resp.ProjectID != project.ID {
-		t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp.ProjectID)
-	}
-
-	// if resp.Kind != "connector" {
-	// 	t.Errorf("service account kind incorrect: expected %s, got %s\n", "connector", resp.Kind)
-	// }
-
-	// if resp.Integration != models.OIDC {
-	// 	t.Errorf("service account auth mechanism incorrect: expected %s, got %s\n", models.OIDC, resp.Integration)
-	// }
-
-	// // verify clusters
-	// if len(resp.Clusters) != 1 {
-	// 	t.Fatalf("length of clusters is not 1")
-	// }
-
-	// if resp.Clusters[0].ServiceAccountID != resp.ID {
-	// 	t.Errorf("cluster's sa id is incorrect: expected %d, got %d\n", resp.ID, resp.Clusters[0].ServiceAccountID)
-	// }
-
-	// if resp.Clusters[0].Name != "cluster-test" {
-	// 	t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "cluster-test", resp.Clusters[0].Name)
-	// }
-
-	// if resp.Clusters[0].Server != "https://10.10.10.10" {
-	// 	t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://10.10.10.10", resp.Clusters[0].Server)
-	// }
-}
-
-func TestListProjectClusters(t *testing.T) {
-	email := "list_project_clusters_test@example.com"
-	client := api.NewClient(baseURL, "cookie_list_project_clusters_test.json")
-	user := initUser(email, client, t)
-	client.Login(context.Background(), &api.LoginRequest{
-		Email:    user.Email,
-		Password: "hello1234",
-	})
-	project := initProject("project-test", client, t)
-	cc := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
-	initProjectCluster(project.ID, cc.ID, client, t)
-
-	resp, err := client.ListProjectClusters(
-		context.Background(),
-		project.ID,
-	)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// verify clusters
-	if len(resp) != 1 {
-		t.Fatalf("length of clusters is not 1")
-	}
-
-	if resp[0].Name != "cluster-test" {
-		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "cluster-test", resp[0].Name)
-	}
-
-	if resp[0].Server != "https://10.10.10.10" {
-		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://10.10.10.10", resp[0].Server)
-	}
-}
-
-func TestDeleteProject(t *testing.T) {
-	email := "delete_project_test@example.com"
-	client := api.NewClient(baseURL, "cookie_delete_project_test.json")
-	user := initUser(email, client, t)
-	client.Login(context.Background(), &api.LoginRequest{
-		Email:    user.Email,
-		Password: "hello1234",
-	})
-	project := initProject("project-test", client, t)
-
-	resp, err := client.DeleteProject(context.Background(), project.ID)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// make sure user is admin and project name is correct
-	if resp.Name != "project-test" {
-		t.Errorf("project name incorrect: expected %s, got %s\n", "project-test", resp.Name)
-	}
-
-	if len(resp.Roles) != 1 {
-		t.Fatalf("project role length is not 1")
-	}
-
-	if resp.Roles[0].Kind != models.RoleAdmin {
-		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
-	}
-
-	if resp.Roles[0].UserID != user.ID {
-		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
-	}
-
-	// make sure that project can no longer be found
-	_, err = client.GetProject(context.Background(), project.ID)
-
-	if err == nil {
-		t.Fatalf("no error returned\n")
-	}
-}
-
-const OIDCAuthWithoutData string = `
-apiVersion: v1
-clusters:
-- cluster:
-    server: https://10.10.10.10
-    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
-  name: cluster-test
-contexts:
-- context:
-    cluster: cluster-test
-    user: test-admin
-  name: context-test
-current-context: context-test
-kind: Config
-preferences: {}
-users:
-- name: test-admin
-  user:
-    auth-provider:
-      config:
-        client-id: porter-api
-        id-token: token
-        idp-issuer-url: https://10.10.10.10
-        idp-certificate-authority: /fake/path/to/ca.pem
-      name: oidc
-`

+ 0 - 164
client/user_test.go

@@ -1,164 +0,0 @@
-package client_test
-
-import (
-	"context"
-	"strings"
-	"testing"
-
-	"github.com/porter-dev/porter/cli/cmd/api"
-	"github.com/porter-dev/porter/internal/models"
-)
-
-func initUser(email string, client *api.Client, t *testing.T) *api.CreateUserResponse {
-	t.Helper()
-
-	resp, err := client.CreateUser(context.Background(), &api.CreateUserRequest{
-		Email:    email,
-		Password: "hello1234",
-	})
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	return resp
-}
-
-func TestLogin(t *testing.T) {
-	email := "login_test@example.com"
-	client := api.NewClient(baseURL, "cookie_login_test.json")
-	user := initUser(email, client, t)
-
-	resp, err := client.Login(context.Background(), &api.LoginRequest{
-		Email:    user.Email,
-		Password: "hello1234",
-	})
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	if resp.Email != user.Email {
-		t.Errorf("incorrect email: expected %s, got %s\n", user.Email, resp.Email)
-	}
-}
-
-func TestLogout(t *testing.T) {
-	email := "logout_test@example.com"
-	client := api.NewClient(baseURL, "cookie_logout_test.json")
-	user := initUser(email, client, t)
-
-	client.Login(context.Background(), &api.LoginRequest{
-		Email:    user.Email,
-		Password: "hello1234",
-	})
-
-	err := client.Logout(context.Background())
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// try to get the user and ensure 403
-	_, err = client.AuthCheck(context.Background())
-
-	if err != nil && !strings.Contains(err.Error(), "403") {
-		t.Fatalf("%v\n", err)
-	}
-}
-
-func TestAuthCheck(t *testing.T) {
-	email := "auth_check_test@example.com"
-	client := api.NewClient(baseURL, "cookie_auth_check_test.json")
-	user := initUser(email, client, t)
-	client.Login(context.Background(), &api.LoginRequest{
-		Email:    user.Email,
-		Password: "hello1234",
-	})
-
-	resp, err := client.AuthCheck(context.Background())
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	if resp.Email != user.Email {
-		t.Errorf("incorrect email: expected %s, got %s\n", user.Email, resp.Email)
-	}
-}
-
-func TestGetUser(t *testing.T) {
-	email := "get_user_test@example.com"
-	client := api.NewClient(baseURL, "cookie_get_user_test.json")
-	user := initUser(email, client, t)
-
-	resp, err := client.GetUser(context.Background(), user.ID)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	if resp.Email != user.Email {
-		t.Errorf("incorrect email: expected %s, got %s\n", user.Email, resp.Email)
-	}
-}
-
-func TestListUserProjects(t *testing.T) {
-	email := "list_user_projects@example.com"
-	client := api.NewClient(baseURL, "cookie_list_user_projects.json")
-	user := initUser(email, client, t)
-	client.Login(context.Background(), &api.LoginRequest{
-		Email:    user.Email,
-		Password: "hello1234",
-	})
-	project := initProject("project-test", client, t)
-
-	projects, err := client.ListUserProjects(context.Background(), user.ID)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	if len(projects) != 1 {
-		t.Fatalf("length of projects is not 1")
-	}
-
-	resp := projects[0]
-
-	// make sure user is admin and project name is correct
-	if resp.Name != project.Name {
-		t.Errorf("project name incorrect: expected %s, got %s\n", project.Name, resp.Name)
-	}
-
-	if len(resp.Roles) != 1 {
-		t.Fatalf("project role length is not 1")
-	}
-
-	if resp.Roles[0].Kind != models.RoleAdmin {
-		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
-	}
-
-	if resp.Roles[0].UserID != user.ID {
-		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
-	}
-}
-
-func TestDeleteUser(t *testing.T) {
-	email := "delete_user_test@example.com"
-	client := api.NewClient(baseURL, "cookie_delete_user_test.json")
-	user := initUser(email, client, t)
-
-	err := client.DeleteUser(context.Background(), user.ID, &api.DeleteUserRequest{
-		Password: "hello1234",
-	})
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	_, err = client.GetUser(context.Background(), user.ID)
-
-	if err != nil && !strings.Contains(err.Error(), "could not find requested object") {
-		t.Fatalf("%v\n", err)
-	}
-}

+ 6 - 3
cmd/migrate/keyrotate/helpers_test.go

@@ -5,6 +5,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/models"
@@ -138,9 +139,11 @@ func initProjectRole(tester *tester, t *testing.T) {
 	t.Helper()
 
 	role := &models.Role{
-		Kind:      models.RoleAdmin,
-		UserID:    tester.initUsers[0].Model.ID,
-		ProjectID: tester.initProjects[0].Model.ID,
+		Role: types.Role{
+			Kind:      types.RoleAdmin,
+			UserID:    tester.initUsers[0].Model.ID,
+			ProjectID: tester.initProjects[0].Model.ID,
+		},
 	}
 
 	role, err := tester.repo.Project.CreateProjectRole(tester.initProjects[0], role)

+ 1 - 1
internal/auth/sessionstore/sessionstore_test.go

@@ -9,7 +9,7 @@ import (
 
 	"github.com/gorilla/securecookie"
 	"github.com/gorilla/sessions"
-	test "github.com/porter-dev/porter/internal/repository/memory"
+	"github.com/porter-dev/porter/internal/repository/test"
 
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 )

+ 6 - 3
internal/forms/helper_test.go

@@ -4,6 +4,7 @@ import (
 	"os"
 	"testing"
 
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/models"
@@ -118,9 +119,11 @@ func initProjectRole(tester *tester, t *testing.T) {
 	t.Helper()
 
 	role := &models.Role{
-		Kind:      models.RoleAdmin,
-		UserID:    tester.initUsers[0].Model.ID,
-		ProjectID: tester.initProjects[0].Model.ID,
+		Role: types.Role{
+			Kind:      types.RoleAdmin,
+			UserID:    tester.initUsers[0].Model.ID,
+			ProjectID: tester.initProjects[0].Model.ID,
+		},
 	}
 
 	role, err := tester.repo.Project.CreateProjectRole(tester.initProjects[0], role)

+ 10 - 18
internal/models/role.go

@@ -1,38 +1,30 @@
 package models
 
 import (
+	"github.com/porter-dev/porter/api/types"
 	"gorm.io/gorm"
 )
 
-// The roles available for a project
-const (
-	RoleAdmin  string = "admin"
-	RoleViewer string = "viewer"
-)
-
 // Role type that extends gorm.Model
 type Role struct {
 	gorm.Model
-
-	Kind      string `json:"kind"`
-	UserID    uint   `json:"user_id"`
-	ProjectID uint   `json:"project_id"`
+	types.Role
 }
 
 // RoleExternal represents the Role type that is sent over REST
 type RoleExternal struct {
-	ID        uint   `json:"id"`
-	Kind      string `json:"kind"`
-	UserID    uint   `json:"user_id"`
-	ProjectID uint   `json:"project_id"`
+	types.Role
+	ID uint `json:"id"`
 }
 
 // Externalize generates an external Role to be shared over REST
 func (r *Role) Externalize() *RoleExternal {
 	return &RoleExternal{
-		ID:        r.ID,
-		Kind:      r.Kind,
-		UserID:    r.UserID,
-		ProjectID: r.ProjectID,
+		ID: r.ID,
+		Role: types.Role{
+			Kind:      r.Kind,
+			UserID:    r.UserID,
+			ProjectID: r.ProjectID,
+		},
 	}
 }

+ 1 - 1
internal/models/session.go

@@ -3,7 +3,7 @@ package models
 import (
 	"time"
 
-	"github.com/jinzhu/gorm"
+	"gorm.io/gorm"
 )
 
 // Session type that extends gorm.Model.

+ 6 - 3
internal/repository/gorm/helpers_test.go

@@ -5,6 +5,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/models"
@@ -133,9 +134,11 @@ func initProjectRole(tester *tester, t *testing.T) {
 	t.Helper()
 
 	role := &models.Role{
-		Kind:      models.RoleAdmin,
-		UserID:    tester.initUsers[0].Model.ID,
-		ProjectID: tester.initProjects[0].Model.ID,
+		Role: types.Role{
+			Kind:      types.RoleAdmin,
+			UserID:    tester.initUsers[0].Model.ID,
+			ProjectID: tester.initProjects[0].Model.ID,
+		},
 	}
 
 	role, err := tester.repo.Project.CreateProjectRole(tester.initProjects[0], role)

+ 11 - 0
internal/repository/gorm/project.go

@@ -52,6 +52,17 @@ func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 	return project, nil
 }
 
+// ReadProjectRole gets a role for a project specified by a user and project ID
+func (repo *ProjectRepository) ReadProjectRole(userID, projID uint) (*models.Role, error) {
+	role := &models.Role{}
+
+	if err := repo.db.Where("user_id = ? AND project_id = ?", userID, projID).First(&role).Error; err != nil {
+		return nil, err
+	}
+
+	return role, nil
+}
+
 // ListProjectsByUserID lists projects where a user has an associated role
 func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Project, error) {
 	projects := make([]*models.Project, 0)

+ 55 - 11
internal/repository/gorm/project_test.go

@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 
 	"gorm.io/gorm"
@@ -54,9 +55,11 @@ func TestCreateProjectRole(t *testing.T) {
 	defer cleanup(tester, t)
 
 	role := &models.Role{
-		Kind:      models.RoleAdmin,
-		UserID:    0,
-		ProjectID: tester.initProjects[0].Model.ID,
+		Role: types.Role{
+			Kind:      types.RoleAdmin,
+			UserID:    0,
+			ProjectID: tester.initProjects[0].Model.ID,
+		},
 	}
 
 	role, err := tester.repo.Project.CreateProjectRole(tester.initProjects[0], role)
@@ -89,9 +92,11 @@ func TestCreateProjectRole(t *testing.T) {
 		Name: "project-test",
 		Roles: []models.Role{
 			{
-				Kind:      models.RoleAdmin,
-				UserID:    0,
-				ProjectID: 1,
+				Role: types.Role{
+					Kind:      types.RoleAdmin,
+					UserID:    0,
+					ProjectID: 1,
+				},
 			},
 		},
 	}
@@ -121,8 +126,10 @@ func TestListProjectsByUserID(t *testing.T) {
 	initProject(tester, t)
 
 	role := &models.Role{
-		Kind:   models.RoleAdmin,
-		UserID: 1,
+		Role: types.Role{
+			Kind:   types.RoleAdmin,
+			UserID: 1,
+		},
 	}
 
 	role, err := tester.repo.Project.CreateProjectRole(tester.initProjects[1], role)
@@ -149,9 +156,11 @@ func TestListProjectsByUserID(t *testing.T) {
 			Name: "project-test",
 			Roles: []models.Role{
 				{
-					Kind:      models.RoleAdmin,
-					UserID:    tester.initUsers[0].Model.ID,
-					ProjectID: uint(i + 1),
+					Role: types.Role{
+						Kind:      types.RoleAdmin,
+						UserID:    tester.initUsers[0].Model.ID,
+						ProjectID: uint(i + 1),
+					},
 				},
 			},
 		}
@@ -167,7 +176,42 @@ func TestListProjectsByUserID(t *testing.T) {
 			t.Error(diff)
 		}
 	}
+}
+
+func TestReadProjectRole(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./get_project_role.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+
+	// create two projects, same name
+	initProject(tester, t)
+	initProjectRole(tester, t)
 
+	defer cleanup(tester, t)
+
+	role, err := tester.repo.Project.ReadProjectRole(1, 1)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	role.Model = gorm.Model{}
+
+	expRole := &models.Role{
+		Role: types.Role{
+			Kind:      types.RoleAdmin,
+			UserID:    1,
+			ProjectID: 1,
+		},
+	}
+
+	if diff := deep.Equal(role, expRole); diff != nil {
+		t.Errorf("incorrect role")
+		t.Error(diff)
+	}
 }
 
 func TestDeleteProject(t *testing.T) {

+ 0 - 2
internal/repository/gorm/session_test.go

@@ -31,9 +31,7 @@ func (s *Suite) SetupSuite() {
 		err error
 	)
 
-	// TODO: make it work with gorm.io/gorm, currently only works with jinzhu/gorm (gorm V1)
 	db, s.mock, err = sqlmock.New()
-	// db, s.mock, err = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
 
 	require.NoError(s.T(), err)
 

+ 1 - 0
internal/repository/project.go

@@ -12,6 +12,7 @@ type ProjectRepository interface {
 	CreateProject(project *models.Project) (*models.Project, error)
 	CreateProjectRole(project *models.Project, role *models.Role) (*models.Role, error)
 	ReadProject(id uint) (*models.Project, error)
+	ReadProjectRole(userID, projID uint) (*models.Role, error)
 	ListProjectsByUserID(userID uint) ([]*models.Project, error)
 	DeleteProject(project *models.Project) (*models.Project, error)
 }

+ 0 - 0
internal/repository/memory/auth.go → internal/repository/test/auth.go


+ 0 - 0
internal/repository/memory/auth_code.go → internal/repository/test/auth_code.go


+ 0 - 0
internal/repository/memory/cluster.go → internal/repository/test/cluster.go


+ 0 - 0
internal/repository/memory/dns_record.go → internal/repository/test/dns_record.go


+ 0 - 0
internal/repository/memory/gitrepo.go → internal/repository/test/gitrepo.go


+ 0 - 0
internal/repository/memory/helm_repo.go → internal/repository/test/helm_repo.go


+ 0 - 0
internal/repository/memory/infra.go → internal/repository/test/infra.go


+ 0 - 0
internal/repository/memory/invite.go → internal/repository/test/invite.go


+ 22 - 0
internal/repository/memory/project.go → internal/repository/test/project.go

@@ -51,6 +51,28 @@ func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *
 	return role, nil
 }
 
+// ReadProject gets a projects specified by a unique id
+func (repo *ProjectRepository) ReadProjectRole(userID, projID uint) (*models.Role, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(projID-1) >= len(repo.projects) || repo.projects[projID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	// find role in project roles
+	index := int(projID - 1)
+
+	for _, role := range repo.projects[index].Roles {
+		if role.UserID == userID {
+			return &role, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
 // ReadProject gets a projects specified by a unique id
 func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 	if !repo.canQuery {

+ 0 - 0
internal/repository/memory/pw_reset_token.go → internal/repository/test/pw_reset_token.go


+ 0 - 0
internal/repository/memory/registry.go → internal/repository/test/registry.go


+ 0 - 0
internal/repository/memory/release.go → internal/repository/test/release.go


+ 0 - 0
internal/repository/memory/repository.go → internal/repository/test/repository.go


+ 0 - 0
internal/repository/memory/session.go → internal/repository/test/session.go


+ 0 - 0
internal/repository/memory/user.go → internal/repository/test/user.go


+ 2 - 2
server/api/api.go

@@ -19,7 +19,7 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes"
 	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository"
-	memory "github.com/porter-dev/porter/internal/repository/memory"
+	"github.com/porter-dev/porter/internal/repository/test"
 	"github.com/porter-dev/porter/internal/validator"
 	segment "gopkg.in/segmentio/analytics-go.v3"
 	"helm.sh/helm/v3/pkg/storage"
@@ -129,7 +129,7 @@ func New(conf *AppConfig) (*App, error) {
 
 	// if repository not specified, default to in-memory
 	if app.Repo == nil {
-		app.Repo = memory.NewRepository(true)
+		app.Repo = test.NewRepository(true)
 	}
 
 	// create the session store

+ 2 - 2
server/api/helpers_test.go

@@ -12,7 +12,7 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes"
 	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository"
-	memory "github.com/porter-dev/porter/internal/repository/memory"
+	"github.com/porter-dev/porter/internal/repository/test"
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/router"
 
@@ -82,7 +82,7 @@ func newTester(canQuery bool) *tester {
 	}
 
 	logger := lr.NewConsole(appConf.Debug)
-	repo := memory.NewRepository(canQuery)
+	repo := test.NewRepository(canQuery)
 	store, _ := sessionstore.NewStore(repo, appConf.Server)
 	k8sAgent := kubernetes.GetAgentTesting()
 

+ 6 - 3
server/api/invite_handler.go

@@ -8,6 +8,7 @@ import (
 	"strconv"
 
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/integrations/email"
 	"github.com/porter-dev/porter/internal/models"
@@ -169,9 +170,11 @@ func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 
 	// create a new Role with the user as the admin
 	_, err = app.Repo.Project.CreateProjectRole(projModel, &models.Role{
-		UserID:    userID,
-		ProjectID: uint(projID),
-		Kind:      models.RoleAdmin,
+		Role: types.Role{
+			UserID:    userID,
+			ProjectID: uint(projID),
+			Kind:      types.RoleAdmin,
+		},
 	})
 
 	if err != nil {

+ 6 - 3
server/api/project_handler.go

@@ -6,6 +6,7 @@ import (
 	"strconv"
 
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
 )
@@ -61,9 +62,11 @@ func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
 
 	// create a new Role with the user as the admin
 	_, err = app.Repo.Project.CreateProjectRole(projModel, &models.Role{
-		UserID:    userID,
-		ProjectID: projModel.ID,
-		Kind:      models.RoleAdmin,
+		Role: types.Role{
+			UserID:    userID,
+			ProjectID: projModel.ID,
+			Kind:      types.RoleAdmin,
+		},
 	})
 
 	if err != nil {

+ 6 - 3
server/api/project_handler_test.go

@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 )
 
@@ -154,9 +155,11 @@ func initProject(tester *tester) {
 
 	// create a new Role with the user as the owner
 	tester.repo.Project.CreateProjectRole(projModel, &models.Role{
-		UserID:    user.ID,
-		ProjectID: projModel.ID,
-		Kind:      models.RoleAdmin,
+		Role: types.Role{
+			UserID:    user.ID,
+			ProjectID: projModel.ID,
+			Kind:      types.RoleAdmin,
+		},
 	})
 }
 

+ 2 - 2
server/middleware/auth.go

@@ -12,8 +12,8 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/gorilla/sessions"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/auth/token"
-	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 )
 
@@ -225,7 +225,7 @@ func (auth *Auth) DoesUserHaveProjectAccess(
 					next.ServeHTTP(w, r)
 					return
 				} else if accessType == WriteAccess {
-					if role.Kind == models.RoleAdmin {
+					if role.Kind == types.RoleAdmin {
 						next.ServeHTTP(w, r)
 						return
 					}