소스 검색

Merge pull request #1981 from porter-dev/nafees/api-v1

[POR-449] Initial changes for public API v1
abelanger5 4 년 전
부모
커밋
25f853d522
70개의 변경된 파일4156개의 추가작업 그리고 434개의 파일을 삭제
  1. 38 7
      api/server/authn/handler.go
  2. 21 5
      api/server/authz/policy.go
  3. 117 58
      api/server/authz/policy/loader.go
  4. 18 9
      api/server/authz/policy/loader_test.go
  5. 11 1
      api/server/authz/policy/policy.go
  6. 3 3
      api/server/authz/policy/policy_test.go
  7. 4 4
      api/server/authz/policy_test.go
  8. 118 0
      api/server/handlers/api_token/create.go
  9. 72 0
      api/server/handlers/api_token/get.go
  10. 51 0
      api/server/handlers/api_token/list.go
  11. 73 0
      api/server/handlers/api_token/revoke.go
  12. 1 0
      api/server/handlers/cluster/create_namespace.go
  13. 11 4
      api/server/handlers/cluster/delete_namespace.go
  14. 61 0
      api/server/handlers/cluster/get_namespace.go
  15. 4 0
      api/server/handlers/namespace/list_releases.go
  16. 89 0
      api/server/handlers/policy/create.go
  17. 66 0
      api/server/handlers/policy/get.go
  18. 45 0
      api/server/handlers/policy/list.go
  19. 5 2
      api/server/handlers/project/get_policy.go
  20. 124 10
      api/server/handlers/release/create.go
  21. 2 0
      api/server/handlers/release/get_gha_template.go
  22. 28 27
      api/server/router/base.go
  23. 46 45
      api/server/router/cluster.go
  24. 24 23
      api/server/router/git_installation.go
  25. 10 9
      api/server/router/helm_repo.go
  26. 20 19
      api/server/router/infra.go
  27. 12 11
      api/server/router/invite.go
  28. 28 27
      api/server/router/namespace.go
  29. 8 7
      api/server/router/oauth_callback.go
  30. 247 41
      api/server/router/project.go
  31. 17 16
      api/server/router/project_integration.go
  32. 9 8
      api/server/router/project_oauth.go
  33. 13 12
      api/server/router/registry.go
  34. 32 31
      api/server/router/release.go
  35. 39 22
      api/server/router/router.go
  36. 10 9
      api/server/router/slack_integration.go
  37. 23 22
      api/server/router/user.go
  38. 172 0
      api/server/router/v1/cluster.go
  39. 56 0
      api/server/router/v1/namespace.go
  40. 56 0
      api/server/router/v1/project.go
  41. 259 0
      api/server/router/v1/registry.go
  42. 212 0
      api/server/router/v1/release.go
  43. 25 0
      api/server/shared/apierrors/errors.go
  44. 28 0
      api/server/shared/router/router.go
  45. 28 0
      api/types/api_token.go
  46. 88 0
      api/types/policy.go
  47. 1 0
      api/types/project.go
  48. 322 0
      dashboard/src/main/home/project-settings/APITokensSection.tsx
  49. 11 0
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  50. 592 0
      dashboard/src/main/home/project-settings/api-tokens/CreateAPITokenForm.tsx
  51. 135 0
      dashboard/src/main/home/project-settings/api-tokens/CustomPolicyForm.tsx
  52. 228 0
      dashboard/src/main/home/project-settings/api-tokens/TokenList.tsx
  53. 39 0
      dashboard/src/shared/api.tsx
  54. 3 0
      dashboard/src/shared/auth/types.ts
  55. 1 0
      dashboard/src/shared/types.tsx
  56. 44 2
      internal/auth/token/token.go
  57. 15 0
      internal/kubernetes/agent.go
  58. 49 0
      internal/models/api_token.go
  59. 44 0
      internal/models/policy.go
  60. 2 0
      internal/models/project.go
  61. 13 0
      internal/repository/api_token.go
  62. 55 0
      internal/repository/gorm/api_token.go
  63. 2 0
      internal/repository/gorm/migrate.go
  64. 64 0
      internal/repository/gorm/policy.go
  65. 12 0
      internal/repository/gorm/repository.go
  66. 14 0
      internal/repository/policy.go
  67. 2 0
      internal/repository/repository.go
  68. 32 0
      internal/repository/test/api_token.go
  69. 40 0
      internal/repository/test/policy.go
  70. 12 0
      internal/repository/test/repository.go

+ 38 - 7
api/server/authn/handler.go

@@ -11,6 +11,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/models"
 )
 
 // AuthNFactory generates a middleware handler `AuthN`
@@ -62,7 +63,7 @@ func (authn *AuthN) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		authn.sendForbiddenError(err, w, r)
 		return
 	} else if err == nil && tok != nil {
-		authn.nextWithToken(w, r, tok)
+		authn.verifyTokenWithNext(w, r, tok)
 		return
 	}
 
@@ -120,13 +121,43 @@ func (authn *AuthN) handleForbiddenForSession(
 	return
 }
 
