Explorar o código

Merge branch 'nafees/api-v1' into dev

Mohammed Nafees %!s(int64=4) %!d(string=hai) anos
pai
achega
ecfafc5e8c
Modificáronse 46 ficheiros con 2927 adicións e 119 borrados
  1. 38 7
      api/server/authn/handler.go
  2. 26 10
      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. 16 7
      api/server/authz/project.go
  9. 118 0
      api/server/handlers/api_token/create.go
  10. 72 0
      api/server/handlers/api_token/get.go
  11. 51 0
      api/server/handlers/api_token/list.go
  12. 73 0
      api/server/handlers/api_token/revoke.go
  13. 4 0
      api/server/handlers/namespace/list_releases.go
  14. 89 0
      api/server/handlers/policy/create.go
  15. 66 0
      api/server/handlers/policy/get.go
  16. 45 0
      api/server/handlers/policy/list.go
  17. 5 2
      api/server/handlers/project/get_policy.go
  18. 124 10
      api/server/handlers/release/create.go
  19. 2 0
      api/server/handlers/release/get_gha_template.go
  20. 205 0
      api/server/router/project.go
  21. 7 6
      api/server/router/router.go
  22. 28 0
      api/types/api_token.go
  23. 88 0
      api/types/policy.go
  24. 1 0
      api/types/project.go
  25. 322 0
      dashboard/src/main/home/project-settings/APITokensSection.tsx
  26. 11 0
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  27. 592 0
      dashboard/src/main/home/project-settings/api-tokens/CreateAPITokenForm.tsx
  28. 135 0
      dashboard/src/main/home/project-settings/api-tokens/CustomPolicyForm.tsx
  29. 228 0
      dashboard/src/main/home/project-settings/api-tokens/TokenList.tsx
  30. 39 0
      dashboard/src/shared/api.tsx
  31. 3 0
      dashboard/src/shared/auth/types.ts
  32. 1 0
      dashboard/src/shared/types.tsx
  33. 44 2
      internal/auth/token/token.go
  34. 49 0
      internal/models/api_token.go
  35. 44 0
      internal/models/policy.go
  36. 2 0
      internal/models/project.go
  37. 13 0
      internal/repository/api_token.go
  38. 55 0
      internal/repository/gorm/api_token.go
  39. 2 0
      internal/repository/gorm/migrate.go
  40. 64 0
      internal/repository/gorm/policy.go
  41. 12 0
      internal/repository/gorm/repository.go
  42. 14 0
      internal/repository/policy.go
  43. 2 0
      internal/repository/repository.go
  44. 32 0
      internal/repository/test/api_token.go
  45. 40 0
      internal/repository/test/policy.go
  46. 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

+ 26 - 10
api/server/authz/policy.go

@@ -39,19 +39,35 @@ type PolicyHandler struct {
 }
 
 func (h *PolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// get the full map of scopes to resource actions
-	reqScopes, reqErr := getRequestActionForEndpoint(r, h.endpointMeta)
+	// get the project id from the URL param context
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
 
-	if reqErr != nil {
-		apierrors.HandleAPIError(h.config.Logger, h.config.Alerter, w, r, reqErr, true)
-		return
+	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
+		proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+		if !proj.APITokensEnabled {
+			apierrors.HandleAPIError(h.config.Logger, h.config.Alerter, w, r,
+				apierrors.NewErrForbidden(fmt.Errorf("api tokens are not enabled for this project")), true)
+			return
+		}
+
+		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)
+
+		policyLoaderOpts.ProjectID = projID
+		policyLoaderOpts.UserID = user.ID
 	}
 
 	// load policy documents for the user + project
-	projID := reqScopes[types.ProjectScope].Resource.UInt
-	user, _ := r.Context().Value(types.UserScope).(*models.User)
-
-	policyDocs, reqErr := h.loader.LoadPolicyDocuments(user.ID, projID)
+	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 {

+ 16 - 7
api/server/authz/project.go

@@ -13,27 +13,35 @@ import (
 )
 
 type ProjectScopedFactory struct {
-	config *config.Config
+	config       *config.Config
+	endpointMeta types.APIRequestMetadata
 }
 
 func NewProjectScopedFactory(
 	config *config.Config,
+	endpointMeta types.APIRequestMetadata,
 ) *ProjectScopedFactory {
-	return &ProjectScopedFactory{config}
+	return &ProjectScopedFactory{config, endpointMeta}
 }
 
 func (p *ProjectScopedFactory) Middleware(next http.Handler) http.Handler {
-	return &ProjectScopedMiddleware{next, p.config}
+	return &ProjectScopedMiddleware{next, p.endpointMeta, p.config}
 }
 
 type ProjectScopedMiddleware struct {
-	next   http.Handler
-	config *config.Config
+	next         http.Handler
+	endpointMeta types.APIRequestMetadata
+	config       *config.Config
 }
 
 func (p *ProjectScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// get the project id from the URL param context
-	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	// get the full map of scopes to resource actions
+	reqScopes, reqErr := getRequestActionForEndpoint(r, p.endpointMeta)
+
+	if reqErr != nil {
+		apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, reqErr, true)
+		return
+	}
 
 	projID := reqScopes[types.ProjectScope].Resource.UInt
 
@@ -53,6 +61,7 @@ func (p *ProjectScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	}
 
 	ctx := NewProjectContext(r.Context(), project)
+	ctx = NewRequestScopeCtx(ctx, reqScopes)
 	r = r.Clone(ctx)
 	p.next.ServeHTTP(w, r)
 }

+ 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())
+}

+ 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,

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

@@ -4,11 +4,13 @@ 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"
@@ -920,6 +922,209 @@ func getProjectRoutes(
 	// 	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{

+ 7 - 6
api/server/router/router.go

@@ -131,6 +131,7 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 				RelativePath: "",
 			},
 			endpointFactory,
+			v1ProjRegisterer.Children...,
 		)
 
 		allRoutes = append(allRoutes, v1Routes...)
@@ -167,10 +168,6 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 	// after authentication. Each subsequent http.Handler can lookup the user in context.
 	authNFactory := authn.NewAuthNFactory(config)
 
-	// Create a new "project-scoped" factory which will create a new project-scoped request
-	// after authorization. Each subsequent http.Handler can lookup the project in context.
-	projFactory := authz.NewProjectScopedFactory(config)
-
 	// Create a new "cluster-scoped" factory which will create a new cluster-scoped request
 	// after authorization. Each subsequent http.Handler can lookup the cluster in context.
 	clusterFactory := authz.NewClusterScopedFactory(config)
@@ -208,7 +205,7 @@ func registerRoutes(config *config.Config, routes []*router.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)
@@ -229,10 +226,14 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 					atomicGroup.Use(authNFactory.NewAuthenticated)
 				}
 			case types.ProjectScope:
+				// Create a new "project-scoped" factory which will create a new project-scoped request
+				// after authorization. Each subsequent http.Handler can lookup the project in context.
+				projFactory := authz.NewProjectScopedFactory(config, *route.Endpoint.Metadata)
+
 				policyFactory := authz.NewPolicyMiddleware(config, *route.Endpoint.Metadata, policyDocLoader)
 
-				atomicGroup.Use(policyFactory.Middleware)
 				atomicGroup.Use(projFactory.Middleware)
+				atomicGroup.Use(policyFactory.Middleware)
 			case types.ClusterScope:
 				atomicGroup.Use(clusterFactory.Middleware)
 			case types.NamespaceScope:

+ 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")

+ 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(),
 	}
 }