-// nextWithToken calls the next handler with either the service account or user corresponding
-// to the token set in context.
-func (authn *AuthN) nextWithToken(w http.ResponseWriter, r *http.Request, tok *token.Token) {
-	// TODO: add section to get service account for server-side token
+func (authn *AuthN) verifyTokenWithNext(w http.ResponseWriter, r *http.Request, tok *token.Token) {
+	// if the token has a stored token id and secret we check that the token is valid in the database
+	if tok.Secret != "" && tok.TokenID != "" {
+		apiToken, err := authn.config.Repo.APIToken().ReadAPIToken(tok.ProjectID, tok.TokenID)
 
-	// for now, we just use nextWithUser using the `iby` field for the token
-	authn.nextWithUserID(w, r, tok.IBy)
+		if err != nil {
+			authn.sendForbiddenError(fmt.Errorf("token with id %s not valid", tok.TokenID), w, r)
+			return
+		}
+
+		// first ensure that the token hasn't been revoked, and the token has not expired
+		if apiToken.Revoked || apiToken.IsExpired() {
+			authn.sendForbiddenError(fmt.Errorf("token with id %s not valid", tok.TokenID), w, r)
+			return
+		}
+
+		authn.nextWithAPIToken(w, r, apiToken)
+	} else {
+		// otherwise we just use nextWithUser using the `iby` field for the token
+		authn.nextWithUserID(w, r, tok.IBy)
+	}
+}
+
+// nextWithAPIToken sets the token in context
+func (authn *AuthN) nextWithAPIToken(w http.ResponseWriter, r *http.Request, tok *models.APIToken) {
+	ctx := r.Context()
+	ctx = context.WithValue(ctx, "api_token", tok)
+
+	// add a service account user to the project: note that any calls depending on a DB lookup for the
+	// user will fail
+	ctx = context.WithValue(ctx, types.UserScope, &models.User{
+		Email:         fmt.Sprintf("%s-%d", tok.Name, tok.ProjectID),
+		EmailVerified: true,
+	})
+
+	r = r.Clone(ctx)
+	authn.next.ServeHTTP(w, r)
 }
 
 // nextWithUserID calls the next handler with the user set in the context with key

+ 21 - 5
api/server/authz/policy.go

@@ -47,11 +47,27 @@ func (h *PolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// load policy documents for the user + project
-	projID := reqScopes[types.ProjectScope].Resource.UInt
-	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	policyLoaderOpts := &policy.PolicyLoaderOpts{}
+
+	// first check if an api token exists in context
+	if r.Context().Value("api_token") != nil {
+		projID := reqScopes[types.ProjectScope].Resource.UInt
+
+		// FIXME: find a clean way to get the project
+
+		apiToken, _ := r.Context().Value("api_token").(*models.APIToken)
+		policyLoaderOpts.ProjectToken = apiToken
+		policyLoaderOpts.ProjectID = projID
+	} else {
+		projID := reqScopes[types.ProjectScope].Resource.UInt
+		user, _ := r.Context().Value(types.UserScope).(*models.User)
 
-	policyDocs, reqErr := h.loader.LoadPolicyDocuments(user.ID, projID)
+		policyLoaderOpts.ProjectID = projID
+		policyLoaderOpts.UserID = user.ID
+	}
+
+	// load policy documents for the user + project
+	policyDocs, reqErr := h.loader.LoadPolicyDocuments(policyLoaderOpts)
 
 	if reqErr != nil {
 		apierrors.HandleAPIError(h.config.Logger, h.config.Alerter, w, r, reqErr, true)
@@ -67,7 +83,7 @@ func (h *PolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			h.config.Alerter,
 			w,
 			r,
-			apierrors.NewErrForbidden(fmt.Errorf("policy forbids action for user %d in project %d", user.ID, projID)),
+			apierrors.NewErrForbidden(fmt.Errorf("policy forbids action in project %d", policyLoaderOpts.ProjectID)),
 			true,
 		)
 

+ 117 - 58
api/server/authz/policy/loader.go

@@ -1,85 +1,144 @@
 package policy
 
 import (
+	"errors"
 	"fmt"
+	"net/http"
 
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 )
 
+type PolicyLoaderOpts struct {
+	ProjectID, UserID uint
+	ProjectToken      *models.APIToken
+}
+
 type PolicyDocumentLoader interface {
-	LoadPolicyDocuments(userID, projectID uint) ([]*types.PolicyDocument, apierrors.RequestError)
+	LoadPolicyDocuments(opts *PolicyLoaderOpts) ([]*types.PolicyDocument, apierrors.RequestError)
 }
 
-// BasicPolicyDocumentLoader loads policy documents simply depending on the
-type BasicPolicyDocumentLoader struct {
-	projRepo repository.ProjectRepository
+// RepoPolicyDocumentLoader loads policy documents by reading from the repository database
+type RepoPolicyDocumentLoader struct {
+	projRepo   repository.ProjectRepository
+	policyRepo repository.PolicyRepository
 }
 
-func NewBasicPolicyDocumentLoader(projRepo repository.ProjectRepository) *BasicPolicyDocumentLoader {
-	return &BasicPolicyDocumentLoader{projRepo}
+func NewBasicPolicyDocumentLoader(projRepo repository.ProjectRepository, policyRepo repository.PolicyRepository) *RepoPolicyDocumentLoader {
+	return &RepoPolicyDocumentLoader{projRepo, policyRepo}
 }
 
-func (b *BasicPolicyDocumentLoader) LoadPolicyDocuments(
-	userID, projectID uint,
+func (b *RepoPolicyDocumentLoader) LoadPolicyDocuments(
+	opts *PolicyLoaderOpts,
 ) ([]*types.PolicyDocument, apierrors.RequestError) {
-	// read role and case on role "kind"
-	role, err := b.projRepo.ReadProjectRole(projectID, userID)
-
-	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)
-	}
+	if opts.ProjectToken != nil {
+		// check that the token belongs to the project, in this case it's solely project-scoped
+		if opts.ProjectID == 0 || opts.ProjectToken.ProjectID == 0 || opts.ProjectID != opts.ProjectToken.ProjectID {
+			return nil, apierrors.NewErrForbidden(fmt.Errorf("project id %d does not match token id %d", opts.ProjectID, opts.ProjectToken.ProjectID))
+		}
 
-	// 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),
-		)
+		proj, err := b.projRepo.ReadProject(opts.ProjectID)
+
+		if err != nil {
+			return nil, apierrors.NewErrForbidden(fmt.Errorf("error fetching project: %w", err))
+		}
+
+		if !proj.APITokensEnabled {
+			return nil, apierrors.NewErrForbidden(fmt.Errorf("api tokens are not enabled for this project"))
+		}
+
+		// load the policy
+		apiPolicy, reqErr := GetAPIPolicyFromUID(b.policyRepo, opts.ProjectToken.ProjectID, opts.ProjectToken.PolicyUID)
+
+		if reqErr != nil {
+			return nil, reqErr
+		}
+
+		return apiPolicy.Policy, nil
+	} else if opts.ProjectID != 0 && opts.UserID != 0 {
+		userID := opts.UserID
+		projectID := opts.ProjectID
+		// read role and case on role "kind"
+		role, err := b.projRepo.ReadProjectRole(projectID, userID)
+
+		if err != nil && err == gorm.ErrRecordNotFound {
+			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 types.AdminPolicy, nil
+		case types.RoleDeveloper:
+			return types.DeveloperPolicy, nil
+		case types.RoleViewer:
+			return types.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(),
-	},
+	return nil, apierrors.NewErrForbidden(
+		fmt.Errorf("policy loader called with invalid arguments"),
+	)
 }
 
-var DeveloperPolicy = []*types.PolicyDocument{
-	{
-		Scope: types.ProjectScope,
-		Verbs: types.ReadWriteVerbGroup(),
-		Children: map[types.PermissionScope]*types.PolicyDocument{
-			types.SettingsScope: {
-				Scope: types.SettingsScope,
-				Verbs: types.ReadVerbGroup(),
+func GetAPIPolicyFromUID(policyRepo repository.PolicyRepository, projectID uint, uid string) (*types.APIPolicy, apierrors.RequestError) {
+	switch uid {
+	case "admin":
+		return &types.APIPolicy{
+			APIPolicyMeta: &types.APIPolicyMeta{
+				Name: "admin",
+				UID:  "admin",
 			},
-		},
-	},
-}
-
-var ViewerPolicy = []*types.PolicyDocument{
-	{
-		Scope: types.ProjectScope,
-		Verbs: types.ReadVerbGroup(),
-		Children: map[types.PermissionScope]*types.PolicyDocument{
-			types.SettingsScope: {
-				Scope: types.SettingsScope,
-				Verbs: []types.APIVerb{},
+			Policy: types.AdminPolicy,
+		}, nil
+	case "developer":
+		return &types.APIPolicy{
+			APIPolicyMeta: &types.APIPolicyMeta{
+				Name: "developer",
+				UID:  "developer",
 			},
-		},
-	},
+			Policy: types.DeveloperPolicy,
+		}, nil
+	case "viewer":
+		return &types.APIPolicy{
+			APIPolicyMeta: &types.APIPolicyMeta{
+				Name: "viewer",
+				UID:  "viewer",
+			},
+			Policy: types.ViewerPolicy,
+		}, nil
+	default:
+		// look up the policy and make sure it exists
+		policyModel, err := policyRepo.ReadPolicy(projectID, uid)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				return nil, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("policy not found in project"),
+					http.StatusBadRequest,
+				)
+			}
+
+			return nil, apierrors.NewErrInternal(err)
+		}
+
+		apiPolicy, err := policyModel.ToAPIPolicyType()
+
+		if err != nil {
+			return nil, apierrors.NewErrInternal(err)
+		}
+
+		return apiPolicy, nil
+	}
 }

+ 18 - 9
api/server/authz/policy/loader_test.go

@@ -26,17 +26,17 @@ var basicLoaderTests = []basicLoaderTest{
 	{
 		description: "should load admin policy",
 		roleKind:    types.RoleAdmin,
-		expPolicy:   policy.AdminPolicy,
+		expPolicy:   types.AdminPolicy,
 	},
 	{
 		description: "should load developer policy",
 		roleKind:    types.RoleDeveloper,
-		expPolicy:   policy.DeveloperPolicy,
+		expPolicy:   types.DeveloperPolicy,
 	},
 	{
 		description: "should load viewer policy",
 		roleKind:    types.RoleViewer,
-		expPolicy:   policy.ViewerPolicy,
+		expPolicy:   types.ViewerPolicy,
 	},
 	{
 		description:      "should not load custom policy for basic loader",
@@ -53,7 +53,7 @@ func TestBasicPolicyDocumentLoader(t *testing.T) {
 	for _, basicTest := range basicLoaderTests {
 		// use the in-memory project repo
 		projRepo := test.NewProjectRepository(true)
-		loader := policy.NewBasicPolicyDocumentLoader(projRepo)
+		loader := policy.NewBasicPolicyDocumentLoader(projRepo, nil)
 
 		project := &models.Project{
 			Name: "test-project",
@@ -79,7 +79,10 @@ func TestBasicPolicyDocumentLoader(t *testing.T) {
 			t.Fatalf("%v", err)
 		}
 
-		docs, reqErr := loader.LoadPolicyDocuments(1, 1)
+		docs, reqErr := loader.LoadPolicyDocuments(&policy.PolicyLoaderOpts{
+			ProjectID: 1,
+			UserID:    1,
+		})
 
 		assert.Equal(
 			reqErr != nil,
@@ -123,7 +126,7 @@ func TestErrorForbiddenInvalidRole(t *testing.T) {
 
 	// use the in-memory project repo
 	projRepo := test.NewProjectRepository(true)
-	loader := policy.NewBasicPolicyDocumentLoader(projRepo)
+	loader := policy.NewBasicPolicyDocumentLoader(projRepo, nil)
 
 	project := &models.Project{
 		Name: "test-project",
@@ -149,7 +152,10 @@ func TestErrorForbiddenInvalidRole(t *testing.T) {
 		t.Fatalf("%v", err)
 	}
 
-	_, reqErr := loader.LoadPolicyDocuments(2, 1)
+	_, reqErr := loader.LoadPolicyDocuments(&policy.PolicyLoaderOpts{
+		ProjectID: 1,
+		UserID:    2,
+	})
 
 	if reqErr == nil {
 		t.Fatalf("Expected forbidden error for invalid project role")
@@ -174,9 +180,12 @@ func TestErrorCannotQuery(t *testing.T) {
 
 	// use the in-memory project repo
 	projRepo := test.NewProjectRepository(false)
-	loader := policy.NewBasicPolicyDocumentLoader(projRepo)
+	loader := policy.NewBasicPolicyDocumentLoader(projRepo, nil)
 
-	_, reqErr := loader.LoadPolicyDocuments(2, 1)
+	_, reqErr := loader.LoadPolicyDocuments(&policy.PolicyLoaderOpts{
+		ProjectID: 2,
+		UserID:    1,
+	})
 
 	if reqErr == nil {
 		t.Fatalf("Expected internal error for failing to query")

+ 11 - 1
api/server/authz/policy/policy.go

@@ -117,6 +117,16 @@ func populateAndVerifyPolicyDocument(
 
 	processedChildren := 0
 
+	// by default, we pass the parent's verbs to the child.
+	passedParentVerbs := currDoc.Verbs
+
+	// however, if the current scope is a project scope, we don't pass the parent's verbs to
+	// the child. This is to avoid additional verbs being added later as a child of the ProjectScope,
+	// which would unintentionally grant permission to future-added scopes.
+	if currScope == types.ProjectScope {
+		passedParentVerbs = []types.APIVerb{}
+	}
+
 	for currScope := range subTree {
 		if _, exists := currDoc.Children[currScope]; exists {
 			processedChildren++
@@ -126,7 +136,7 @@ func populateAndVerifyPolicyDocument(
 			currDoc.Children[currScope],
 			subTree,
 			currScope,
-			currDoc.Verbs,
+			passedParentVerbs,
 			reqScopes,
 			matchDocs,
 		)

+ 3 - 3
api/server/authz/policy/policy_test.go

@@ -18,7 +18,7 @@ type testHasScopeAccess struct {
 var hasScopeAccessTests = []testHasScopeAccess{
 	{
 		description: "admin access to project",
-		policy:      policy.AdminPolicy,
+		policy:      types.AdminPolicy,
 		reqScopes: map[types.PermissionScope]*types.RequestAction{
 			types.ProjectScope: {
 				Verb: types.APIVerbGet,
@@ -31,7 +31,7 @@ var hasScopeAccessTests = []testHasScopeAccess{
 	},
 	{
 		description: "viewer access cannot perform write operation",
-		policy:      policy.ViewerPolicy,
+		policy:      types.ViewerPolicy,
 		reqScopes: map[types.PermissionScope]*types.RequestAction{
 			types.ClusterScope: {
 				Verb: types.APIVerbCreate,
@@ -44,7 +44,7 @@ var hasScopeAccessTests = []testHasScopeAccess{
 	},
 	{
 		description: "developer access cannot write settings",
-		policy:      policy.DeveloperPolicy,
+		policy:      types.DeveloperPolicy,
 		reqScopes: map[types.PermissionScope]*types.RequestAction{
 			types.SettingsScope: {
 				Verb: types.APIVerbUpdate,

+ 4 - 4
api/server/authz/policy_test.go

@@ -240,7 +240,7 @@ func loadHandlers(
 	shouldLoaderLoadViewer bool,
 ) (*config.Config, http.Handler, *testHandler) {
 	config := apitest.LoadConfig(t)
-	var loader policy.PolicyDocumentLoader = policy.NewBasicPolicyDocumentLoader(config.Repo.Project())
+	var loader policy.PolicyDocumentLoader = policy.NewBasicPolicyDocumentLoader(config.Repo.Project(), config.Repo.Policy())
 
 	if shouldLoaderFail {
 		loader = &failingDocLoader{}
@@ -260,14 +260,14 @@ func loadHandlers(
 
 type failingDocLoader struct{}
 
-func (f *failingDocLoader) LoadPolicyDocuments(userID, projectID uint) ([]*types.PolicyDocument, apierrors.RequestError) {
+func (f *failingDocLoader) LoadPolicyDocuments(opts *policy.PolicyLoaderOpts) ([]*types.PolicyDocument, apierrors.RequestError) {
 	return nil, apierrors.NewErrInternal(fmt.Errorf("new error internal"))
 }
 
 type viewerDocLoader struct{}
 
-func (f *viewerDocLoader) LoadPolicyDocuments(userID, projectID uint) ([]*types.PolicyDocument, apierrors.RequestError) {
-	return policy.ViewerPolicy, nil
+func (f *viewerDocLoader) LoadPolicyDocuments(opts *policy.PolicyLoaderOpts) ([]*types.PolicyDocument, apierrors.RequestError) {
+	return types.ViewerPolicy, nil
 }
 
 type testHandler struct {

+ 118 - 0
api/server/handlers/api_token/create.go

@@ -0,0 +1,118 @@
+package api_token
+
+import (
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/authz/policy"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/encryption"
+	"github.com/porter-dev/porter/internal/models"
+	"golang.org/x/crypto/bcrypt"
+)
+
+type APITokenCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewAPITokenCreateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *APITokenCreateHandler {
+	return &APITokenCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *APITokenCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	if !proj.APITokensEnabled {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("api token endpoints are not enabled for this project")))
+		return
+	}
+
+	req := &types.CreateAPIToken{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	// if the expiry time is not set, set the expiry to 1 year
+	if req.ExpiresAt.IsZero() {
+		req.ExpiresAt = time.Now().Add(time.Hour * 24 * 365)
+	}
+
+	apiPolicy, reqErr := policy.GetAPIPolicyFromUID(p.Repo().Policy(), proj.ID, req.PolicyUID)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	uid, err := encryption.GenerateRandomBytes(16)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	secretKey, err := encryption.GenerateRandomBytes(16)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// hash the secret key for storage in the db
+	hashedToken, err := bcrypt.GenerateFromPassword([]byte(secretKey), 8)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	apiToken := &models.APIToken{
+		UniqueID:        uid,
+		ProjectID:       proj.ID,
+		CreatedByUserID: user.ID,
+		Expiry:          &req.ExpiresAt,
+		Revoked:         false,
+		PolicyUID:       apiPolicy.UID,
+		PolicyName:      apiPolicy.Name,
+		Name:            req.Name,
+		SecretKey:       hashedToken,
+	}
+
+	apiToken, err = p.Repo().APIToken().CreateAPIToken(apiToken)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// generate porter jwt token
+	jwt, err := token.GetStoredTokenForAPI(user.ID, proj.ID, apiToken.UniqueID, secretKey)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	encoded, err := jwt.EncodeToken(p.Config().TokenConf)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, apiToken.ToAPITokenType(apiPolicy.Policy, encoded))
+}

+ 72 - 0
api/server/handlers/api_token/get.go

@@ -0,0 +1,72 @@
+package api_token
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz/policy"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type APITokenGetHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewAPITokenGetHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *APITokenGetHandler {
+	return &APITokenGetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *APITokenGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	if !proj.APITokensEnabled {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("api token endpoints are not enabled for this project")))
+		return
+	}
+
+	// get the token id from the request
+	tokenID, reqErr := requestutils.GetURLParamString(r, types.URLParamTokenID)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	token, err := p.Repo().APIToken().ReadAPIToken(proj.ID, tokenID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("token with id %s not found in project", tokenID),
+				http.StatusNotFound,
+			))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	apiPolicy, reqErr := policy.GetAPIPolicyFromUID(p.Repo().Policy(), proj.ID, token.PolicyUID)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	p.WriteResult(w, r, token.ToAPITokenType(apiPolicy.Policy, ""))
+}

+ 51 - 0
api/server/handlers/api_token/list.go

@@ -0,0 +1,51 @@
+package api_token
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type APITokenListHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewAPITokenListHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *APITokenListHandler {
+	return &APITokenListHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *APITokenListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	if !proj.APITokensEnabled {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("api token endpoints are not enabled for this project")))
+		return
+	}
+
+	tokens, err := p.Repo().APIToken().ListAPITokensByProjectID(proj.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	apiTokens := make([]*types.APITokenMeta, 0)
+
+	for _, tok := range tokens {
+		apiTokens = append(apiTokens, tok.ToAPITokenMetaType())
+	}
+
+	p.WriteResult(w, r, apiTokens)
+}

+ 73 - 0
api/server/handlers/api_token/revoke.go

@@ -0,0 +1,73 @@
+package api_token
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type APITokenRevokeHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewAPITokenRevokeHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *APITokenRevokeHandler {
+	return &APITokenRevokeHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *APITokenRevokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	if !proj.APITokensEnabled {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("api token endpoints are not enabled for this project")))
+		return
+	}
+
+	// get the token id from the request
+	tokenID, reqErr := requestutils.GetURLParamString(r, types.URLParamTokenID)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	token, err := p.Repo().APIToken().ReadAPIToken(proj.ID, tokenID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("token with id %s not found in project", tokenID),
+				http.StatusNotFound,
+			))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	token.Revoked = true
+
+	token, err = p.Repo().APIToken().UpdateAPIToken(token)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, token.ToAPITokenMetaType())
+}

+ 1 - 0
api/server/handlers/cluster/create_namespace.go

@@ -55,5 +55,6 @@ func (c *CreateNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		Namespace: namespace,
 	}
 
+	w.WriteHeader(http.StatusCreated)
 	c.WriteResult(w, r, res)
 }

+ 11 - 4
api/server/handlers/cluster/delete_namespace.go

@@ -8,6 +8,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 )
@@ -28,10 +29,16 @@ func NewDeleteNamespaceHandler(
 }
 
 func (c *DeleteNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	request := &types.DeleteNamespaceRequest{}
+	namespace, _ := requestutils.GetURLParamString(r, types.URLParamNamespace)
 
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
+	if namespace == "" {
+		request := &types.DeleteNamespaceRequest{}
+
+		if ok := c.DecodeAndValidate(w, r, request); !ok {
+			return
+		}
+
+		namespace = request.Name
 	}
 
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
@@ -43,7 +50,7 @@ func (c *DeleteNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	if err := agent.DeleteNamespace(request.Name); err != nil {
+	if err := agent.DeleteNamespace(namespace); err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 61 - 0
api/server/handlers/cluster/get_namespace.go

@@ -0,0 +1,61 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"k8s.io/apimachinery/pkg/api/errors"
+)
+
+type GetNamespaceHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetNamespaceHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetNamespaceHandler {
+	return &GetNamespaceHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace, reqErr := requestutils.GetURLParamString(r, types.URLParamNamespace)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+	}
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res, err := agent.GetNamespace(namespace)
+
+	if err != nil {
+		if errors.IsNotFound(err) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(err))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 4 - 0
api/server/handlers/namespace/list_releases.go

@@ -35,6 +35,10 @@ func (c *ListReleasesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	if request.ReleaseListFilter == nil {
+		request.ReleaseListFilter = &types.ReleaseListFilter{}
+	}
+
 	namespace := r.Context().Value(types.NamespaceScope).(string)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 

+ 89 - 0
api/server/handlers/policy/create.go

@@ -0,0 +1,89 @@
+package policy
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/encryption"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type PolicyCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewPolicyCreateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *PolicyCreateHandler {
+	return &PolicyCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *PolicyCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	req := &types.CreatePolicy{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	// policy can't be one of the preset policy names
+	if name := strings.ToLower(req.Name); name == "admin" || name == "developer" || name == "viewer" {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("name cannot be one of the preset policy names"),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	uid, err := encryption.GenerateRandomBytes(16)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	policyBytes, err := json.Marshal(req.Policy)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	policy := &models.Policy{
+		ProjectID:       proj.ID,
+		UniqueID:        uid,
+		CreatedByUserID: user.ID,
+		Name:            req.Name,
+		PolicyBytes:     policyBytes,
+	}
+
+	policy, err = p.Repo().Policy().CreatePolicy(policy)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res, err := policy.ToAPIPolicyType()
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 66 - 0
api/server/handlers/policy/get.go

@@ -0,0 +1,66 @@
+package policy
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type PolicyGetHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewPolicyGetHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *PolicyGetHandler {
+	return &PolicyGetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *PolicyGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// get the token id from the request
+	policyID, reqErr := requestutils.GetURLParamString(r, types.URLParamPolicyID)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	policy, err := p.Repo().Policy().ReadPolicy(proj.ID, policyID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("policy with id %s not found in project", policyID),
+				http.StatusNotFound,
+			))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res, err := policy.ToAPIPolicyType()
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 45 - 0
api/server/handlers/policy/list.go

@@ -0,0 +1,45 @@
+package policy
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type PolicyListHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewPolicyListHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *PolicyListHandler {
+	return &PolicyListHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *PolicyListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	policies, err := p.Repo().Policy().ListPoliciesByProjectID(proj.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make([]*types.APIPolicyMeta, 0)
+
+	for _, policy := range policies {
+		res = append(res, policy.ToAPIPolicyTypeMeta())
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 5 - 2
api/server/handlers/project/get_policy.go

@@ -30,9 +30,12 @@ func (p *ProjectGetPolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
-	policyDocLoader := policy.NewBasicPolicyDocumentLoader(p.Repo().Project())
+	policyDocLoader := policy.NewBasicPolicyDocumentLoader(p.Config().Repo.Project(), p.Config().Repo.Policy())
 
-	policyDocs, err := policyDocLoader.LoadPolicyDocuments(user.ID, proj.ID)
+	policyDocs, err := policyDocLoader.LoadPolicyDocuments(&policy.PolicyLoaderOpts{
+		UserID:    user.ID,
+		ProjectID: proj.ID,
+	})
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 124 - 10
api/server/handlers/release/create.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"net/http"
 	"strings"
+	"time"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -21,6 +22,7 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/registry"
+	"golang.org/x/crypto/bcrypt"
 	"gopkg.in/yaml.v2"
 	"helm.sh/helm/v3/pkg/release"
 	v1 "k8s.io/api/core/v1"
@@ -44,6 +46,7 @@ func NewCreateReleaseHandler(
 
 func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	namespace := r.Context().Value(types.NamespaceScope).(string)
 	operationID := oauth.CreateRandomState()
@@ -163,6 +166,7 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	if request.GithubActionConfig != nil {
 		_, _, err := createGitAction(
 			c.Config(),
+			proj,
 			user.ID,
 			cluster.ProjectID,
 			cluster.ID,
@@ -241,6 +245,7 @@ func createReleaseFromHelmRelease(
 
 func createGitAction(
 	config *config.Config,
+	project *models.Project,
 	userID, projectID, clusterID uint,
 	request *types.CreateGitActionConfigRequest,
 	name, namespace string,
@@ -269,23 +274,24 @@ func createGitAction(
 		}
 	}
 
+	isDryRun := release == nil
+
 	repoSplit := strings.Split(request.GitRepo, "/")
 
 	if len(repoSplit) != 2 {
 		return nil, nil, fmt.Errorf("invalid formatting of repo name")
 	}
 
-	// generate porter jwt token
-	jwt, err := token.GetTokenForAPI(userID, projectID)
-
-	if err != nil {
-		return nil, nil, err
-	}
+	encoded := ""
+	var err error
 
-	encoded, err := jwt.EncodeToken(config.TokenConf)
+	// if this isn't a dry run, generate the token
+	if !isDryRun {
+		encoded, err = getToken(config, project, userID, projectID, clusterID, request)
 
-	if err != nil {
-		return nil, nil, err
+		if err != nil {
+			return nil, nil, err
+		}
 	}
 
 	// create the commit in the git repo
@@ -310,7 +316,7 @@ func createGitAction(
 		PorterToken:            encoded,
 		Version:                "v0.1.0",
 		ShouldCreateWorkflow:   request.ShouldCreateWorkflow,
-		DryRun:                 release == nil,
+		DryRun:                 isDryRun,
 	}
 
 	// Save the github err for after creating the git action config. However, we
@@ -360,6 +366,114 @@ func createGitAction(
 	return ga.ToGitActionConfigType(), workflowYAML, nil
 }
 
+func getToken(
+	config *config.Config,
+	proj *models.Project,
+	userID, projectID, clusterID uint,
+	request *types.CreateGitActionConfigRequest,
+) (string, error) {
+	// create a policy for the token
+	policy := []*types.PolicyDocument{
+		{
+			Scope: types.ProjectScope,
+			Verbs: types.ReadWriteVerbGroup(),
+			Children: map[types.PermissionScope]*types.PolicyDocument{
+				types.ClusterScope: {
+					Scope: types.ClusterScope,
+					Verbs: types.ReadWriteVerbGroup(),
+				},
+				types.RegistryScope: {
+					Scope: types.RegistryScope,
+					Verbs: types.ReadVerbGroup(),
+				},
+				types.HelmRepoScope: {
+					Scope: types.HelmRepoScope,
+					Verbs: types.ReadVerbGroup(),
+				},
+			},
+		},
+	}
+
+	uid, err := encryption.GenerateRandomBytes(16)
+
+	if err != nil {
+		return "", err
+	}
+
+	policyBytes, err := json.Marshal(policy)
+
+	if err != nil {
+		return "", err
+	}
+
+	policyModel := &models.Policy{
+		ProjectID:       projectID,
+		UniqueID:        uid,
+		CreatedByUserID: userID,
+		Name:            strings.ToLower(fmt.Sprintf("repo-%s-token-policy", request.GitRepo)),
+		PolicyBytes:     policyBytes,
+	}
+
+	policyModel, err = config.Repo.Policy().CreatePolicy(policyModel)
+
+	if err != nil {
+		return "", err
+	}
+
+	// create the token in the database
+	tokenUID, err := encryption.GenerateRandomBytes(16)
+
+	if err != nil {
+		return "", err
+	}
+
+	secretKey, err := encryption.GenerateRandomBytes(16)
+
+	if err != nil {
+		return "", err
+	}
+
+	// hash the secret key for storage in the db
+	hashedToken, err := bcrypt.GenerateFromPassword([]byte(secretKey), 8)
+
+	if err != nil {
+		return "", err
+	}
+
+	expiresAt := time.Now().Add(time.Hour * 24 * 365)
+
+	apiToken := &models.APIToken{
+		UniqueID:        tokenUID,
+		ProjectID:       projectID,
+		CreatedByUserID: userID,
+		Expiry:          &expiresAt,
+		Revoked:         false,
+		PolicyUID:       policyModel.UniqueID,
+		PolicyName:      policyModel.Name,
+		Name:            strings.ToLower(fmt.Sprintf("repo-%s-token", request.GitRepo)),
+		SecretKey:       hashedToken,
+	}
+
+	if !proj.APITokensEnabled {
+		return "", fmt.Errorf("api tokens are not enabled for this project")
+	}
+
+	apiToken, err = config.Repo.APIToken().CreateAPIToken(apiToken)
+
+	if err != nil {
+		return "", err
+	}
+
+	// generate porter jwt token
+	jwt, err := token.GetStoredTokenForAPI(userID, projectID, apiToken.UniqueID, secretKey)
+
+	if err != nil {
+		return "", err
+	}
+
+	return jwt.EncodeToken(config.TokenConf)
+}
+
 func createBuildConfig(
 	config *config.Config,
 	release *models.Release,

+ 2 - 0
api/server/handlers/release/get_gha_template.go

@@ -27,6 +27,7 @@ func NewGetGHATemplateHandler(
 
 func (c *GetGHATemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	namespace := r.Context().Value(types.NamespaceScope).(string)
 
@@ -38,6 +39,7 @@ func (c *GetGHATemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 	_, workflowYAML, err := createGitAction(
 		c.Config(),
+		proj,
 		user.ID,
 		cluster.ProjectID,
 		cluster.ID,

+ 28 - 27
api/server/router/base.go

@@ -14,11 +14,12 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/webhook"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewBaseRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewBaseRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetBaseRoutes,
 		Children:  children,
 	}
@@ -29,9 +30,9 @@ func GetBaseRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
-	routes := make([]*Route, 0)
+	children ...*router.Registerer,
+) []*router.Route {
+	routes := make([]*router.Route, 0)
 
 	// GET /api/readyz -> healthcheck.NewReadyzHandler
 	getReadyzEndpoint := factory.NewAPIEndpoint(
@@ -51,7 +52,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getReadyzEndpoint,
 		Handler:  getReadyzHandler,
 		Router:   r,
@@ -75,7 +76,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getLivezEndpoint,
 		Handler:  getLivezHandler,
 		Router:   r,
@@ -98,7 +99,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getMetadataEndpoint,
 		Handler:  getMetadataHandler,
 		Router:   r,
@@ -121,7 +122,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listClusterIntsEndpoint,
 		Handler:  listClusterIntsHandler,
 		Router:   r,
@@ -144,7 +145,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listRegistryIntsEndpoint,
 		Handler:  listRegistryIntsHandler,
 		Router:   r,
@@ -167,7 +168,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listHelmRepoIntsEndpoint,
 		Handler:  listHelmRepoIntsHandler,
 		Router:   r,
@@ -191,7 +192,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createUserEndpoint,
 		Handler:  createUserHandler,
 		Router:   r,
@@ -215,7 +216,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: loginUserEndpoint,
 		Handler:  loginUserHandler,
 		Router:   r,
@@ -239,7 +240,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: cliLoginExchangeEndpoint,
 		Handler:  cliLoginExchangeHandler,
 		Router:   r,
@@ -263,7 +264,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: passwordInitiateResetEndpoint,
 		Handler:  passwordInitiateResetHandler,
 		Router:   r,
@@ -287,7 +288,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: passwordVerifyResetEndpoint,
 		Handler:  passwordVerifyResetHandler,
 		Router:   r,
@@ -311,7 +312,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: passwordFinalizeResetEndpoint,
 		Handler:  passwordFinalizeResetHandler,
 		Router:   r,
@@ -336,7 +337,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: webhookEndpoint,
 		Handler:  webhookHandler,
 		Router:   r,
@@ -359,7 +360,7 @@ func GetBaseRoutes(
 		config,
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: githubAppInstallEndpoint,
 		Handler:  githubAppInstallHandler,
 		Router:   r,
@@ -384,7 +385,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: githubAppWebhookEndpoint,
 		Handler:  githubAppWebhookHandler,
 		Router:   r,
@@ -409,7 +410,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: githubLoginStartEndpoint,
 		Handler:  githubLoginStartHandler,
 		Router:   r,
@@ -434,7 +435,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: githubLoginCallbackEndpoint,
 		Handler:  githubLoginCallbackHandler,
 		Router:   r,
@@ -459,7 +460,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: googleLoginStartEndpoint,
 		Handler:  googleLoginStartHandler,
 		Router:   r,
@@ -484,7 +485,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: googleLoginCallbackEndpoint,
 		Handler:  googleLoginCallbackHandler,
 		Router:   r,
@@ -509,7 +510,7 @@ func GetBaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getCredentialsEndpoint,
 		Handler:  getCredentialsHandler,
 		Router:   r,
@@ -533,7 +534,7 @@ func GetBaseRoutes(
 		factory.GetDecoderValidator(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: addProjectBillingEndpoint,
 		Handler:  addProjectBillingHandler,
 		Router:   r,
@@ -559,7 +560,7 @@ func GetBaseRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: githubIncomingWebhookEndpoint,
 			Handler:  githubIncomingWebhookHandler,
 			Router:   r,

+ 46 - 45
api/server/router/cluster.go

@@ -10,11 +10,12 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/kube_events"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewClusterScopedRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewClusterScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetClusterScopedRoutes,
 		Children:  children,
 	}
@@ -25,8 +26,8 @@ func GetClusterScopedRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	routes, projPath := getClusterRoutes(r, config, basePath, factory)
 
 	if len(children) > 0 {
@@ -47,7 +48,7 @@ func getClusterRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) ([]*Route, *types.Path) {
+) ([]*router.Route, *types.Path) {
 	relPath := "/clusters/{cluster_id}"
 
 	newPath := &types.Path{
@@ -55,7 +56,7 @@ func getClusterRoutes(
 		RelativePath: relPath,
 	}
 
-	routes := make([]*Route, 0)
+	routes := make([]*router.Route, 0)
 
 	// POST /api/projects/{project_id}/clusters -> project.NewCreateClusterManualHandler
 	createEndpoint := factory.NewAPIEndpoint(
@@ -79,7 +80,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createEndpoint,
 		Handler:  createHandler,
 		Router:   r,
@@ -109,7 +110,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createCandidateEndpoint,
 		Handler:  createCandidateHandler,
 		Router:   r,
@@ -136,7 +137,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listCandidatesEndpoint,
 		Handler:  listCandidatesHandler,
 		Router:   r,
@@ -169,7 +170,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: resolveCandidateEndpoint,
 		Handler:  resolveCandidateHandler,
 		Router:   r,
@@ -198,7 +199,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateClusterEndpoint,
 		Handler:  updateClusterHandler,
 		Router:   r,
@@ -226,7 +227,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deleteClusterEndpoint,
 		Handler:  deleteClusterHandler,
 		Router:   r,
@@ -254,7 +255,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getEndpoint,
 		Handler:  getHandler,
 		Router:   r,
@@ -282,7 +283,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listDatabaseEndpoint,
 		Handler:  listDatabaseHandler,
 		Router:   r,
@@ -312,7 +313,7 @@ func getClusterRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: listEnvEndpoint,
 			Handler:  listEnvHandler,
 			Router:   r,
@@ -341,7 +342,7 @@ func getClusterRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: listDeploymentsEndpoint,
 			Handler:  listDeploymentsHandler,
 			Router:   r,
@@ -370,7 +371,7 @@ func getClusterRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: getDeploymentEndpoint,
 			Handler:  getDeploymentHandler,
 			Router:   r,
@@ -399,7 +400,7 @@ func getClusterRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: reenableDeploymentEndpoint,
 			Handler:  reenableDeploymentHandler,
 			Router:   r,
@@ -428,7 +429,7 @@ func getClusterRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: triggerDeploymentWorkflowEndpoint,
 			Handler:  triggerDeploymentWorkflowHandler,
 			Router:   r,
@@ -457,7 +458,7 @@ func getClusterRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: enablePullRequestEndpoint,
 			Handler:  enablePullRequestHandler,
 			Router:   r,
@@ -487,7 +488,7 @@ func getClusterRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: deleteDeploymentEndpoint,
 			Handler:  deleteDeploymentHandler,
 			Router:   r,
@@ -517,7 +518,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listNamespacesEndpoint,
 		Handler:  listNamespacesHandler,
 		Router:   r,
@@ -545,7 +546,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listNodesEndpoint,
 		Handler:  listNodesHandler,
 		Router:   r,
@@ -573,7 +574,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getNodeEndpoint,
 		Handler:  getNodeHandler,
 		Router:   r,
@@ -602,7 +603,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createNamespaceEndpoint,
 		Handler:  createNamespaceHandler,
 		Router:   r,
@@ -630,7 +631,7 @@ func getClusterRoutes(
 		factory.GetDecoderValidator(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deleteNamespaceEndpoint,
 		Handler:  deleteNamespaceHandler,
 		Router:   r,
@@ -658,7 +659,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getTemporaryKubeconfigEndpoint,
 		Handler:  getTemporaryKubeconfigHandler,
 		Router:   r,
@@ -683,7 +684,7 @@ func getClusterRoutes(
 
 	detectPrometheusInstalledHandler := cluster.NewDetectPrometheusInstalledHandler(config)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: detectPrometheusInstalledEndpoint,
 		Handler:  detectPrometheusInstalledHandler,
 		Router:   r,
@@ -708,7 +709,7 @@ func getClusterRoutes(
 
 	detectAgentInstalledHandler := cluster.NewDetectAgentInstalledHandler(config, factory.GetResultWriter())
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: detectAgentInstalledEndpoint,
 		Handler:  detectAgentInstalledHandler,
 		Router:   r,
@@ -737,7 +738,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: installAgentEndpoint,
 		Handler:  installAgentHandler,
 		Router:   r,
@@ -766,7 +767,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: upgradeAgentEndpoint,
 		Handler:  upgradeAgentHandler,
 		Router:   r,
@@ -795,7 +796,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listKubeEventsEndpoint,
 		Handler:  listKubeEventsHandler,
 		Router:   r,
@@ -824,7 +825,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getKubeEventEndpoint,
 		Handler:  getKubeEventHandler,
 		Router:   r,
@@ -853,7 +854,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getKubeEventLogsEndpoint,
 		Handler:  getKubeEventLogsHandler,
 		Router:   r,
@@ -882,7 +883,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getKubeEventLogBucketsEndpoint,
 		Handler:  getKubeEventLogBucketsHandler,
 		Router:   r,
@@ -911,7 +912,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createKubeEventsEndpoint,
 		Handler:  createKubeEventsHandler,
 		Router:   r,
@@ -939,7 +940,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listNGINXIngressesEndpoint,
 		Handler:  listNGINXIngressesHandler,
 		Router:   r,
@@ -968,7 +969,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getPodMetricsEndpoint,
 		Handler:  getPodMetricsHandler,
 		Router:   r,
@@ -998,7 +999,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: streamHelmReleaseEndpoint,
 		Handler:  streamHelmReleaseHandler,
 		Router:   r,
@@ -1032,7 +1033,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: streamStatusEndpoint,
 		Handler:  streamStatusHandler,
 		Router:   r,
@@ -1061,7 +1062,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getPodsEndpoint,
 		Handler:  getPodsHandler,
 		Router:   r,
@@ -1090,7 +1091,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getIncidentsEndpoint,
 		Handler:  getIncidentsHandler,
 		Router:   r,
@@ -1119,7 +1120,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getIncidentEventLogsEndpoint,
 		Handler:  getIncidentEventLogsHandler,
 		Router:   r,
@@ -1148,7 +1149,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: notifyNewIncidentEndpoint,
 		Handler:  notifyNewIncidentHandler,
 		Router:   r,
@@ -1177,7 +1178,7 @@ func getClusterRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: notifyResolvedIncidentEndpoint,
 		Handler:  notifyResolvedIncidentHandler,
 		Router:   r,

+ 24 - 23
api/server/router/git_installation.go

@@ -8,11 +8,12 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewGitInstallationScopedRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewGitInstallationScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetGitInstallationScopedRoutes,
 		Children:  children,
 	}
@@ -23,8 +24,8 @@ func GetGitInstallationScopedRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	routes, projPath := getGitInstallationRoutes(r, config, basePath, factory)
 
 	if len(children) > 0 {
@@ -45,7 +46,7 @@ func getGitInstallationRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) ([]*Route, *types.Path) {
+) ([]*router.Route, *types.Path) {
 	relPath := "/gitrepos/{git_installation_id}"
 
 	newPath := &types.Path{
@@ -53,7 +54,7 @@ func getGitInstallationRoutes(
 		RelativePath: relPath,
 	}
 
-	routes := make([]*Route, 0)
+	routes := make([]*router.Route, 0)
 
 	// GET /api/projects/{project_id}/gitrepos/{git_installation_id} -> gitinstallation.NewGitInstallationGetHandler
 	getEndpoint := factory.NewAPIEndpoint(
@@ -77,7 +78,7 @@ func getGitInstallationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getEndpoint,
 		Handler:  getHandler,
 		Router:   r,
@@ -106,7 +107,7 @@ func getGitInstallationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getPermissionsEndpoint,
 		Handler:  getPermissionsHandler,
 		Router:   r,
@@ -144,7 +145,7 @@ func getGitInstallationRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: createEnvironmentEndpoint,
 			Handler:  createEnvironmentHandler,
 			Router:   r,
@@ -180,7 +181,7 @@ func getGitInstallationRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: createDeploymentEndpoint,
 			Handler:  createDeploymentHandler,
 			Router:   r,
@@ -216,7 +217,7 @@ func getGitInstallationRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: getDeploymentEndpoint,
 			Handler:  getDeploymentHandler,
 			Router:   r,
@@ -252,7 +253,7 @@ func getGitInstallationRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: listDeploymentsEndpoint,
 			Handler:  listDeploymentsHandler,
 			Router:   r,
@@ -288,7 +289,7 @@ func getGitInstallationRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: finalizeDeploymentEndpoint,
 			Handler:  finalizeDeploymentHandler,
 			Router:   r,
@@ -324,7 +325,7 @@ func getGitInstallationRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: updateDeploymentEndpoint,
 			Handler:  updateDeploymentHandler,
 			Router:   r,
@@ -360,7 +361,7 @@ func getGitInstallationRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: updateDeploymentStatusEndpoint,
 			Handler:  updateDeploymentStatusHandler,
 			Router:   r,
@@ -396,7 +397,7 @@ func getGitInstallationRoutes(
 			factory.GetResultWriter(),
 		)
 
-		routes = append(routes, &Route{
+		routes = append(routes, &router.Route{
 			Endpoint: deleteEnvironmentEndpoint,
 			Handler:  deleteEnvironmentHandler,
 			Router:   r,
@@ -427,7 +428,7 @@ func getGitInstallationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listReposEndpoint,
 		Handler:  listReposHandler,
 		Router:   r,
@@ -462,7 +463,7 @@ func getGitInstallationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listBranchesEndpoint,
 		Handler:  listBranchesHandler,
 		Router:   r,
@@ -499,7 +500,7 @@ func getGitInstallationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getBuildpackEndpoint,
 		Handler:  getBuildpackHandler,
 		Router:   r,
@@ -536,7 +537,7 @@ func getGitInstallationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getContentsEndpoint,
 		Handler:  getContentsHandler,
 		Router:   r,
@@ -573,7 +574,7 @@ func getGitInstallationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getProcfileEndpoint,
 		Handler:  getProcfileHandler,
 		Router:   r,
@@ -610,7 +611,7 @@ func getGitInstallationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getTarballURLEndpoint,
 		Handler:  getTarballURLHandler,
 		Router:   r,
@@ -646,7 +647,7 @@ func getGitInstallationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: rerunWorkflowEndpoint,
 		Handler:  rerunWorkflowHandler,
 		Router:   r,

+ 10 - 9
api/server/router/helm_repo.go

@@ -5,11 +5,12 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/helmrepo"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewHelmRepoScopedRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewHelmRepoScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetHelmRepoScopedRoutes,
 		Children:  children,
 	}
@@ -20,8 +21,8 @@ func GetHelmRepoScopedRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	routes, projPath := getHelmRepoRoutes(r, config, basePath, factory)
 
 	if len(children) > 0 {
@@ -42,7 +43,7 @@ func getHelmRepoRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) ([]*Route, *types.Path) {
+) ([]*router.Route, *types.Path) {
 	relPath := "/helmrepos/{helm_repo_id}"
 
 	newPath := &types.Path{
@@ -50,7 +51,7 @@ func getHelmRepoRoutes(
 		RelativePath: relPath,
 	}
 
-	routes := make([]*Route, 0)
+	routes := make([]*router.Route, 0)
 
 	// GET /api/projects/{project_id}/helmrepos/{helm_repo_id} -> registry.NewHelmRepoGetHandler
 	getEndpoint := factory.NewAPIEndpoint(
@@ -74,7 +75,7 @@ func getHelmRepoRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getEndpoint,
 		Handler:  getHandler,
 		Router:   r,
@@ -103,7 +104,7 @@ func getHelmRepoRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: hrListEndpoint,
 		Handler:  hrListHandler,
 		Router:   r,
@@ -132,7 +133,7 @@ func getHelmRepoRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: chartGetEndpoint,
 		Handler:  chartGetHandler,
 		Router:   r,

+ 20 - 19
api/server/router/infra.go

@@ -8,11 +8,12 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/infra"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewInfraScopedRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewInfraScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetInfraScopedRoutes,
 		Children:  children,
 	}
@@ -23,8 +24,8 @@ func GetInfraScopedRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	routes, projPath := getInfraRoutes(r, config, basePath, factory)
 
 	if len(children) > 0 {
@@ -45,7 +46,7 @@ func getInfraRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) ([]*Route, *types.Path) {
+) ([]*router.Route, *types.Path) {
 	relPath := "/infras/{infra_id}"
 
 	newPath := &types.Path{
@@ -53,7 +54,7 @@ func getInfraRoutes(
 		RelativePath: relPath,
 	}
 
-	routes := make([]*Route, 0)
+	routes := make([]*router.Route, 0)
 
 	// GET /api/projects/{project_id}/infra -> project.NewInfraListHandler
 	listInfraEndpoint := factory.NewAPIEndpoint(
@@ -77,7 +78,7 @@ func getInfraRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listInfraEndpoint,
 		Handler:  listInfraHandler,
 		Router:   r,
@@ -105,7 +106,7 @@ func getInfraRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getEndpoint,
 		Handler:  getHandler,
 		Router:   r,
@@ -134,7 +135,7 @@ func getInfraRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: retryCreateEndpoint,
 		Handler:  retryCreateHandler,
 		Router:   r,
@@ -163,7 +164,7 @@ func getInfraRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateEndpoint,
 		Handler:  updateHandler,
 		Router:   r,
@@ -192,7 +193,7 @@ func getInfraRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: retryDeleteEndpoint,
 		Handler:  retryDeleteHandler,
 		Router:   r,
@@ -220,7 +221,7 @@ func getInfraRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listOperationsEndpoint,
 		Handler:  listOperationsHandler,
 		Router:   r,
@@ -249,7 +250,7 @@ func getInfraRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getOperationEndpoint,
 		Handler:  getOperationHandler,
 		Router:   r,
@@ -279,7 +280,7 @@ func getInfraRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: streamStateEndpoint,
 		Handler:  streamStateHandler,
 		Router:   r,
@@ -309,7 +310,7 @@ func getInfraRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: streamLogEndpoint,
 		Handler:  streamLogHandler,
 		Router:   r,
@@ -338,7 +339,7 @@ func getInfraRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getOperationLogsEndpoint,
 		Handler:  getOperationLogsHandler,
 		Router:   r,
@@ -366,7 +367,7 @@ func getInfraRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getStateEndpoint,
 		Handler:  getStateHandler,
 		Router:   r,
@@ -395,7 +396,7 @@ func getInfraRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deleteEndpoint,
 		Handler:  deleteHandler,
 		Router:   r,
@@ -423,7 +424,7 @@ func getInfraRoutes(
 		factory.GetDecoderValidator(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateDBStatusEndpoint,
 		Handler:  updateDBStatusHandler,
 		Router:   r,

+ 12 - 11
api/server/router/invite.go

@@ -5,11 +5,12 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/invite"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewInviteScopedRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewInviteScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetInviteScopedRoutes,
 		Children:  children,
 	}
@@ -20,8 +21,8 @@ func GetInviteScopedRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	routes, projPath := getInviteRoutes(r, config, basePath, factory)
 
 	if len(children) > 0 {
@@ -42,7 +43,7 @@ func getInviteRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) ([]*Route, *types.Path) {
+) ([]*router.Route, *types.Path) {
 	relPath := "/invites/{invite_id}"
 
 	newPath := &types.Path{
@@ -50,7 +51,7 @@ func getInviteRoutes(
 		RelativePath: relPath,
 	}
 
-	routes := make([]*Route, 0)
+	routes := make([]*router.Route, 0)
 
 	// GET /api/projects/{project_id}/invites -> invite.NewInvitesListHandler
 	listEndpoint := factory.NewAPIEndpoint(
@@ -74,7 +75,7 @@ func getInviteRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listEndpoint,
 		Handler:  listHandler,
 		Router:   r,
@@ -105,7 +106,7 @@ func getInviteRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createEndpoint,
 		Handler:  createHandler,
 		Router:   r,
@@ -131,7 +132,7 @@ func getInviteRoutes(
 
 	acceptHandler := invite.NewInviteAcceptHandler(config)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: acceptEndpoint,
 		Handler:  acceptHandler,
 		Router:   r,
@@ -160,7 +161,7 @@ func getInviteRoutes(
 		factory.GetDecoderValidator(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateRoleEndpoint,
 		Handler:  updateRoleHandler,
 		Router:   r,
@@ -188,7 +189,7 @@ func getInviteRoutes(
 		config,
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deleteEndpoint,
 		Handler:  deleteHandler,
 		Router:   r,

+ 28 - 27
api/server/router/namespace.go

@@ -9,11 +9,12 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/namespace"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewNamespaceScopedRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewNamespaceScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetNamespaceScopedRoutes,
 		Children:  children,
 	}
@@ -24,8 +25,8 @@ func GetNamespaceScopedRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	routes, projPath := getNamespaceRoutes(r, config, basePath, factory)
 
 	if len(children) > 0 {
@@ -46,7 +47,7 @@ func getNamespaceRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) ([]*Route, *types.Path) {
+) ([]*router.Route, *types.Path) {
 	relPath := "/namespaces/{namespace}"
 
 	newPath := &types.Path{
@@ -54,7 +55,7 @@ func getNamespaceRoutes(
 		RelativePath: relPath,
 	}
 
-	routes := make([]*Route, 0)
+	routes := make([]*router.Route, 0)
 
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/envgroups/list -> namespace.NewListEnvGroupsHandler
 	listEnvGroupsEndpoint := factory.NewAPIEndpoint(
@@ -79,7 +80,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listEnvGroupsEndpoint,
 		Handler:  listEnvGroupsHandler,
 		Router:   r,
@@ -109,7 +110,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: cloneEnvGroupEndpoint,
 		Handler:  cloneEnvGroupHandler,
 		Router:   r,
@@ -139,7 +140,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getEnvGroupEndpoint,
 		Handler:  getEnvGroupHandler,
 		Router:   r,
@@ -169,7 +170,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getEnvGroupAllVersionsEndpoint,
 		Handler:  getEnvGroupAllVersionsHandler,
 		Router:   r,
@@ -199,7 +200,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createEnvGroupEndpoint,
 		Handler:  createEnvGroupHandler,
 		Router:   r,
@@ -229,7 +230,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateEnvGroupAppsEndpoint,
 		Handler:  updateEnvGroupAppsHandler,
 		Router:   r,
@@ -259,7 +260,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: removeEnvGroupAppEndpoint,
 		Handler:  removeEnvGroupAppHandler,
 		Router:   r,
@@ -289,7 +290,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deleteEnvGroupEndpoint,
 		Handler:  deleteEnvGroupHandler,
 		Router:   r,
@@ -319,7 +320,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateConfigMapEndpoint,
 		Handler:  updateConfigMapHandler,
 		Router:   r,
@@ -348,7 +349,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deleteCRDEndpoint,
 		Handler:  deleteCRDHandler,
 		Router:   r,
@@ -378,7 +379,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listReleasesEndpoint,
 		Handler:  listReleasesHandler,
 		Router:   r,
@@ -413,7 +414,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: streamPodLogsEndpoint,
 		Handler:  streamPodLogsHandler,
 		Router:   r,
@@ -447,7 +448,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: streamJobRunsEndpoint,
 		Handler:  streamJobRunsHandler,
 		Router:   r,
@@ -481,7 +482,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getPreviousLogsEndpoint,
 		Handler:  getPreviousLogsHandler,
 		Router:   r,
@@ -514,7 +515,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getJobPodsEndpoint,
 		Handler:  getJobPodsHandler,
 		Router:   r,
@@ -546,7 +547,7 @@ func getNamespaceRoutes(
 		config,
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deleteJobEndpoint,
 		Handler:  deleteJobHandler,
 		Router:   r,
@@ -578,7 +579,7 @@ func getNamespaceRoutes(
 		config,
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: stopJobEndpoint,
 		Handler:  stopJobHandler,
 		Router:   r,
@@ -611,7 +612,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getPodEndpoint,
 		Handler:  getPodHandler,
 		Router:   r,
@@ -643,7 +644,7 @@ func getNamespaceRoutes(
 		config,
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deletePodEndpoint,
 		Handler:  deletePodHandler,
 		Router:   r,
@@ -676,7 +677,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getPodEventsEndpoint,
 		Handler:  getPodEventsHandler,
 		Router:   r,
@@ -706,7 +707,7 @@ func getNamespaceRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getIngressEndpoint,
 		Handler:  getIngressHandler,
 		Router:   r,

+ 8 - 7
api/server/router/oauth_callback.go

@@ -5,11 +5,12 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/oauth_callback"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewOAuthCallbackRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewOAuthCallbackRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetOAuthCallbackRoutes,
 		Children:  children,
 	}
@@ -20,11 +21,11 @@ func GetOAuthCallbackRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	relPath := "/oauth"
 
-	routes := make([]*Route, 0)
+	routes := make([]*router.Route, 0)
 
 	// GET /api/oauth/slack/callback -> oauth_callback.NewOAuthCallbackSlackHandler
 	slackEndpoint := factory.NewAPIEndpoint(
@@ -44,7 +45,7 @@ func GetOAuthCallbackRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: slackEndpoint,
 		Handler:  slackHandler,
 		Router:   r,
@@ -68,7 +69,7 @@ func GetOAuthCallbackRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: doEndpoint,
 		Handler:  doHandler,
 		Router:   r,

+ 247 - 41
api/server/router/project.go

@@ -4,20 +4,23 @@ import (
 	"fmt"
 
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/api_token"
 	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/helmrepo"
 	"github.com/porter-dev/porter/api/server/handlers/infra"
+	"github.com/porter-dev/porter/api/server/handlers/policy"
 	"github.com/porter-dev/porter/api/server/handlers/project"
 	"github.com/porter-dev/porter/api/server/handlers/registry"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewProjectScopedRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewProjectScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetProjectScopedRoutes,
 		Children:  children,
 	}
@@ -28,8 +31,8 @@ func GetProjectScopedRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	routes, projPath := getProjectRoutes(r, config, basePath, factory)
 
 	if len(children) > 0 {
@@ -50,7 +53,7 @@ func getProjectRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) ([]*Route, *types.Path) {
+) ([]*router.Route, *types.Path) {
 	relPath := "/projects/{project_id}"
 
 	newPath := &types.Path{
@@ -58,7 +61,7 @@ func getProjectRoutes(
 		RelativePath: relPath,
 	}
 
-	routes := make([]*Route, 0)
+	routes := make([]*router.Route, 0)
 
 	// GET /api/projects/{project_id} -> project.NewProjectGetHandler
 	getEndpoint := factory.NewAPIEndpoint(
@@ -81,7 +84,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getEndpoint,
 		Handler:  getHandler,
 		Router:   r,
@@ -108,7 +111,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deleteEndpoint,
 		Handler:  deleteHandler,
 		Router:   r,
@@ -135,7 +138,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getPolicyEndpoint,
 		Handler:  getPolicyHandler,
 		Router:   r,
@@ -163,7 +166,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getOnboardingEndpoint,
 		Handler:  getOnboardingHandler,
 		Router:   r,
@@ -191,7 +194,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateOnboardingEndpoint,
 		Handler:  updateOnboardingHandler,
 		Router:   r,
@@ -218,7 +221,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getUsageEndpoint,
 		Handler:  getUsageHandler,
 		Router:   r,
@@ -245,7 +248,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getBillingEndpoint,
 		Handler:  getBillingHandler,
 		Router:   r,
@@ -274,7 +277,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getBillingTokenEndpoint,
 		Handler:  getBillingTokenHandler,
 		Router:   r,
@@ -298,7 +301,7 @@ func getProjectRoutes(
 		factory.GetDecoderValidator(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getBillingWebhookEndpoint,
 		Handler:  getBillingWebhookHandler,
 		Router:   r,
@@ -325,7 +328,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listClusterEndpoint,
 		Handler:  listClusterHandler,
 		Router:   r,
@@ -352,7 +355,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listGitReposEndpoint,
 		Handler:  listGitReposHandler,
 		Router:   r,
@@ -379,7 +382,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listCollaboratorsEndpoint,
 		Handler:  listCollaboratorsHandler,
 		Router:   r,
@@ -406,7 +409,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listRolesEndpoint,
 		Handler:  listRolesHandler,
 		Router:   r,
@@ -434,7 +437,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateRoleEndpoint,
 		Handler:  updateRoleHandler,
 		Router:   r,
@@ -462,7 +465,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deleteRoleEndpoint,
 		Handler:  deleteRoleHandler,
 		Router:   r,
@@ -489,7 +492,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listRegistriesEndpoint,
 		Handler:  listRegistriesHandler,
 		Router:   r,
@@ -517,7 +520,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createRegistryEndpoint,
 		Handler:  createRegistryHandler,
 		Router:   r,
@@ -545,7 +548,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getECRTokenEndpoint,
 		Handler:  getECRTokenHandler,
 		Router:   r,
@@ -573,7 +576,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getDOCRTokenEndpoint,
 		Handler:  getDOCRTokenHandler,
 		Router:   r,
@@ -601,7 +604,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getGCRTokenEndpoint,
 		Handler:  getGCRTokenHandler,
 		Router:   r,
@@ -629,7 +632,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getACRTokenEndpoint,
 		Handler:  getACRTokenHandler,
 		Router:   r,
@@ -657,7 +660,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getDockerhubTokenEndpoint,
 		Handler:  getDockerhubTokenHandler,
 		Router:   r,
@@ -685,7 +688,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createInfraEndpoint,
 		Handler:  createInfraHandler,
 		Router:   r,
@@ -712,7 +715,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getTemplatesEndpoint,
 		Handler:  getTemplatesHandler,
 		Router:   r,
@@ -739,7 +742,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getTemplateEndpoint,
 		Handler:  getTemplateHandler,
 		Router:   r,
@@ -767,7 +770,7 @@ func getProjectRoutes(
 	// 	factory.GetResultWriter(),
 	// )
 
-	// routes = append(routes, &Route{
+	// routes = append(routes, &router.Route{
 	// 	Endpoint: provisionECREndpoint,
 	// 	Handler:  provisionECRHandler,
 	// 	Router:   r,
@@ -797,7 +800,7 @@ func getProjectRoutes(
 	// 	factory.GetResultWriter(),
 	// )
 
-	// routes = append(routes, &Route{
+	// routes = append(routes, &router.Route{
 	// 	Endpoint: provisionEKSEndpoint,
 	// 	Handler:  provisionEKSHandler,
 	// 	Router:   r,
@@ -825,7 +828,7 @@ func getProjectRoutes(
 	// 	factory.GetResultWriter(),
 	// )
 
-	// routes = append(routes, &Route{
+	// routes = append(routes, &router.Route{
 	// 	Endpoint: provisionDOCREndpoint,
 	// 	Handler:  provisionDOCRHandler,
 	// 	Router:   r,
@@ -855,7 +858,7 @@ func getProjectRoutes(
 	// 	factory.GetResultWriter(),
 	// )
 
-	// routes = append(routes, &Route{
+	// routes = append(routes, &router.Route{
 	// 	Endpoint: provisionDOKSEndpoint,
 	// 	Handler:  provisionDOKSHandler,
 	// 	Router:   r,
@@ -883,7 +886,7 @@ func getProjectRoutes(
 	// 	factory.GetResultWriter(),
 	// )
 
-	// routes = append(routes, &Route{
+	// routes = append(routes, &router.Route{
 	// 	Endpoint: provisionGCREndpoint,
 	// 	Handler:  provisionGCRHandler,
 	// 	Router:   r,
@@ -913,12 +916,215 @@ func getProjectRoutes(
 	// 	factory.GetResultWriter(),
 	// )
 
-	// routes = append(routes, &Route{
+	// routes = append(routes, &router.Route{
 	// 	Endpoint: provisionGKEEndpoint,
 	// 	Handler:  provisionGKEHandler,
 	// 	Router:   r,
 	// })
 
+	//  POST /api/projects/{project_id}/policy -> policy.NewPolicyCreateHandler
+	policyCreateEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/policy",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	policyCreateHandler := policy.NewPolicyCreateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: policyCreateEndpoint,
+		Handler:  policyCreateHandler,
+		Router:   r,
+	})
+
+	//  GET /api/projects/{project_id}/policies -> policy.NewPolicyListHandler
+	policyListEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/policies",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	policyListHandler := policy.NewPolicyListHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: policyListEndpoint,
+		Handler:  policyListHandler,
+		Router:   r,
+	})
+
+	//  GET /api/projects/{project_id}/policy/{policy_id} -> policy.NewPolicyGetHandler
+	policyGetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/policy/{%s}", relPath, types.URLParamPolicyID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	policyGetHandler := policy.NewPolicyGetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: policyGetEndpoint,
+		Handler:  policyGetHandler,
+		Router:   r,
+	})
+
+	//  POST /api/projects/{project_id}/api_token -> api_token.NewAPITokenCreateHandler
+	apiTokenCreateEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/api_token",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	apiTokenCreateHandler := api_token.NewAPITokenCreateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: apiTokenCreateEndpoint,
+		Handler:  apiTokenCreateHandler,
+		Router:   r,
+	})
+
+	//  GET /api/projects/{project_id}/api_token -> api_token.NewAPITokenListHandler
+	apiTokenListEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/api_token", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	apiTokenListHandler := api_token.NewAPITokenListHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: apiTokenListEndpoint,
+		Handler:  apiTokenListHandler,
+		Router:   r,
+	})
+
+	//  GET /api/projects/{project_id}/api_token/{api_token_id} -> api_token.NewAPITokenGetHandler
+	apiTokenGetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/api_token/{%s}", relPath, types.URLParamTokenID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	apiTokenGetHandler := api_token.NewAPITokenGetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: apiTokenGetEndpoint,
+		Handler:  apiTokenGetHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/api_token/{api_token_id}/revoke -> api_token.NewAPITokenRevokeHandler
+	apiTokenRevokeEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/api_token/{%s}/revoke", relPath, types.URLParamTokenID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	apiTokenRevokeHandler := api_token.NewAPITokenRevokeHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: apiTokenRevokeEndpoint,
+		Handler:  apiTokenRevokeHandler,
+		Router:   r,
+	})
+
 	//  POST /api/projects/{project_id}/helmrepos -> helmrepo.NewHelmRepoCreateHandler
 	hrCreateEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -941,7 +1147,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: hrCreateEndpoint,
 		Handler:  hrCreateHandler,
 		Router:   r,
@@ -968,7 +1174,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: hrListEndpoint,
 		Handler:  hrListHandler,
 		Router:   r,
@@ -995,7 +1201,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getTagsEndpoint,
 		Handler:  getTagsHandler,
 		Router:   r,
@@ -1023,7 +1229,7 @@ func getProjectRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createTagEndpoint,
 		Handler:  createTagHandler,
 		Router:   r,

+ 17 - 16
api/server/router/project_integration.go

@@ -5,11 +5,12 @@ import (
 	project_integration "github.com/porter-dev/porter/api/server/handlers/project_integration"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewProjectIntegrationScopedRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewProjectIntegrationScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetProjectIntegrationScopedRoutes,
 		Children:  children,
 	}
@@ -20,8 +21,8 @@ func GetProjectIntegrationScopedRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	routes, projPath := getProjectIntegrationRoutes(r, config, basePath, factory)
 
 	if len(children) > 0 {
@@ -42,7 +43,7 @@ func getProjectIntegrationRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) ([]*Route, *types.Path) {
+) ([]*router.Route, *types.Path) {
 	relPath := "/integrations"
 
 	newPath := &types.Path{
@@ -50,7 +51,7 @@ func getProjectIntegrationRoutes(
 		RelativePath: relPath,
 	}
 
-	routes := make([]*Route, 0)
+	routes := make([]*router.Route, 0)
 
 	// GET /api/projects/{project_id}/integrations/oauth -> project_integration.NewListOAuthHandler
 	listOAuthEndpoint := factory.NewAPIEndpoint(
@@ -73,7 +74,7 @@ func getProjectIntegrationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listOAuthEndpoint,
 		Handler:  listOAuthHandler,
 		Router:   r,
@@ -100,7 +101,7 @@ func getProjectIntegrationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listDOEndpoint,
 		Handler:  listDOHandler,
 		Router:   r,
@@ -128,7 +129,7 @@ func getProjectIntegrationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createBasicEndpoint,
 		Handler:  createBasicHandler,
 		Router:   r,
@@ -156,7 +157,7 @@ func getProjectIntegrationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createAWSEndpoint,
 		Handler:  createAWSHandler,
 		Router:   r,
@@ -183,7 +184,7 @@ func getProjectIntegrationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listAWSEndpoint,
 		Handler:  listAWSHandler,
 		Router:   r,
@@ -211,7 +212,7 @@ func getProjectIntegrationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: overwriteAWSEndpoint,
 		Handler:  overwriteAWSHandler,
 		Router:   r,
@@ -238,7 +239,7 @@ func getProjectIntegrationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listAzureEndpoint,
 		Handler:  listAzureHandler,
 		Router:   r,
@@ -266,7 +267,7 @@ func getProjectIntegrationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createGCPEndpoint,
 		Handler:  createGCPHandler,
 		Router:   r,
@@ -293,7 +294,7 @@ func getProjectIntegrationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listGCPEndpoint,
 		Handler:  listGCPHandler,
 		Router:   r,
@@ -321,7 +322,7 @@ func getProjectIntegrationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createAzureEndpoint,
 		Handler:  createAzureHandler,
 		Router:   r,

+ 9 - 8
api/server/router/project_oauth.go

@@ -6,11 +6,12 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/project_oauth"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewProjectOAuthScopedRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewProjectOAuthScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetProjectOAuthScopedRoutes,
 		Children:  children,
 	}
@@ -21,8 +22,8 @@ func GetProjectOAuthScopedRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	routes, projPath := getProjectOAuthRoutes(r, config, basePath, factory)
 
 	if len(children) > 0 {
@@ -43,7 +44,7 @@ func getProjectOAuthRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) ([]*Route, *types.Path) {
+) ([]*router.Route, *types.Path) {
 	relPath := "/oauth"
 
 	newPath := &types.Path{
@@ -51,7 +52,7 @@ func getProjectOAuthRoutes(
 		RelativePath: relPath,
 	}
 
-	routes := make([]*Route, 0)
+	routes := make([]*router.Route, 0)
 
 	// GET /api/projects/{project_id}/oauth/slack -> project_integration.NewProjectOAuthSlackHandler
 	slackEndpoint := factory.NewAPIEndpoint(
@@ -75,7 +76,7 @@ func getProjectOAuthRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: slackEndpoint,
 		Handler:  slackHandler,
 		Router:   r,
@@ -103,7 +104,7 @@ func getProjectOAuthRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: doEndpoint,
 		Handler:  doHandler,
 		Router:   r,

+ 13 - 12
api/server/router/registry.go

@@ -7,11 +7,12 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/registry"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewRegistryScopedRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewRegistryScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetRegistryScopedRoutes,
 		Children:  children,
 	}
@@ -22,8 +23,8 @@ func GetRegistryScopedRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	routes, projPath := getRegistryRoutes(r, config, basePath, factory)
 
 	if len(children) > 0 {
@@ -44,7 +45,7 @@ func getRegistryRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) ([]*Route, *types.Path) {
+) ([]*router.Route, *types.Path) {
 	relPath := "/registries/{registry_id}"
 
 	newPath := &types.Path{
@@ -52,7 +53,7 @@ func getRegistryRoutes(
 		RelativePath: relPath,
 	}
 
-	routes := make([]*Route, 0)
+	routes := make([]*router.Route, 0)
 
 	// GET /api/projects/{project_id}/registries/{registry_id} -> registry.NewRegistryGetHandler
 	getEndpoint := factory.NewAPIEndpoint(
@@ -76,7 +77,7 @@ func getRegistryRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getEndpoint,
 		Handler:  getHandler,
 		Router:   r,
@@ -105,7 +106,7 @@ func getRegistryRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateEndpoint,
 		Handler:  updateHandler,
 		Router:   r,
@@ -133,7 +134,7 @@ func getRegistryRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deleteEndpoint,
 		Handler:  deleteHandler,
 		Router:   r,
@@ -161,7 +162,7 @@ func getRegistryRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listRepositoriesEndpoint,
 		Handler:  listRepositoriesHandler,
 		Router:   r,
@@ -193,7 +194,7 @@ func getRegistryRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listImagesEndpoint,
 		Handler:  listImagesHandler,
 		Router:   r,
@@ -222,7 +223,7 @@ func getRegistryRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createRepositoryEndpoint,
 		Handler:  createRepositoryHandler,
 		Router:   r,

+ 32 - 31
api/server/router/release.go

@@ -5,11 +5,12 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/release"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewReleaseScopedRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewReleaseScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetReleaseScopedRoutes,
 		Children:  children,
 	}
@@ -20,8 +21,8 @@ func GetReleaseScopedRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	routes, projPath := getReleaseRoutes(r, config, basePath, factory)
 
 	if len(children) > 0 {
@@ -42,7 +43,7 @@ func getReleaseRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) ([]*Route, *types.Path) {
+) ([]*router.Route, *types.Path) {
 	relPath := "/releases/{name}/{version}"
 
 	newPath := &types.Path{
@@ -50,7 +51,7 @@ func getReleaseRoutes(
 		RelativePath: relPath,
 	}
 
-	routes := make([]*Route, 0)
+	routes := make([]*router.Route, 0)
 
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} -> release.NewReleaseGetHandler
 	getEndpoint := factory.NewAPIEndpoint(
@@ -76,7 +77,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getEndpoint,
 		Handler:  getHandler,
 		Router:   r,
@@ -108,7 +109,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: streamFormEndpoint,
 		Handler:  streamFormHandler,
 		Router:   r,
@@ -138,7 +139,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getControllersEndpoint,
 		Handler:  getControllersHandler,
 		Router:   r,
@@ -168,7 +169,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getComponentsEndpoint,
 		Handler:  getComponentsHandler,
 		Router:   r,
@@ -198,7 +199,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getHistoryEndpoint,
 		Handler:  getHistoryHandler,
 		Router:   r,
@@ -228,7 +229,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getAllPodsEndpoint,
 		Handler:  getAllPodsHandler,
 		Router:   r,
@@ -258,7 +259,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateNotifsEndpoint,
 		Handler:  updateNotifsHandler,
 		Router:   r,
@@ -287,7 +288,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getNotifsEndpoint,
 		Handler:  getNotifsHandler,
 		Router:   r,
@@ -317,7 +318,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateBuildConfigEndpoint,
 		Handler:  updateBuildConfigHandler,
 		Router:   r,
@@ -346,7 +347,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getWebhookEndpoint,
 		Handler:  getWebhookHandler,
 		Router:   r,
@@ -376,7 +377,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createWebhookEndpoint,
 		Handler:  createWebhookHandler,
 		Router:   r,
@@ -406,7 +407,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getStepsEndpoint,
 		Handler:  getStepsHandler,
 		Router:   r,
@@ -436,7 +437,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateStepsEndpoint,
 		Handler:  updateStepsHandler,
 		Router:   r,
@@ -466,7 +467,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createReleaseEndpoint,
 		Handler:  createReleaseHandler,
 		Router:   r,
@@ -496,7 +497,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createAddonEndpoint,
 		Handler:  createAddonHandler,
 		Router:   r,
@@ -526,7 +527,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getGHATemplateEndpoint,
 		Handler:  getGHATemplateHandler,
 		Router:   r,
@@ -558,7 +559,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: rollbackEndpoint,
 		Handler:  rollbackHandler,
 		Router:   r,
@@ -590,7 +591,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: upgradeEndpoint,
 		Handler:  upgradeHandler,
 		Router:   r,
@@ -622,7 +623,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deleteEndpoint,
 		Handler:  deleteHandler,
 		Router:   r,
@@ -653,7 +654,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateImageBatchEndpoint,
 		Handler:  updateImageBatchHandler,
 		Router:   r,
@@ -685,7 +686,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getJobsEndpoint,
 		Handler:  getJobsHandler,
 		Router:   r,
@@ -716,7 +717,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getLatestJobRunEndpoint,
 		Handler:  getLatestJobRunHandler,
 		Router:   r,
@@ -747,7 +748,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getJobsStatusEndpoint,
 		Handler:  getJobsStatusHandler,
 		Router:   r,
@@ -776,7 +777,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createSubdomainEndpoint,
 		Handler:  createSubdomainHandler,
 		Router:   r,
@@ -808,7 +809,7 @@ func getReleaseRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: updateReleaseTagsEndpoint,
 		Handler:  updateReleaseTagsHandler,
 		Router:   r,

+ 39 - 22
api/server/router/router.go

@@ -12,8 +12,10 @@ import (
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz/policy"
 	"github.com/porter-dev/porter/api/server/router/middleware"
+	v1 "github.com/porter-dev/porter/api/server/router/v1"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
@@ -90,13 +92,13 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 			userRegisterer.Children...,
 		)
 
-		routes := [][]*Route{
+		routes := [][]*router.Route{
 			baseRoutes,
 			userRoutes,
 			oauthCallbackRoutes,
 		}
 
-		var allRoutes []*Route
+		var allRoutes []*router.Route
 		for _, r := range routes {
 			allRoutes = append(allRoutes, r...)
 		}
@@ -104,6 +106,39 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 		registerRoutes(config, allRoutes)
 	})
 
+	r.Route("/api/v1", func(r chi.Router) {
+		// set panic middleware for all API endpoints to catch panics
+		r.Use(panicMW.Middleware)
+
+		// set the content type for all API endpoints and log all request info
+		r.Use(middleware.ContentTypeJSON)
+
+		var allRoutes []*router.Route
+
+		v1RegistryRegisterer := v1.NewV1RegistryScopedRegisterer()
+		v1ReleaseRegisterer := v1.NewV1ReleaseScopedRegisterer()
+		v1NamespaceRegisterer := v1.NewV1NamespaceScopedRegisterer(v1ReleaseRegisterer)
+		v1ClusterRegisterer := v1.NewV1ClusterScopedRegisterer(v1NamespaceRegisterer)
+		v1ProjRegisterer := v1.NewV1ProjectScopedRegisterer(
+			v1ClusterRegisterer,
+			v1RegistryRegisterer,
+		)
+
+		v1Routes := v1ProjRegisterer.GetRoutes(
+			r,
+			config,
+			&types.Path{
+				RelativePath: "",
+			},
+			endpointFactory,
+			v1ProjRegisterer.Children...,
+		)
+
+		allRoutes = append(allRoutes, v1Routes...)
+
+		registerRoutes(config, allRoutes)
+	})
+
 	staticFilePath := config.ServerConf.StaticFilePath
 	fs := http.FileServer(http.Dir(staticFilePath))
 
@@ -128,25 +163,7 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	return r
 }
 
-type Route struct {
-	Endpoint *shared.APIEndpoint
-	Handler  http.Handler
-	Router   chi.Router
-}
-
-type Registerer struct {
-	GetRoutes func(
-		r chi.Router,
-		config *config.Config,
-		basePath *types.Path,
-		factory shared.APIEndpointFactory,
-		children ...*Registerer,
-	) []*Route
-
-	Children []*Registerer
-}
-
-func registerRoutes(config *config.Config, routes []*Route) {
+func registerRoutes(config *config.Config, routes []*router.Route) {
 	// Create a new "user-scoped" factory which will create a new user-scoped request
 	// after authentication. Each subsequent http.Handler can lookup the user in context.
 	authNFactory := authn.NewAuthNFactory(config)
@@ -192,7 +209,7 @@ func registerRoutes(config *config.Config, routes []*Route) {
 	releaseFactory := authz.NewReleaseScopedFactory(config)
 
 	// Policy doc loader loads the policy documents for a specific project.
-	policyDocLoader := policy.NewBasicPolicyDocumentLoader(config.Repo.Project())
+	policyDocLoader := policy.NewBasicPolicyDocumentLoader(config.Repo.Project(), config.Repo.Policy())
 
 	// set up logging middleware to log information about the request
 	loggerMw := middleware.NewRequestLoggerMiddleware(config.Logger)

+ 10 - 9
api/server/router/slack_integration.go

@@ -5,11 +5,12 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/slack_integration"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewSlackIntegrationScopedRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewSlackIntegrationScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetSlackIntegrationScopedRoutes,
 		Children:  children,
 	}
@@ -20,8 +21,8 @@ func GetSlackIntegrationScopedRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	routes, projPath := getSlackIntegrationRoutes(r, config, basePath, factory)
 
 	if len(children) > 0 {
@@ -42,7 +43,7 @@ func getSlackIntegrationRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) ([]*Route, *types.Path) {
+) ([]*router.Route, *types.Path) {
 	relPath := "/slack_integrations"
 
 	newPath := &types.Path{
@@ -50,7 +51,7 @@ func getSlackIntegrationRoutes(
 		RelativePath: relPath,
 	}
 
-	routes := make([]*Route, 0)
+	routes := make([]*router.Route, 0)
 
 	// GET /api/projects/{project_id}/slack_integrations -> slack_integration.NewListHandler
 	listEndpoint := factory.NewAPIEndpoint(
@@ -73,7 +74,7 @@ func getSlackIntegrationRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listEndpoint,
 		Handler:  listHandler,
 		Router:   r,
@@ -97,7 +98,7 @@ func getSlackIntegrationRoutes(
 
 	existsHandler := slack_integration.NewSlackIntegrationExists(config)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: existsEndpoint,
 		Handler:  existsHandler,
 		Router:   r,
@@ -121,7 +122,7 @@ func getSlackIntegrationRoutes(
 
 	deleteHandler := slack_integration.NewSlackIntegrationDelete(config)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deleteEndpoint,
 		Handler:  deleteHandler,
 		Router:   r,

+ 23 - 22
api/server/router/user.go

@@ -10,11 +10,12 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/user"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewUserScopedRegisterer(children ...*Registerer) *Registerer {
-	return &Registerer{
+func NewUserScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
 		GetRoutes: GetUserScopedRoutes,
 		Children:  children,
 	}
@@ -25,8 +26,8 @@ func GetUserScopedRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-	children ...*Registerer,
-) []*Route {
+	children ...*router.Registerer,
+) []*router.Route {
 	routes := getUserRoutes(r, config, basePath, factory)
 
 	for _, child := range children {
@@ -45,8 +46,8 @@ func getUserRoutes(
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) []*Route {
-	routes := make([]*Route, 0)
+) []*router.Route {
+	routes := make([]*router.Route, 0)
 
 	// POST /api/welcome -> user.NewUserWelcomeHandler
 	welcomeEndpoint := factory.NewAPIEndpoint(
@@ -67,7 +68,7 @@ func getUserRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: welcomeEndpoint,
 		Handler:  welcomeHandler,
 		Router:   r,
@@ -93,7 +94,7 @@ func getUserRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: cliLoginUserEndpoint,
 		Handler:  cliLoginUserHandler,
 		Router:   r,
@@ -114,7 +115,7 @@ func getUserRoutes(
 
 	logoutUserHandler := user.NewUserLogoutHandler(config)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: logoutUserEndpoint,
 		Handler:  logoutUserHandler,
 		Router:   r,
@@ -138,7 +139,7 @@ func getUserRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: authCheckEndpoint,
 		Handler:  authCheckHandler,
 		Router:   r,
@@ -162,7 +163,7 @@ func getUserRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: deleteUserEndpoint,
 		Handler:  deleteUserHandler,
 		Router:   r,
@@ -187,7 +188,7 @@ func getUserRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: createEndpoint,
 		Handler:  createHandler,
 		Router:   r,
@@ -211,7 +212,7 @@ func getUserRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listEndpoint,
 		Handler:  listHandler,
 		Router:   r,
@@ -232,7 +233,7 @@ func getUserRoutes(
 
 	emailVerifyInitiateHandler := user.NewVerifyEmailInitiateHandler(config)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: emailVerifyInitiateEndpoint,
 		Handler:  emailVerifyInitiateHandler,
 		Router:   r,
@@ -257,7 +258,7 @@ func getUserRoutes(
 		factory.GetDecoderValidator(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: emailVerifyFinalizeEndpoint,
 		Handler:  emailVerifyFinalizeHandler,
 		Router:   r,
@@ -282,7 +283,7 @@ func getUserRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: listTemplatesEndpoint,
 		Handler:  listTemplatesRequest,
 		Router:   r,
@@ -311,7 +312,7 @@ func getUserRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getTemplateEndpoint,
 		Handler:  getTemplateRequest,
 		Router:   r,
@@ -340,7 +341,7 @@ func getUserRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: getTemplateUpgradeNotesEndpoint,
 		Handler:  getTemplateUpgradeNotesRequest,
 		Router:   r,
@@ -365,7 +366,7 @@ func getUserRoutes(
 		config,
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: githubAppOAuthStartEndpoint,
 		Handler:  githubAppOAuthStartHandler,
 		Router:   r,
@@ -390,7 +391,7 @@ func getUserRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: githubAppOAuthCallbackEndpoint,
 		Handler:  githubAppOAuthCallbackHandler,
 		Router:   r,
@@ -415,7 +416,7 @@ func getUserRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: githubAppAccountsEndpoint,
 		Handler:  githubAppAccountsHandler,
 		Router:   r,
@@ -440,7 +441,7 @@ func getUserRoutes(
 		factory.GetResultWriter(),
 	)
 
-	routes = append(routes, &Route{
+	routes = append(routes, &router.Route{
 		Endpoint: canCreateProjectEndpoint,
 		Handler:  canCreateProjectHandler,
 		Router:   r,

+ 172 - 0
api/server/router/v1/cluster.go

@@ -0,0 +1,172 @@
+package v1
+
+import (
+	"fmt"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/cluster"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+func NewV1ClusterScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetV1ClusterScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetV1ClusterScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getV1ClusterRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getV1ClusterRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/clusters/{cluster_id}"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewCreateNamespaceHandler
+	createNamespaceEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/namespaces",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	createNamespaceHandler := cluster.NewCreateNamespaceHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createNamespaceEndpoint,
+		Handler:  createNamespaceHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} -> cluster.NewGetNamespaceHandler
+	getNamespaceEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/namespaces/{%s}", relPath, types.URLParamNamespace),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getNamespaceHandler := cluster.NewGetNamespaceHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getNamespaceEndpoint,
+		Handler:  getNamespaceHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewListNamespacesHandler
+	listNamespacesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/namespaces",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listNamespacesHandler := cluster.NewListNamespacesHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listNamespacesEndpoint,
+		Handler:  listNamespacesHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} -> cluster.NewDeleteNamespaceHandler
+	deleteNamespaceEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/namespaces/{%s}", relPath, types.URLParamNamespace),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	deleteNamespaceHandler := cluster.NewDeleteNamespaceHandler(
+		config,
+		factory.GetDecoderValidator(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteNamespaceEndpoint,
+		Handler:  deleteNamespaceHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 56 - 0
api/server/router/v1/namespace.go

@@ -0,0 +1,56 @@
+package v1
+
+import (
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+func NewV1NamespaceScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetV1NamespaceScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetV1NamespaceScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getV1NamespaceRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getV1NamespaceRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/namespaces/{namespace}"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	return routes, newPath
+}

+ 56 - 0
api/server/router/v1/project.go

@@ -0,0 +1,56 @@
+package v1
+
+import (
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+func NewV1ProjectScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetV1ProjectScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetV1ProjectScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getV1ProjectRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getV1ProjectRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/projects/{project_id}"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	return routes, newPath
+}

+ 259 - 0
api/server/router/v1/registry.go

@@ -0,0 +1,259 @@
+package v1
+
+import (
+	"fmt"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/registry"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+func NewV1RegistryScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetV1RegistryScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetV1RegistryScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getV1RegistryRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getV1RegistryRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/registries"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	// POST /api/v1/projects/{project_id}/registries -> registry.NewRegistryCreateHandler
+	createRegistryEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	createRegistryHandler := registry.NewRegistryCreateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createRegistryEndpoint,
+		Handler:  createRegistryHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/registries/{registry_id} -> registry.NewRegistryGetHandler
+	getEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{registry_id}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.RegistryScope,
+			},
+		},
+	)
+
+	getHandler := registry.NewRegistryGetHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getEndpoint,
+		Handler:  getHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/registries -> registry.NewRegistryListHandler
+	listRegistriesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listRegistriesHandler := registry.NewRegistryListHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listRegistriesEndpoint,
+		Handler:  listRegistriesHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/registries/{registry_id} -> registry.NewRegistryDeleteHandler
+	deleteEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{registry_id}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.RegistryScope,
+			},
+		},
+	)
+
+	deleteHandler := registry.NewRegistryDeleteHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteEndpoint,
+		Handler:  deleteHandler,
+		Router:   r,
+	})
+
+	// POST /api/v1/projects/{project_id}/registries/{registry_id}/repositories -> registry.NewRegistryCreateRepositoryHandler
+	createRepositoryEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{registry_id}/repositories",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.RegistryScope,
+			},
+		},
+	)
+
+	createRepositoryHandler := registry.NewRegistryCreateRepositoryHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createRepositoryEndpoint,
+		Handler:  createRepositoryHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories -> registry.NewRegistryListRepositoriesHandler
+	listRepositoriesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{registry_id}/repositories",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.RegistryScope,
+			},
+		},
+	)
+
+	listRepositoriesHandler := registry.NewRegistryListRepositoriesHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listRepositoriesEndpoint,
+		Handler:  listRepositoriesHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories/* -> registry.NewRegistryListImagesHandler
+	listImagesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/{registry_id}/repositories/%s",
+					relPath,
+					types.URLParamWildcard,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.RegistryScope,
+			},
+		},
+	)
+
+	listImagesHandler := registry.NewRegistryListImagesHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listImagesEndpoint,
+		Handler:  listImagesHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 212 - 0
api/server/router/v1/release.go

@@ -0,0 +1,212 @@
+package v1
+
+import (
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/namespace"
+	"github.com/porter-dev/porter/api/server/handlers/release"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+func NewV1ReleaseScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetV1ReleaseScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetV1ReleaseScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getV1ReleaseRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getV1ReleaseRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/releases"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases -> release.NewCreateReleaseHandler
+	createReleaseEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	createReleaseHandler := release.NewCreateReleaseHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createReleaseEndpoint,
+		Handler:  createReleaseHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} -> release.NewReleaseGetHandler
+	getEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{name}/{version}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.ReleaseScope,
+			},
+		},
+	)
+
+	getHandler := release.NewReleaseGetHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getEndpoint,
+		Handler:  getHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases -> namespace.NewListReleasesHandler
+	listReleasesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	listReleasesHandler := namespace.NewListReleasesHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listReleasesEndpoint,
+		Handler:  listReleasesHandler,
+		Router:   r,
+	})
+
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} ->
+	// release.NewUpgradeReleaseHandler
+	upgradeEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{name}/{version}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.ReleaseScope,
+			},
+		},
+	)
+
+	upgradeHandler := release.NewUpgradeReleaseHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: upgradeEndpoint,
+		Handler:  upgradeHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} ->
+	// release.NewDeleteReleaseHandler
+	deleteEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.ReleaseScope,
+			},
+		},
+	)
+
+	deleteHandler := release.NewDeleteReleaseHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteEndpoint,
+		Handler:  deleteHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

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

@@ -92,6 +92,31 @@ func (e *ErrPassThroughToClient) GetStatusCode() int {
 	return e.statusCode
 }
 
+// errors that denote that a resource was not found
+type ErrNotFound struct {
+	err error
+}
+
+func NewErrNotFound(err error) RequestError {
+	return &ErrNotFound{err}
+}
+
+func (e *ErrNotFound) Error() string {
+	return e.err.Error()
+}
+
+func (e *ErrNotFound) InternalError() string {
+	return e.err.Error()
+}
+
+func (e *ErrNotFound) ExternalError() string {
+	return "Resource not found."
+}
+
+func (e *ErrNotFound) GetStatusCode() int {
+	return http.StatusNotFound
+}
+
 type ErrorOpts struct {
 	Code uint
 }

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

@@ -0,0 +1,28 @@
+package router
+
+import (
+	"net/http"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type Route struct {
+	Endpoint *shared.APIEndpoint
+	Handler  http.Handler
+	Router   chi.Router
+}
+
+type Registerer struct {
+	GetRoutes func(
+		r chi.Router,
+		config *config.Config,
+		basePath *types.Path,
+		factory shared.APIEndpointFactory,
+		children ...*Registerer,
+	) []*Route
+
+	Children []*Registerer
+}

+ 28 - 0
api/types/api_token.go

@@ -0,0 +1,28 @@
+package types
+
+import "time"
+
+const URLParamTokenID URLParam = "api_token_id"
+
+type APITokenMeta struct {
+	CreatedAt time.Time `json:"created_at"`
+	ExpiresAt time.Time `json:"expires_at"`
+
+	ID         string `json:"id"`
+	PolicyName string `json:"policy_name"`
+	PolicyUID  string `json:"policy_uid"`
+	Name       string `json:"name"`
+}
+
+type APIToken struct {
+	*APITokenMeta
+
+	Policy []*PolicyDocument `json:"policy"`
+	Token  string            `json:"token,omitempty"`
+}
+
+type CreateAPIToken struct {
+	PolicyUID string    `json:"policy_uid" form:"required"`
+	ExpiresAt time.Time `json:"expires_at"`
+	Name      string    `json:"name" form:"required"`
+}

+ 88 - 0
api/types/policy.go

@@ -1,5 +1,7 @@
 package types
 
+import "time"
+
 type PermissionScope string
 
 const (
@@ -57,6 +59,32 @@ var AdminPolicy = []*PolicyDocument{
 	{
 		Scope: ProjectScope,
 		Verbs: ReadWriteVerbGroup(),
+		Children: map[PermissionScope]*PolicyDocument{
+			ClusterScope: {
+				Scope: ClusterScope,
+				Verbs: ReadWriteVerbGroup(),
+			},
+			RegistryScope: {
+				Scope: RegistryScope,
+				Verbs: ReadWriteVerbGroup(),
+			},
+			HelmRepoScope: {
+				Scope: HelmRepoScope,
+				Verbs: ReadWriteVerbGroup(),
+			},
+			GitInstallationScope: {
+				Scope: GitInstallationScope,
+				Verbs: ReadWriteVerbGroup(),
+			},
+			InfraScope: {
+				Scope: InfraScope,
+				Verbs: ReadWriteVerbGroup(),
+			},
+			SettingsScope: {
+				Scope: SettingsScope,
+				Verbs: ReadWriteVerbGroup(),
+			},
+		},
 	},
 }
 
@@ -65,6 +93,26 @@ var DeveloperPolicy = []*PolicyDocument{
 		Scope: ProjectScope,
 		Verbs: ReadWriteVerbGroup(),
 		Children: map[PermissionScope]*PolicyDocument{
+			ClusterScope: {
+				Scope: ClusterScope,
+				Verbs: ReadWriteVerbGroup(),
+			},
+			RegistryScope: {
+				Scope: RegistryScope,
+				Verbs: ReadWriteVerbGroup(),
+			},
+			HelmRepoScope: {
+				Scope: HelmRepoScope,
+				Verbs: ReadWriteVerbGroup(),
+			},
+			GitInstallationScope: {
+				Scope: GitInstallationScope,
+				Verbs: ReadWriteVerbGroup(),
+			},
+			InfraScope: {
+				Scope: InfraScope,
+				Verbs: ReadWriteVerbGroup(),
+			},
 			SettingsScope: {
 				Scope: SettingsScope,
 				Verbs: ReadVerbGroup(),
@@ -78,6 +126,26 @@ var ViewerPolicy = []*PolicyDocument{
 		Scope: ProjectScope,
 		Verbs: ReadVerbGroup(),
 		Children: map[PermissionScope]*PolicyDocument{
+			ClusterScope: {
+				Scope: ClusterScope,
+				Verbs: ReadVerbGroup(),
+			},
+			RegistryScope: {
+				Scope: RegistryScope,
+				Verbs: ReadVerbGroup(),
+			},
+			HelmRepoScope: {
+				Scope: HelmRepoScope,
+				Verbs: ReadVerbGroup(),
+			},
+			GitInstallationScope: {
+				Scope: GitInstallationScope,
+				Verbs: ReadVerbGroup(),
+			},
+			InfraScope: {
+				Scope: InfraScope,
+				Verbs: ReadVerbGroup(),
+			},
 			SettingsScope: {
 				Scope: SettingsScope,
 				Verbs: []APIVerb{},
@@ -85,3 +153,23 @@ var ViewerPolicy = []*PolicyDocument{
 		},
 	},
 }
+
+type CreatePolicy struct {
+	Name   string            `json:"name" form:"required"`
+	Policy []*PolicyDocument `json:"policy" form:"required"`
+}
+
+const URLParamPolicyID URLParam = "policy_id"
+
+type APIPolicyMeta struct {
+	CreatedAt time.Time `json:"created_at"`
+	UpdatedAt time.Time `json:"updated_at"`
+	ProjectID uint      `json:"project_id"`
+	UID       string    `json:"uid"`
+	Name      string    `json:"name"`
+}
+
+type APIPolicy struct {
+	*APIPolicyMeta
+	Policy []*PolicyDocument `json:"policy"`
+}

+ 1 - 0
api/types/project.go

@@ -7,6 +7,7 @@ type Project struct {
 	PreviewEnvsEnabled  bool    `json:"preview_envs_enabled"`
 	RDSDatabasesEnabled bool    `json:"enable_rds_databases"`
 	ManagedInfraEnabled bool    `json:"managed_infra_enabled"`
+	APITokensEnabled    bool    `json:"api_tokens_enabled"`
 }
 
 type CreateProjectRequest struct {

+ 322 - 0
dashboard/src/main/home/project-settings/APITokensSection.tsx

@@ -0,0 +1,322 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+
+import { InviteType } from "shared/types";
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import Loading from "components/Loading";
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
+import Heading from "components/form-components/Heading";
+import CopyToClipboard from "components/CopyToClipboard";
+import { Column } from "react-table";
+import Table from "components/Table";
+import RadioSelector from "components/RadioSelector";
+import CreateAPITokenForm from "./api-tokens/CreateAPITokenForm";
+import TokenList from "./api-tokens/TokenList";
+import SaveButton from "components/SaveButton";
+
+type Props = {};
+
+export type APITokenMeta = {
+  created_at: string;
+  updated_at: string;
+  expires_at: string;
+  id: string;
+  policy_name: string;
+  policy_uid: string;
+  name: string;
+};
+
+export type APIToken = APITokenMeta & {
+  token?: string;
+};
+
+const APITokensSection: React.FunctionComponent<Props> = ({}) => {
+  const { currentProject } = useContext(Context);
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [apiTokens, setAPITokens] = useState<Array<APITokenMeta>>([]);
+  const [shouldCreate, setShouldCreate] = useState(false);
+  const [expanded, setExpanded] = useState("");
+
+  useEffect(() => {
+    api
+      .listAPITokens("<token>", {}, { project_id: currentProject.id })
+      .then(({ data }) => {
+        setAPITokens(data);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, [currentProject, shouldCreate]);
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  if (shouldCreate) {
+    return (
+      <CreateAPITokenForm
+        onCreate={() => setShouldCreate(false)}
+        back={() => setShouldCreate(false)}
+      />
+    );
+  }
+
+  const getTokenList = () => {
+    return apiTokens.map((token) => {
+      return <div>{token.name}</div>;
+    });
+  };
+
+  const revokeToken = (id: string) => {
+    setAPITokens((toks) => toks.filter((tok) => tok.id !== id));
+  };
+
+  return (
+    <APITokensSectionWrapper>
+      <Heading isAtTop={true}>API Tokens</Heading>
+      <Helper>
+        This displays all active API tokens, which are tokens that have not
+        expired and have not been revoked.
+      </Helper>
+      <TokenListWrapper>
+        <TokenList
+          tokens={apiTokens}
+          setExpanded={setExpanded}
+          expanded={expanded}
+          revokeToken={revokeToken}
+        />
+      </TokenListWrapper>
+      <SaveButtonContainer>
+        <SaveButton
+          makeFlush={true}
+          clearPosition={true}
+          onClick={() => setShouldCreate(true)}
+        >
+          <i className="material-icons">add</i>
+          Create API Token
+        </SaveButton>
+      </SaveButtonContainer>
+    </APITokensSectionWrapper>
+  );
+};
+
+export default APITokensSection;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  width: 70px;
+  float: right;
+  justify-content: space-between;
+`;
+
+const DeleteButton = styled.div`
+  display: flex;
+  visibility: ${(props: { invis?: boolean }) =>
+    props.invis ? "hidden" : "visible"};
+  align-items: center;
+  justify-content: center;
+  width: 30px;
+  float: right;
+  height: 30px;
+  :hover {
+    background: #ffffff11;
+    border-radius: 20px;
+    cursor: pointer;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #ffffff44;
+    border-radius: 20px;
+  }
+`;
+
+const SettingsButton = styled(DeleteButton)`
+  margin-right: -60px;
+`;
+
+const Role = styled.div`
+  text-transform: capitalize;
+  margin-right: 50px;
+`;
+
+const RoleSelectorWrapper = styled.div`
+  font-size: 14px;
+`;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 200px;
+  display: flex;
+  align-items: center;
+  margin-top: 23px;
+  justify-content: center;
+  background: #ffffff11;
+  border-radius: 5px;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const ButtonWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const InputRowWrapper = styled.div`
+  width: 40%;
+`;
+
+const CopyButton = styled.div`
+  visibility: ${(props: { invis?: boolean }) =>
+    props.invis ? "hidden" : "visible"};
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
+  margin: 8px 0 8px 12px;
+  float: right;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 120px;
+  cursor: pointer;
+  height: 30px;
+  border-radius: 5px;
+  border: 1px solid #ffffff20;
+  background-color: #ffffff10;
+  overflow: hidden;
+  transition: all 0.1s ease-out;
+  :hover {
+    border: 1px solid #ffffff66;
+    background-color: #ffffff20;
+  }
+`;
+
+const NewLinkButton = styled(CopyButton)`
+  border: none;
+  width: auto;
+  float: none;
+  display: block;
+  margin: unset;
+  background-color: transparent;
+  :hover {
+    border: none;
+    background-color: transparent;
+  }
+`;
+
+const InviteButton = styled.div<{ disabled: boolean }>`
+  height: 35px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 0 15px;
+  margin-top: 13px;
+  text-align: left;
+  float: left;
+  margin-left: 0;
+  justify-content: center;
+  border: 0;
+  border-radius: 5px;
+  background: ${(props) => (!props.disabled ? "#616FEEcc" : "#aaaabb")};
+  box-shadow: ${(props) =>
+    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
+  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+  margin-bottom: 10px;
+`;
+
+const Url = styled.a`
+  max-width: 300px;
+  font-size: 13px;
+  user-select: text;
+  font-weight: 400;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  > i {
+    margin-left: 10px;
+    font-size: 15px;
+  }
+
+  > span {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  :hover {
+    cursor: pointer;
+  }
+`;
+
+const Invalid = styled.div`
+  color: #f5cb42;
+  margin-left: 15px;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;
+
+const Status = styled.div<{ status: "accepted" | "expired" | "pending" }>`
+  padding: 5px 10px;
+  margin-right: 12px;
+  background: ${(props) => {
+    if (props.status === "accepted") return "#38a88a";
+    if (props.status === "expired") return "#cc3d42";
+    if (props.status === "pending") return "#ffffff11";
+  }};
+  font-size: 13px;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  max-height: 25px;
+  max-width: 80px;
+  text-transform: capitalize;
+  font-weight: 400;
+  user-select: none;
+`;
+
+const TokenListWrapper = styled.div`
+  width: 100%;
+  max-height: 500px;
+  overflow-y: auto;
+`;
+
+const APITokensSectionWrapper = styled.div`
+  width: 60%;
+  min-width: 600px;
+`;
+
+const ControlRow = styled.div`
+  width: 100%;
+  display: flex;
+  margin-left: auto;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const SaveButtonContainer = styled.div`
+  position: relative;
+  margin-top: 20px;
+`;

+ 11 - 0
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -12,6 +12,7 @@ import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { RouteComponentProps, withRouter, WithRouterProps } from "react-router";
 import { getQueryParam } from "shared/routing";
 import BillingPage from "./BillingPage";
+import APITokensSection from "./APITokensSection";
 
 type PropsType = RouteComponentProps & WithAuthProps & {};
 
@@ -72,6 +73,14 @@ class ProjectSettings extends Component<PropsType, StateType> {
           label: "Billing",
         });
       }
+
+      if (currentProject?.api_tokens_enabled) {
+        tabOptions.push({
+          value: "api-tokens",
+          label: "API Tokens",
+        });
+      }
+
       tabOptions.push({
         value: "additional-settings",
         label: "Additional Settings",
@@ -100,6 +109,8 @@ class ProjectSettings extends Component<PropsType, StateType> {
 
     if (this.state.currentTab === "manage-access") {
       return <InvitePage />;
+    } else if (this.state.currentTab === "api-tokens") {
+      return <APITokensSection />;
     } else {
       return (
         <>

+ 592 - 0
dashboard/src/main/home/project-settings/api-tokens/CreateAPITokenForm.tsx

@@ -0,0 +1,592 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+
+import { InviteType } from "shared/types";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import backArrow from "assets/back_arrow.png";
+
+import Loading from "components/Loading";
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
+import Heading from "components/form-components/Heading";
+import CopyToClipboard from "components/CopyToClipboard";
+import { Column } from "react-table";
+import Table from "components/Table";
+import RadioSelector from "components/RadioSelector";
+import SelectRow from "components/form-components/SelectRow";
+import SaveButton from "components/SaveButton";
+import { APIToken } from "../APITokensSection";
+import CustomPolicyForm from "./CustomPolicyForm";
+import { PolicyDocType, Verbs } from "shared/auth/types";
+
+type Props = {
+  onCreate: () => void;
+  back: () => void;
+};
+
+const getDateValue = (option: string): string => {
+  let now = new Date();
+
+  switch (option) {
+    case "oneday":
+      return new Date(new Date().setHours(now.getHours() + 24)).toISOString();
+    case "threedays":
+      return new Date(
+        new Date().setHours(now.getHours() + 24 * 3)
+      ).toISOString();
+    case "sevendays":
+      return new Date(
+        new Date().setHours(now.getHours() + 24 * 7)
+      ).toISOString();
+    case "thirtydays":
+      return new Date(
+        new Date().setHours(now.getHours() + 24 * 30)
+      ).toISOString();
+    case "oneyear":
+      return new Date(
+        new Date().setHours(now.getHours() + 24 * 365)
+      ).toISOString();
+    default:
+      return "";
+  }
+};
+
+export const getDateOptions = (): { value: string; label: string }[] => {
+  return [
+    {
+      label: "1 Day",
+      value: "oneday",
+    },
+    {
+      label: "3 Days",
+      value: "threedays",
+    },
+    {
+      label: "7 Days",
+      value: "sevendays",
+    },
+    {
+      label: "30 Days",
+      value: "thirtydays",
+    },
+    {
+      label: "1 Year",
+      value: "oneyear",
+    },
+  ];
+};
+
+export type ScopeOption = {
+  value: string;
+  label: string;
+};
+
+const CreateAPITokenForm: React.FunctionComponent<Props> = ({
+  onCreate,
+  back,
+}) => {
+  const { currentProject } = useContext(Context);
+  const [apiTokenName, setAPITokenName] = useState("");
+  const dateOptions = getDateOptions();
+  const [expiration, setExpiration] = useState("thirtydays");
+  const [policy, setPolicy] = useState("developer");
+  const [createdToken, setCreatedToken] = useState<APIToken>(null);
+  const [copied, setCopied] = useState(false);
+  const [selectedClusterFields, setSelectedClusterFields] = useState<
+    ScopeOption[]
+  >([]);
+  const [selectedRegistryFields, setSelectedRegistryFields] = useState<
+    ScopeOption[]
+  >([]);
+  const [selectedInfraFields, setSelectedInfraFields] = useState<ScopeOption[]>(
+    []
+  );
+  const [selectedSettingsFields, setSelectedSettingsFields] = useState<
+    ScopeOption[]
+  >([]);
+  const [policyName, setPolicyName] = useState("");
+
+  const createToken = () => {
+    let cb = (policyUID: string) => {
+      api
+        .createAPIToken(
+          "<token>",
+          {
+            name: apiTokenName,
+            expires_at: getDateValue(expiration),
+            policy_uid: policyUID || policy,
+          },
+          { project_id: currentProject.id }
+        )
+        .then(({ data }) => {
+          setCreatedToken(data);
+        })
+        .catch((err) => {
+          console.error(err);
+        });
+    };
+
+    if (policy == "admin" || policy == "developer" || policy == "viewer") {
+      cb(policy);
+    } else {
+      createPolicy(cb);
+    }
+  };
+
+  const getVerbsForScope = (
+    scopeVal: string,
+    allSelected: string[]
+  ): Verbs[] => {
+    if (scopeVal.includes("read")) {
+      return allSelected.includes(scopeVal) ? ["get", "list"] : [];
+    } else if (scopeVal.includes("write")) {
+      return allSelected.includes(scopeVal)
+        ? ["create", "update", "delete"]
+        : [];
+    } else {
+      return [];
+    }
+  };
+
+  const createPolicy = (cb?: (id: string) => void) => {
+    let allSelectedFields = selectedClusterFields.concat(
+      ...selectedRegistryFields,
+      ...selectedInfraFields,
+      ...selectedSettingsFields
+    );
+
+    let allSelectedValues = allSelectedFields.map((field) => field.value);
+
+    // construct the policy
+    let policy: PolicyDocType = {
+      scope: "project",
+      verbs: [],
+      children: {
+        cluster: {
+          scope: "cluster",
+          verbs: [],
+          children: {
+            namespace: {
+              scope: "namespace",
+              verbs: getVerbsForScope(
+                "namespace-read",
+                allSelectedValues
+              ).concat(getVerbsForScope("namespace-write", allSelectedValues)),
+              children: {
+                release: {
+                  scope: "release",
+                  verbs: getVerbsForScope(
+                    "release-read",
+                    allSelectedValues
+                  ).concat(
+                    getVerbsForScope("release-write", allSelectedValues)
+                  ),
+                },
+              },
+            },
+          },
+        },
+        registry: {
+          scope: "registry",
+          verbs: getVerbsForScope("registry-read", allSelectedValues).concat(
+            getVerbsForScope("registry-write", allSelectedValues)
+          ),
+        },
+        infra: {
+          scope: "infra",
+          verbs: getVerbsForScope("infra-read", allSelectedValues).concat(
+            getVerbsForScope("infra-write", allSelectedValues)
+          ),
+        },
+        settings: {
+          scope: "settings",
+          verbs: getVerbsForScope("settings-read", allSelectedValues).concat(
+            getVerbsForScope("settings-write", allSelectedValues)
+          ),
+        },
+      },
+    };
+
+    api
+      .createPolicy(
+        "<token>",
+        {
+          name: policyName,
+          policy: [policy],
+        },
+        { project_id: currentProject.id }
+      )
+      .then(({ data }) => {
+        console.log("data response is", data);
+        cb && cb(data?.uid);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  };
+
+  if (createdToken != null) {
+    return (
+      <CreateTokenWrapper>
+        <ControlRow>
+          <Heading isAtTop={true}>API token created successfully!</Heading>
+          <BackButton>
+            <BackButtonImg src={backArrow} />
+          </BackButton>
+        </ControlRow>
+        <Helper>
+          Please copy this token and store it in a secure location. This token
+          will only be shown once:
+        </Helper>
+        <TokenDisplayBlock>
+          <CodeBlock>{createdToken.token}</CodeBlock>
+          <CopyToClipboard
+            as={CopyTokenButton}
+            text={createdToken.token}
+            onSuccess={() => setCopied(true)}
+          >
+            <i className="material-icons-outlined">
+              {copied ? "check" : "content_copy"}
+            </i>
+          </CopyToClipboard>
+        </TokenDisplayBlock>
+        <SaveButton
+          text="Continue"
+          onClick={onCreate}
+          makeFlush={true}
+          clearPosition={true}
+        />
+      </CreateTokenWrapper>
+    );
+  }
+
+  const renderPolicyContents = () => {
+    if (policy === "custom") {
+      return (
+        <CustomPolicyForm
+          selectedClusterFields={selectedClusterFields}
+          setSelectedClusterFields={setSelectedClusterFields}
+          selectedRegistryFields={selectedRegistryFields}
+          setSelectedRegistryFields={setSelectedRegistryFields}
+          selectedInfraFields={selectedInfraFields}
+          setSelectedInfraFields={setSelectedInfraFields}
+          selectedSettingsFields={selectedSettingsFields}
+          setSelectedSettingsFields={setSelectedSettingsFields}
+          policyName={policyName}
+          setPolicyName={setPolicyName}
+        />
+      );
+    }
+  };
+
+  return (
+    <CreateTokenWrapper>
+      <ControlRow>
+        <Heading isAtTop={true}>Create API Token</Heading>
+        <BackButton onClick={back}>
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+      </ControlRow>
+      <InputRow
+        value={apiTokenName}
+        type="text"
+        setValue={(newName: string) => setAPITokenName(newName)}
+        label="API Token Name"
+        width="100%"
+        placeholder="ex: api-token-admin"
+        isRequired={true}
+      />
+      <SelectRow
+        value={expiration}
+        label="Expiration"
+        setActiveValue={setExpiration}
+        options={dateOptions}
+      />
+      <SelectRow
+        value={policy}
+        label="Role"
+        setActiveValue={setPolicy}
+        options={[
+          {
+            label: "Admin",
+            value: "admin",
+          },
+          {
+            label: "Developer",
+            value: "developer",
+          },
+          {
+            label: "Viewer",
+            value: "viewer",
+          },
+          {
+            label: "Custom Role",
+            value: "custom",
+          },
+        ]}
+      />
+      {renderPolicyContents()}
+      <SaveButton
+        text="Create Token"
+        onClick={createToken}
+        makeFlush={true}
+        clearPosition={true}
+        disabled={!apiTokenName}
+      />
+    </CreateTokenWrapper>
+  );
+};
+
+export default CreateAPITokenForm;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  width: 70px;
+  float: right;
+  justify-content: space-between;
+`;
+
+const DeleteButton = styled.div`
+  display: flex;
+  visibility: ${(props: { invis?: boolean }) =>
+    props.invis ? "hidden" : "visible"};
+  align-items: center;
+  justify-content: center;
+  width: 30px;
+  float: right;
+  height: 30px;
+  :hover {
+    background: #ffffff11;
+    border-radius: 20px;
+    cursor: pointer;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #ffffff44;
+    border-radius: 20px;
+  }
+`;
+
+const SettingsButton = styled(DeleteButton)`
+  margin-right: -60px;
+`;
+
+const Role = styled.div`
+  text-transform: capitalize;
+  margin-right: 50px;
+`;
+
+const RoleSelectorWrapper = styled.div`
+  font-size: 14px;
+`;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 200px;
+  display: flex;
+  align-items: center;
+  margin-top: 23px;
+  justify-content: center;
+  background: #ffffff11;
+  border-radius: 5px;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const ButtonWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const CreateTokenWrapper = styled.div`
+  width: 60%;
+  min-width: 600px;
+  position: relative;
+`;
+
+const CopyButton = styled.div`
+  visibility: ${(props: { invis?: boolean }) =>
+    props.invis ? "hidden" : "visible"};
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
+  margin: 8px 0 8px 12px;
+  float: right;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 120px;
+  cursor: pointer;
+  height: 30px;
+  border-radius: 5px;
+  border: 1px solid #ffffff20;
+  background-color: #ffffff10;
+  overflow: hidden;
+  transition: all 0.1s ease-out;
+  :hover {
+    border: 1px solid #ffffff66;
+    background-color: #ffffff20;
+  }
+`;
+
+const NewLinkButton = styled(CopyButton)`
+  border: none;
+  width: auto;
+  float: none;
+  display: block;
+  margin: unset;
+  background-color: transparent;
+  :hover {
+    border: none;
+    background-color: transparent;
+  }
+`;
+
+const InviteButton = styled.div<{ disabled: boolean }>`
+  height: 35px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 0 15px;
+  margin-top: 13px;
+  text-align: left;
+  float: left;
+  margin-left: 0;
+  justify-content: center;
+  border: 0;
+  border-radius: 5px;
+  background: ${(props) => (!props.disabled ? "#616FEEcc" : "#aaaabb")};
+  box-shadow: ${(props) =>
+    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
+  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+  margin-bottom: 10px;
+`;
+
+const Url = styled.a`
+  max-width: 300px;
+  font-size: 13px;
+  user-select: text;
+  font-weight: 400;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  > i {
+    margin-left: 10px;
+    font-size: 15px;
+  }
+
+  > span {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  :hover {
+    cursor: pointer;
+  }
+`;
+
+const Invalid = styled.div`
+  color: #f5cb42;
+  margin-left: 15px;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;
+
+const Status = styled.div<{ status: "accepted" | "expired" | "pending" }>`
+  padding: 5px 10px;
+  margin-right: 12px;
+  background: ${(props) => {
+    if (props.status === "accepted") return "#38a88a";
+    if (props.status === "expired") return "#cc3d42";
+    if (props.status === "pending") return "#ffffff11";
+  }};
+  font-size: 13px;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  max-height: 25px;
+  max-width: 80px;
+  text-transform: capitalize;
+  font-weight: 400;
+  user-select: none;
+`;
+
+const TokenDisplayBlock = styled.div`
+  display: flex;
+  justify-content: space-between;
+  width: 100%;
+  background-color: #1b1d26;
+  margin-bottom: 20px;
+`;
+
+const CopyTokenButton = styled.div`
+  height: 30px;
+  padding: 10px;
+  cursor: pointer;
+
+  > i {
+    margin-left: 10px;
+    font-size: 15px;
+  }
+`;
+
+const CodeBlock = styled.div`
+  display: inline-block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  user-select: text;
+  overflow: auto;
+  padding: 10px;
+  white-space: nowrap;
+  border-right: 10px solid #1b1d26;
+`;
+
+const ControlRow = styled.div`
+  width: 100%;
+  display: flex;
+  margin-left: auto;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;

+ 135 - 0
dashboard/src/main/home/project-settings/api-tokens/CustomPolicyForm.tsx

@@ -0,0 +1,135 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+
+import { InviteType } from "shared/types";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import backArrow from "assets/back_arrow.png";
+
+import Loading from "components/Loading";
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
+import Heading from "components/form-components/Heading";
+import CopyToClipboard from "components/CopyToClipboard";
+import { Column } from "react-table";
+import Table from "components/Table";
+import RadioSelector from "components/RadioSelector";
+import SelectRow from "components/form-components/SelectRow";
+import SaveButton from "components/SaveButton";
+import { APIToken } from "../APITokensSection";
+import CheckboxList from "components/form-components/CheckboxList";
+import { PolicyDocType } from "shared/auth/types";
+import { ScopeOption } from "./CreateAPITokenForm";
+
+type Props = {
+  selectedClusterFields: ScopeOption[];
+  setSelectedClusterFields: (scope: ScopeOption[]) => void;
+  selectedRegistryFields: ScopeOption[];
+  setSelectedRegistryFields: (scope: ScopeOption[]) => void;
+  selectedInfraFields: ScopeOption[];
+  setSelectedInfraFields: (scope: ScopeOption[]) => void;
+  selectedSettingsFields: ScopeOption[];
+  setSelectedSettingsFields: (scope: ScopeOption[]) => void;
+  policyName: string;
+  setPolicyName: (name: string) => void;
+};
+
+const clusterSettingsOptions = [
+  { value: "namespace-read", label: "Read access to namespaces" },
+  { value: "namespace-write", label: "Write access to namespaces" },
+  {
+    value: "release-read",
+    label: "Read access to releases (applications, jobs, other helm charts)",
+  },
+  {
+    value: "release-write",
+    label: "Write access to releases (applications, jobs, other helm charts)",
+  },
+];
+
+const registrySettingsOptions = [
+  { value: "registry-read", label: "Read access to registries" },
+  { value: "registry-write", label: "Write access to registries" },
+];
+
+const infraSettingsOptions = [
+  {
+    value: "infra-read",
+    label:
+      "Read access to infrastructure (provisioned clusters, registries, and databases)",
+  },
+  {
+    value: "infra-write",
+    label:
+      "Write access to infrastructure (provisioned clusters, registries, and databases)",
+  },
+];
+
+const projectSettingsOptions = [
+  {
+    value: "settings-read",
+    label: "Read access to settings (project invites, API tokens, billing)",
+  },
+  {
+    value: "settings-write",
+    label: "Write access to settings (project invites, API tokens, billing)",
+  },
+];
+
+const CustomPolicyForm: React.FunctionComponent<Props> = ({
+  selectedClusterFields,
+  setSelectedClusterFields,
+  selectedRegistryFields,
+  setSelectedRegistryFields,
+  selectedInfraFields,
+  setSelectedInfraFields,
+  selectedSettingsFields,
+  setSelectedSettingsFields,
+  policyName,
+  setPolicyName,
+}) => {
+  return (
+    <CustomPolicyFormWrapper>
+      <Heading>Custom Role Settings</Heading>
+      <InputRow
+        value={policyName}
+        type="text"
+        setValue={(newName: string) => setPolicyName(newName)}
+        label="Role Name"
+        width="100%"
+        placeholder="ex: custom-developer-role"
+        isRequired={true}
+      />
+      <Helper color="white">Cluster Access:</Helper>
+      <CheckboxList
+        options={clusterSettingsOptions}
+        selected={selectedClusterFields}
+        setSelected={setSelectedClusterFields}
+      />
+      <Helper color="white">Registry Access:</Helper>
+      <CheckboxList
+        options={registrySettingsOptions}
+        selected={selectedRegistryFields}
+        setSelected={setSelectedRegistryFields}
+      />
+      <Helper color="white">Infra Access:</Helper>
+      <CheckboxList
+        options={infraSettingsOptions}
+        selected={selectedInfraFields}
+        setSelected={setSelectedInfraFields}
+      />
+      <Helper color="white">Settings Access:</Helper>
+      <CheckboxList
+        options={projectSettingsOptions}
+        selected={selectedSettingsFields}
+        setSelected={setSelectedSettingsFields}
+      />
+    </CustomPolicyFormWrapper>
+  );
+};
+
+export default CustomPolicyForm;
+
+const CustomPolicyFormWrapper = styled.div`
+  margin-bottom: 20px;
+`;

+ 228 - 0
dashboard/src/main/home/project-settings/api-tokens/TokenList.tsx

@@ -0,0 +1,228 @@
+import Description from "components/Description";
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import React, { useContext, useEffect, useState } from "react";
+import { Context } from "shared/Context";
+
+import api from "shared/api";
+import { readableDate } from "shared/string_utils";
+import styled from "styled-components";
+import { APIToken, APITokenMeta } from "../APITokensSection";
+
+type Props = {
+  tokens: APITokenMeta[];
+  setExpanded: (id: string) => void;
+  expanded: string;
+  revokeToken: (id: string) => void;
+};
+
+const TokenList: React.FunctionComponent<Props> = (props) => {
+  const [expandedTok, setExpandedTok] = useState<APIToken>(null);
+  const [isLoading, setIsLoading] = useState(false);
+
+  const { currentProject } = useContext(Context);
+
+  useEffect(() => {
+    if (props.expanded != "") {
+      setIsLoading(true);
+
+      api
+        .getAPIToken(
+          "<token>",
+          {},
+          { project_id: currentProject.id, token: props.expanded }
+        )
+        .then(({ data }) => {
+          setExpandedTok(data);
+          setIsLoading(false);
+        })
+        .catch((err) => {
+          console.error(err);
+        });
+    }
+  }, [currentProject, props.expanded]);
+
+  const revokeAPIToken = (id: string) => {
+    setIsLoading(true);
+
+    api
+      .revokeAPIToken(
+        "<token>",
+        {},
+        { project_id: currentProject.id, token: id }
+      )
+      .then(() => {
+        props.revokeToken(id);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  };
+
+  const renderExpandedContents = () => {
+    if (isLoading || !expandedTok) {
+      return (
+        <Placeholder>
+          <Loading />
+        </Placeholder>
+      );
+    }
+
+    return (
+      <StyledExpandedToken>
+        <Description margin="0">
+          Created at {readableDate(expandedTok.created_at)}. Using token policy:{" "}
+          {expandedTok.policy_name}.
+        </Description>
+        <RevokeAccessButtonWrapper>
+          <RevokeAccessButton onClick={() => revokeAPIToken(expandedTok.id)}>
+            Revoke Token
+          </RevokeAccessButton>
+        </RevokeAccessButtonWrapper>
+      </StyledExpandedToken>
+    );
+  };
+
+  return (
+    <>
+      {props.tokens.map((token) => {
+        if (props.expanded == token.id) {
+          return (
+            <TokenWrapper>
+              <TokenHeader
+                key={token.id}
+                onClick={() => {
+                  setIsLoading(false);
+                  props.setExpanded("");
+                }}
+              >
+                <Flex>
+                  <i className="material-icons">token</i>
+                  {token.name}
+                </Flex>
+                <Right>
+                  <RightHeaderSection>
+                    <TimestampSection>
+                      Expires at {readableDate(token.expires_at)}
+                    </TimestampSection>
+                    <i className="material-icons">expand_less</i>
+                  </RightHeaderSection>
+                </Right>
+              </TokenHeader>
+              {renderExpandedContents()}
+            </TokenWrapper>
+          );
+        }
+
+        return (
+          <TokenWrapper>
+            <TokenHeader
+              key={token.id}
+              onClick={() => {
+                setIsLoading(true);
+                props.setExpanded(token.id);
+              }}
+            >
+              <Flex>
+                <i className="material-icons">token</i>
+                {token.name}
+              </Flex>
+              <Right>
+                <RightHeaderSection>
+                  <TimestampSection>
+                    Expires at {readableDate(token.expires_at)}
+                  </TimestampSection>
+                  <i className="material-icons">expand_more</i>
+                </RightHeaderSection>
+              </Right>
+            </TokenHeader>
+          </TokenWrapper>
+        );
+      })}
+    </>
+  );
+};
+
+export default TokenList;
+
+const TokenWrapper = styled.div`
+  color: #ffffff55;
+  background: #ffffff01;
+  border: 1px solid #aaaabbaa;
+  font-size: 13px;
+  border-radius: 5px;
+  cursor: pointer;
+  margin: 8px 0;
+  :hover {
+    border: 1px solid #aaaabb;
+  }
+`;
+
+const TokenHeader = styled.div`
+  display: flex;
+  align-items: center;
+  padding: 12px 15px;
+  justify-content: space-between;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  color: #ffffff;
+  align-items: center;
+  > i {
+    color: #aaaabb;
+    font-size: 20px;
+    margin-right: 10px;
+  }
+`;
+
+const Right = styled.div`
+  text-align: right;
+`;
+
+const StyledExpandedToken = styled.div`
+  padding: 12px 20px;
+  max-height: 300px;
+  overflow-y: auto;
+`;
+
+const ExpandIconContainer = styled.div`
+  width: 30px;
+  margin-left: 10px;
+  padding-top: 2px;
+`;
+
+const RightHeaderSection = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+`;
+
+const TimestampSection = styled.div`
+  margin-right: 8px;
+`;
+
+const RevokeAccessButton = styled.div`
+  display: inline-block;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  padding: 6px 10px;
+  text-align: center;
+  border: 1px solid #ffffff55;
+  border-radius: 4px;
+  background: #ffffff11;
+  color: #ffffffdd;
+  cursor: pointer;
+  width: 120px;
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const RevokeAccessButtonWrapper = styled.div`
+  width: 100%;
+  text-align: right;
+  margin-top: 12px;
+`;

+ 39 - 0
dashboard/src/shared/api.tsx

@@ -1,3 +1,4 @@
+import { PolicyDocType } from "./auth/types";
 import { PullRequest } from "main/home/cluster-dashboard/preview-environments/types";
 import { release } from "process";
 import { baseApi } from "./baseApi";
@@ -1416,6 +1417,39 @@ const stopJob = baseApi<
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/jobs/${name}/stop`;
 });
 
+const listAPITokens = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/api_token`
+);
+
+const getAPIToken = baseApi<{}, { project_id: number; token: string }>(
+  "GET",
+  ({ project_id, token }) => `/api/projects/${project_id}/api_token/${token}`
+);
+
+const revokeAPIToken = baseApi<{}, { project_id: number; token: string }>(
+  "POST",
+  ({ project_id, token }) =>
+    `/api/projects/${project_id}/api_token/${token}/revoke`
+);
+
+const createAPIToken = baseApi<
+  {
+    name: string;
+    policy_uid: string;
+    expires_at?: string;
+  },
+  { project_id: number }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/api_token`);
+
+const createPolicy = baseApi<
+  {
+    name: string;
+    policy: PolicyDocType[];
+  },
+  { project_id: number }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/policy`);
+
 const getAvailableRoles = baseApi<{}, { project_id: number }>(
   "GET",
   ({ project_id }) => `/api/projects/${project_id}/roles`
@@ -1925,6 +1959,11 @@ export default {
   deleteJob,
   stopJob,
   updateInvite,
+  listAPITokens,
+  getAPIToken,
+  revokeAPIToken,
+  createAPIToken,
+  createPolicy,
   getAvailableRoles,
   getCollaborators,
   updateCollaborator,

+ 3 - 0
dashboard/src/shared/auth/types.ts

@@ -4,7 +4,10 @@ export type ScopeType =
   | "settings"
   | "namespace"
   | "application"
+  | "release"
+  | "registry"
   | "env_group"
+  | "infra"
   | "job"
   | "integrations";
 

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

@@ -240,6 +240,7 @@ export interface ProjectType {
   preview_envs_enabled: boolean;
   enable_rds_databases: boolean;
   managed_infra_enabled: boolean;
+  api_tokens_enabled: boolean;
   roles: {
     id: number;
     kind: string;

+ 44 - 2
internal/auth/token/token.go

@@ -25,6 +25,10 @@ type Token struct {
 	ProjectID uint       `json:"project_id"`
 	IBy       uint       `json:"iby"`
 	IAt       *time.Time `json:"iat"`
+
+	// Additional fields that may or may not be set
+	TokenID string `json:"token_id"`
+	Secret  string `json:"secret"`
 }
 
 func GetTokenForUser(userID uint) (*Token, error) {
@@ -58,6 +62,24 @@ func GetTokenForAPI(userID, projID uint) (*Token, error) {
 	}, nil
 }
 
+func GetStoredTokenForAPI(userID, projID uint, tokenID, secret string) (*Token, error) {
+	if userID == 0 || projID == 0 {
+		return nil, fmt.Errorf("id cannot be 0")
+	}
+
+	iat := time.Now()
+
+	return &Token{
+		SubKind:   API,
+		Sub:       string(API),
+		ProjectID: projID,
+		IBy:       userID,
+		IAt:       &iat,
+		TokenID:   tokenID,
+		Secret:    secret,
+	}, nil
+}
+
 func (t *Token) EncodeToken(conf *TokenGeneratorConf) (string, error) {
 	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
 		"sub_kind":   t.SubKind,
@@ -65,6 +87,8 @@ func (t *Token) EncodeToken(conf *TokenGeneratorConf) (string, error) {
 		"iby":        t.IBy,
 		"iat":        fmt.Sprintf("%d", t.IAt.Unix()),
 		"project_id": t.ProjectID,
+		"token_id":   t.TokenID,
+		"secret":     t.Secret,
 	})
 
 	// Sign and get the complete encoded token as a string using the secret
@@ -105,13 +129,31 @@ func GetTokenFromEncoded(tokenString string, conf *TokenGeneratorConf) (*Token,
 
 		iat := time.Unix(iatUnix, 0)
 
-		return &Token{
+		res := &Token{
 			SubKind:   Subject(fmt.Sprintf("%v", claims["sub_kind"])),
 			Sub:       fmt.Sprintf("%v", claims["sub"]),
 			IBy:       uint(iby),
 			IAt:       &iat,
 			ProjectID: uint(projID),
-		}, nil
+		}
+
+		if tokenIDInter, ok := claims["token_id"]; ok {
+			tokenID, ok := tokenIDInter.(string)
+
+			if ok {
+				res.TokenID = tokenID
+			}
+		}
+
+		if secretInter, ok := claims["secret"]; ok {
+			secret, ok := secretInter.(string)
+
+			if ok {
+				res.Secret = secret
+			}
+		}
+
+		return res, nil
 	}
 
 	return nil, fmt.Errorf("invalid token")

+ 15 - 0
internal/kubernetes/agent.go

@@ -640,6 +640,21 @@ func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
 	)
 }
 
+// GetNamespace gets the namespace given the name
+func (a *Agent) GetNamespace(name string) (*v1.Namespace, error) {
+	ns, err := a.Clientset.CoreV1().Namespaces().Get(
+		context.Background(),
+		name,
+		metav1.GetOptions{},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return ns, nil
+}
+
 // DeleteNamespace deletes the namespace given the name.
 func (a *Agent) DeleteNamespace(name string) error {
 	// check if namespace exists

+ 49 - 0
internal/models/api_token.go

@@ -0,0 +1,49 @@
+package models
+
+import (
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+type APIToken struct {
+	gorm.Model
+
+	UniqueID string `gorm:"unique"`
+
+	ProjectID       uint
+	CreatedByUserID uint
+	Expiry          *time.Time
+	Revoked         bool
+	PolicyUID       string
+	PolicyName      string
+	Name            string
+
+	// SecretKey is hashed like a password before storage
+	SecretKey []byte
+}
+
+func (p *APIToken) IsExpired() bool {
+	timeLeft := p.Expiry.Sub(time.Now())
+	return timeLeft < 0
+}
+
+func (p *APIToken) ToAPITokenMetaType() *types.APITokenMeta {
+	return &types.APITokenMeta{
+		ID:         p.UniqueID,
+		CreatedAt:  p.CreatedAt,
+		ExpiresAt:  *p.Expiry,
+		PolicyName: p.PolicyName,
+		PolicyUID:  p.PolicyUID,
+		Name:       p.Name,
+	}
+}
+
+func (p *APIToken) ToAPITokenType(policy []*types.PolicyDocument, token string) *types.APIToken {
+	return &types.APIToken{
+		APITokenMeta: p.ToAPITokenMetaType(),
+		Policy:       policy,
+		Token:        token,
+	}
+}

+ 44 - 0
internal/models/policy.go

@@ -0,0 +1,44 @@
+package models
+
+import (
+	"encoding/json"
+
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+type Policy struct {
+	gorm.Model
+
+	UniqueID string `gorm:"unique"`
+
+	ProjectID       uint
+	CreatedByUserID uint
+	Name            string
+	PolicyBytes     []byte
+}
+
+func (p *Policy) ToAPIPolicyTypeMeta() *types.APIPolicyMeta {
+	return &types.APIPolicyMeta{
+		CreatedAt: p.CreatedAt,
+		UpdatedAt: p.UpdatedAt,
+		UID:       p.UniqueID,
+		ProjectID: p.ProjectID,
+		Name:      p.Name,
+	}
+}
+
+func (p *Policy) ToAPIPolicyType() (*types.APIPolicy, error) {
+	policy := []*types.PolicyDocument{}
+
+	err := json.Unmarshal(p.PolicyBytes, &policy)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &types.APIPolicy{
+		APIPolicyMeta: p.ToAPIPolicyTypeMeta(),
+		Policy:        policy,
+	}, nil
+}

+ 2 - 0
internal/models/project.go

@@ -60,6 +60,7 @@ type Project struct {
 	PreviewEnvsEnabled  bool
 	RDSDatabasesEnabled bool
 	ManagedInfraEnabled bool
+	APITokensEnabled    bool
 }
 
 // ToProjectType generates an external types.Project to be shared over REST
@@ -77,5 +78,6 @@ func (p *Project) ToProjectType() *types.Project {
 		PreviewEnvsEnabled:  p.PreviewEnvsEnabled,
 		RDSDatabasesEnabled: p.RDSDatabasesEnabled,
 		ManagedInfraEnabled: p.ManagedInfraEnabled,
+		APITokensEnabled:    p.APITokensEnabled,
 	}
 }

+ 13 - 0
internal/repository/api_token.go

@@ -0,0 +1,13 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// APITokenRepository represents the set of queries on the APIToken model
+type APITokenRepository interface {
+	CreateAPIToken(token *models.APIToken) (*models.APIToken, error)
+	ListAPITokensByProjectID(projectID uint) ([]*models.APIToken, error)
+	ReadAPIToken(projectID uint, uid string) (*models.APIToken, error)
+	UpdateAPIToken(token *models.APIToken) (*models.APIToken, error)
+}

+ 55 - 0
internal/repository/gorm/api_token.go

@@ -0,0 +1,55 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// APITokenRepository uses gorm.DB for querying the database
+type APITokenRepository struct {
+	db *gorm.DB
+}
+
+// NewAPITokenRepository returns a APITokenRepository which uses
+// gorm.DB for querying the database
+func NewAPITokenRepository(db *gorm.DB) repository.APITokenRepository {
+	return &APITokenRepository{db}
+}
+
+func (repo *APITokenRepository) CreateAPIToken(a *models.APIToken) (*models.APIToken, error) {
+	if err := repo.db.Create(a).Error; err != nil {
+		return nil, err
+	}
+	return a, nil
+}
+
+func (repo *APITokenRepository) ListAPITokensByProjectID(projectID uint) ([]*models.APIToken, error) {
+	tokens := []*models.APIToken{}
+
+	if err := repo.db.Where("project_id = ? AND NOT revoked", projectID, true).Find(&tokens).Error; err != nil {
+		return nil, err
+	}
+
+	return tokens, nil
+}
+
+func (repo *APITokenRepository) ReadAPIToken(projectID uint, uid string) (*models.APIToken, error) {
+	token := &models.APIToken{}
+
+	if err := repo.db.Where("project_id = ? AND unique_id = ?", projectID, uid).First(&token).Error; err != nil {
+		return nil, err
+	}
+
+	return token, nil
+}
+
+func (repo *APITokenRepository) UpdateAPIToken(
+	token *models.APIToken,
+) (*models.APIToken, error) {
+	if err := repo.db.Save(token).Error; err != nil {
+		return nil, err
+	}
+
+	return token, nil
+}

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

@@ -48,6 +48,8 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&models.CredentialsExchangeToken{},
 		&models.BuildConfig{},
 		&models.Allowlist{},
+		&models.APIToken{},
+		&models.Policy{},
 		&models.Tag{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},

+ 64 - 0
internal/repository/gorm/policy.go

@@ -0,0 +1,64 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// PolicyRepository uses gorm.DB for querying the database
+type PolicyRepository struct {
+	db *gorm.DB
+}
+
+// NewPolicyRepository returns a PolicyRepository which uses
+// gorm.DB for querying the database
+func NewPolicyRepository(db *gorm.DB) repository.PolicyRepository {
+	return &PolicyRepository{db}
+}
+
+func (repo *PolicyRepository) CreatePolicy(a *models.Policy) (*models.Policy, error) {
+	if err := repo.db.Create(a).Error; err != nil {
+		return nil, err
+	}
+	return a, nil
+}
+
+func (repo *PolicyRepository) ListPoliciesByProjectID(projectID uint) ([]*models.Policy, error) {
+	policys := []*models.Policy{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&policys).Error; err != nil {
+		return nil, err
+	}
+
+	return policys, nil
+}
+
+func (repo *PolicyRepository) ReadPolicy(projectID uint, uid string) (*models.Policy, error) {
+	policy := &models.Policy{}
+
+	if err := repo.db.Where("project_id = ? AND unique_id = ?", projectID, uid).First(&policy).Error; err != nil {
+		return nil, err
+	}
+
+	return policy, nil
+}
+
+func (repo *PolicyRepository) UpdatePolicy(
+	policy *models.Policy,
+) (*models.Policy, error) {
+	if err := repo.db.Save(policy).Error; err != nil {
+		return nil, err
+	}
+
+	return policy, nil
+}
+
+func (repo *PolicyRepository) DeletePolicy(
+	policy *models.Policy,
+) (*models.Policy, error) {
+	if err := repo.db.Delete(&policy).Error; err != nil {
+		return nil, err
+	}
+	return policy, nil
+}

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

@@ -42,6 +42,8 @@ type GormRepository struct {
 	ceToken                   repository.CredentialsExchangeTokenRepository
 	buildConfig               repository.BuildConfigRepository
 	allowlist                 repository.AllowlistRepository
+	apiToken                  repository.APITokenRepository
+	policy                    repository.PolicyRepository
 	tag                       repository.TagRepository
 }
 
@@ -185,6 +187,14 @@ func (t *GormRepository) Allowlist() repository.AllowlistRepository {
 	return t.allowlist
 }
 
+func (t *GormRepository) APIToken() repository.APITokenRepository {
+	return t.apiToken
+}
+
+func (t *GormRepository) Policy() repository.PolicyRepository {
+	return t.policy
+}
+
 func (t *GormRepository) Tag() repository.TagRepository {
 	return t.tag
 }
@@ -228,6 +238,8 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		ceToken:                   NewCredentialsExchangeTokenRepository(db),
 		buildConfig:               NewBuildConfigRepository(db),
 		allowlist:                 NewAllowlistRepository(db),
+		apiToken:                  NewAPITokenRepository(db),
+		policy:                    NewPolicyRepository(db),
 		tag:                       NewTagRepository(db),
 	}
 }

+ 14 - 0
internal/repository/policy.go

@@ -0,0 +1,14 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// PolicyRepository represents the set of queries on the Policy model
+type PolicyRepository interface {
+	CreatePolicy(policy *models.Policy) (*models.Policy, error)
+	ListPoliciesByProjectID(projectID uint) ([]*models.Policy, error)
+	ReadPolicy(projectID uint, uid string) (*models.Policy, error)
+	UpdatePolicy(token *models.Policy) (*models.Policy, error)
+	DeletePolicy(policy *models.Policy) (*models.Policy, error)
+}

+ 2 - 0
internal/repository/repository.go

@@ -36,5 +36,7 @@ type Repository interface {
 	CredentialsExchangeToken() CredentialsExchangeTokenRepository
 	BuildConfig() BuildConfigRepository
 	Allowlist() AllowlistRepository
+	APIToken() APITokenRepository
+	Policy() PolicyRepository
 	Tag() TagRepository
 }

+ 32 - 0
internal/repository/test/api_token.go

@@ -0,0 +1,32 @@
+package test
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type APITokenRepository struct {
+	canQuery bool
+}
+
+func NewAPITokenRepository(canQuery bool) repository.APITokenRepository {
+	return &APITokenRepository{canQuery}
+}
+
+func (repo *APITokenRepository) CreateAPIToken(a *models.APIToken) (*models.APIToken, error) {
+	panic("unimplemented")
+}
+
+func (repo *APITokenRepository) ListAPITokensByProjectID(projectID uint) ([]*models.APIToken, error) {
+	panic("unimplemented")
+}
+
+func (repo *APITokenRepository) ReadAPIToken(projectID uint, uid string) (*models.APIToken, error) {
+	panic("unimplemented")
+}
+
+func (repo *APITokenRepository) UpdateAPIToken(
+	token *models.APIToken,
+) (*models.APIToken, error) {
+	panic("unimplemented")
+}

+ 40 - 0
internal/repository/test/policy.go

@@ -0,0 +1,40 @@
+package test
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type PolicyRepository struct {
+	canQuery bool
+}
+
+// NewPolicyRepository returns a PolicyRepository which uses
+// gorm.DB for querying the database
+func NewPolicyRepository(canQuery bool) repository.PolicyRepository {
+	return &PolicyRepository{canQuery}
+}
+
+func (repo *PolicyRepository) CreatePolicy(a *models.Policy) (*models.Policy, error) {
+	panic("unimplemented")
+}
+
+func (repo *PolicyRepository) ListPoliciesByProjectID(projectID uint) ([]*models.Policy, error) {
+	panic("unimplemented")
+}
+
+func (repo *PolicyRepository) ReadPolicy(projectID uint, uid string) (*models.Policy, error) {
+	panic("unimplemented")
+}
+
+func (repo *PolicyRepository) UpdatePolicy(
+	policy *models.Policy,
+) (*models.Policy, error) {
+	panic("unimplemented")
+}
+
+func (repo *PolicyRepository) DeletePolicy(
+	policy *models.Policy,
+) (*models.Policy, error) {
+	panic("unimplemented")
+}

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

@@ -40,6 +40,8 @@ type TestRepository struct {
 	buildConfig               repository.BuildConfigRepository
 	database                  repository.DatabaseRepository
 	allowlist                 repository.AllowlistRepository
+	apiToken                  repository.APITokenRepository
+	policy                    repository.PolicyRepository
 	tag                       repository.TagRepository
 }
 
@@ -183,6 +185,14 @@ func (t *TestRepository) Allowlist() repository.AllowlistRepository {
 	return t.allowlist
 }
 
+func (t *TestRepository) APIToken() repository.APITokenRepository {
+	return t.apiToken
+}
+
+func (t *TestRepository) Policy() repository.PolicyRepository {
+	return t.policy
+}
+
 func (t *TestRepository) Tag() repository.TagRepository {
 	return t.tag
 }
@@ -226,6 +236,8 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		buildConfig:               NewBuildConfigRepository(canQuery),
 		database:                  NewDatabaseRepository(),
 		allowlist:                 NewAllowlistRepository(canQuery),
+		apiToken:                  NewAPITokenRepository(canQuery),
+		policy:                    NewPolicyRepository(canQuery),
 		tag:                       NewTagRepository(),
 	}
 }