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

Merge pull request #1199 from porter-dev/0.8.0-api-cleanup

[POR-34] API migration + refactor
abelanger5 4 лет назад
Родитель
Сommit
e73a880099
100 измененных файлов с 7944 добавлено и 76 удалено
  1. 1 0
      .gitignore
  2. 4 1
      Makefile
  3. 112 75
      api/client/api.go
  4. 93 0
      api/client/deploy.go
  5. 29 0
      api/client/domain.go
  6. 26 0
      api/client/event.go
  7. 69 0
      api/client/git_repo.go
  8. 87 0
      api/client/integration.go
  9. 88 0
      api/client/k8s.go
  10. 179 0
      api/client/project.go
  11. 203 0
      api/client/registry.go
  12. 44 0
      api/client/template.go
  13. 102 0
      api/client/user.go
  14. 181 0
      api/server/authn/handler.go
  15. 257 0
      api/server/authn/handler_test.go
  16. 51 0
      api/server/authn/session_helpers.go
  17. 169 0
      api/server/authz/cluster.go
  18. 132 0
      api/server/authz/git_installation.go
  19. 63 0
      api/server/authz/helm_repo.go
  20. 63 0
      api/server/authz/infra.go
  21. 63 0
      api/server/authz/invite.go
  22. 48 0
      api/server/authz/namespace.go
  23. 128 0
      api/server/authz/policy.go
  24. 12 0
      api/server/authz/policy/doc.go
  25. 85 0
      api/server/authz/policy/loader.go
  26. 191 0
      api/server/authz/policy/loader_test.go
  27. 150 0
      api/server/authz/policy/policy.go
  28. 329 0
      api/server/authz/policy/policy_test.go
  29. 305 0
      api/server/authz/policy_test.go
  30. 52 0
      api/server/authz/project.go
  31. 97 0
      api/server/authz/project_test.go
  32. 63 0
      api/server/authz/registry.go
  33. 66 0
      api/server/authz/release.go
  34. 158 0
      api/server/handlers/cluster/create.go
  35. 113 0
      api/server/handlers/cluster/create_candidate.go
  36. 59 0
      api/server/handlers/cluster/create_namespace.go
  37. 41 0
      api/server/handlers/cluster/delete.go
  38. 52 0
      api/server/handlers/cluster/delete_namespace.go
  39. 48 0
      api/server/handlers/cluster/detect_prometheus_installed.go
  40. 57 0
      api/server/handlers/cluster/get.go
  41. 55 0
      api/server/handlers/cluster/get_kubeconfig.go
  42. 46 0
      api/server/handlers/cluster/get_node.go
  43. 65 0
      api/server/handlers/cluster/get_pod_metrics.go
  44. 63 0
      api/server/handlers/cluster/get_pods.go
  45. 46 0
      api/server/handlers/cluster/list.go
  46. 44 0
      api/server/handlers/cluster/list_candidates.go
  47. 52 0
      api/server/handlers/cluster/list_namespaces.go
  48. 51 0
      api/server/handlers/cluster/list_nginx_ingresses.go
  49. 44 0
      api/server/handlers/cluster/list_nodes.go
  50. 64 0
      api/server/handlers/cluster/resolve_candidate.go
  51. 60 0
      api/server/handlers/cluster/stream_helm_release.go
  52. 63 0
      api/server/handlers/cluster/stream_status.go
  53. 50 0
      api/server/handlers/cluster/update.go
  54. 30 0
      api/server/handlers/gitinstallation/get.go
  55. 88 0
      api/server/handlers/gitinstallation/get_accounts.go
  56. 104 0
      api/server/handlers/gitinstallation/get_buildpack.go
  57. 84 0
      api/server/handlers/gitinstallation/get_contents.go
  58. 95 0
      api/server/handlers/gitinstallation/get_procfile.go
  59. 84 0
      api/server/handlers/gitinstallation/get_tarball_url.go
  60. 114 0
      api/server/handlers/gitinstallation/helpers.go
  61. 25 0
      api/server/handlers/gitinstallation/install.go
  62. 96 0
      api/server/handlers/gitinstallation/list.go
  63. 115 0
      api/server/handlers/gitinstallation/list_branches.go
  64. 115 0
      api/server/handlers/gitinstallation/list_repos.go
  65. 90 0
      api/server/handlers/gitinstallation/oauth_callback.go
  66. 36 0
      api/server/handlers/gitinstallation/oauth_start.go
  67. 115 0
      api/server/handlers/gitinstallation/webhook.go
  68. 105 0
      api/server/handlers/handler.go
  69. 26 0
      api/server/handlers/healthcheck/livez.go
  70. 45 0
      api/server/handlers/healthcheck/readyz.go
  71. 30 0
      api/server/handlers/helmrepo/get.go
  72. 195 0
      api/server/handlers/infra/delete.go
  73. 30 0
      api/server/handlers/infra/get.go
  74. 45 0
      api/server/handlers/infra/list.go
  75. 52 0
      api/server/handlers/infra/stream_logs.go
  76. 117 0
      api/server/handlers/invite/accept.go
  77. 90 0
      api/server/handlers/invite/create.go
  78. 36 0
      api/server/handlers/invite/delete.go
  79. 44 0
      api/server/handlers/invite/list.go
  80. 43 0
      api/server/handlers/invite/update_role.go
  81. 46 0
      api/server/handlers/job/delete.go
  82. 50 0
      api/server/handlers/job/get_pods.go
  83. 49 0
      api/server/handlers/job/stop.go
  84. 26 0
      api/server/handlers/metadata/get.go
  85. 27 0
      api/server/handlers/metadata/list_cluster_ints.go
  86. 27 0
      api/server/handlers/metadata/list_helm_repo_ints.go
  87. 27 0
      api/server/handlers/metadata/list_registry_ints.go
  88. 102 0
      api/server/handlers/namespace/create_configmap.go
  89. 66 0
      api/server/handlers/namespace/delete_configmap.go
  90. 46 0
      api/server/handlers/namespace/delete_pod.go
  91. 60 0
      api/server/handlers/namespace/get_configmap.go
  92. 50 0
      api/server/handlers/namespace/get_ingress.go
  93. 50 0
      api/server/handlers/namespace/get_pod_events.go
  94. 48 0
      api/server/handlers/namespace/list_configmaps.go
  95. 58 0
      api/server/handlers/namespace/list_releases.go
  96. 93 0
      api/server/handlers/namespace/rename_configmap.go
  97. 58 0
      api/server/handlers/namespace/stream_pod_logs.go
  98. 76 0
      api/server/handlers/namespace/update_configmap.go
  99. 86 0
      api/server/handlers/oauth_callback/digitalocean.go
  100. 77 0
      api/server/handlers/oauth_callback/slack.go

+ 1 - 0
.gitignore

@@ -23,6 +23,7 @@ bin
 .terraform.lock.hcl
 .terraform.lock.hcl
 
 
 *kubeconfig*
 *kubeconfig*
+!*kubeconfig*.go
 
 
 # .tfstate files
 # .tfstate files
 *.tfstate
 *.tfstate

+ 4 - 1
Makefile

@@ -11,4 +11,7 @@ setup-env-files:
 	bash ./scripts/dev-environment/CreateDefaultEnvFiles.sh
 	bash ./scripts/dev-environment/CreateDefaultEnvFiles.sh
 
 
 build-cli: 
 build-cli: 
-	go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${VERSION}'" -a -tags cli -o $(BINDIR)/porter ./cli
+	go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${VERSION}'" -a -tags cli -o $(BINDIR)/porter ./cli
+
+build-cli-dev:
+	go build -tags cli -o $(BINDIR)/porter ./cli

+ 112 - 75
cli/cmd/api/api.go → api/client/api.go

@@ -1,17 +1,19 @@
-package api
+package client
 
 
 import (
 import (
-	"bytes"
-	"context"
 	"encoding/base64"
 	"encoding/base64"
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
 	"io/ioutil"
 	"io/ioutil"
 	"net/http"
 	"net/http"
+	"net/url"
+	"os"
 	"path/filepath"
 	"path/filepath"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
+	"github.com/gorilla/schema"
+	"github.com/porter-dev/porter/api/types"
 	"k8s.io/client-go/util/homedir"
 	"k8s.io/client-go/util/homedir"
 )
 )
 
 
@@ -24,38 +26,6 @@ type Client struct {
 	Token          string
 	Token          string
 }
 }
 
 
-// HTTPError is the Porter error response returned if a request fails
-type HTTPError struct {
-	Code   uint     `json:"code"`
-	Errors []string `json:"errors"`
-}
-
-type EventStatus int64
-
-const (
-	EventStatusSuccess    EventStatus = 1
-	EventStatusInProgress             = 2
-	EventStatusFailed                 = 3
-)
-
-// Event represents an event that happens during
-type Event struct {
-	ID     string      `json:"event_id"` // events with the same id wil be treated the same, and the highest index one is retained
-	Name   string      `json:"name"`
-	Index  int64       `json:"index"` // priority of the event, used for sorting
-	Status EventStatus `json:"status"`
-	Info   string      `json:"info"` // extra information (can be error or success)
-}
-
-// StreamEventForm is used to send event data to the api
-type StreamEventForm struct {
-	Event     `json:"event"`
-	Token     string `json:"token"`
-	ClusterID uint   `json:"cluster_id"`
-	Name      string `json:"name"`
-	Namespace string `json:"namespace"`
-}
-
 // NewClient constructs a new client based on a set of options
 // NewClient constructs a new client based on a set of options
 func NewClient(baseURL string, cookieFileName string) *Client {
 func NewClient(baseURL string, cookieFileName string) *Client {
 	home := homedir.HomeDir()
 	home := homedir.HomeDir()
@@ -90,7 +60,102 @@ func NewClientWithToken(baseURL, token string) *Client {
 	return client
 	return client
 }
 }
 
 
-func (c *Client) sendRequest(req *http.Request, v interface{}, useCookie bool) (*HTTPError, error) {
+func (c *Client) getRequest(relPath string, data interface{}, response interface{}) error {
+	vals := make(map[string][]string)
+	err := schema.NewEncoder().Encode(data, vals)
+
+	urlVals := url.Values(vals)
+	encodedURLVals := urlVals.Encode()
+	var req *http.Request
+
+	if encodedURLVals != "" {
+		req, err = http.NewRequest(
+			"GET",
+			fmt.Sprintf("%s%s?%s", c.BaseURL, relPath, encodedURLVals),
+			nil,
+		)
+	} else {
+		req, err = http.NewRequest(
+			"GET",
+			fmt.Sprintf("%s%s", c.BaseURL, relPath),
+			nil,
+		)
+	}
+
+	if err != nil {
+		return err
+	}
+
+	if httpErr, err := c.sendRequest(req, response, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("%v", httpErr.Error)
+		}
+
+		return err
+	}
+
+	return nil
+}
+
+func (c *Client) postRequest(relPath string, data interface{}, response interface{}) error {
+	strData, err := json.Marshal(data)
+
+	if err != nil {
+		return nil
+	}
+
+	fmt.Println(string(strData))
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s%s", c.BaseURL, relPath),
+		strings.NewReader(string(strData)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	if httpErr, err := c.sendRequest(req, response, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("%v", httpErr.Error)
+		}
+
+		return err
+	}
+
+	return nil
+}
+
+func (c *Client) deleteRequest(relPath string, data interface{}, response interface{}) error {
+	strData, err := json.Marshal(data)
+
+	if err != nil {
+		return nil
+	}
+
+	req, err := http.NewRequest(
+		"DELETE",
+		fmt.Sprintf("%s%s", c.BaseURL, relPath),
+		strings.NewReader(string(strData)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	if httpErr, err := c.sendRequest(req, response, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("%v", httpErr.Error)
+		}
+
+		return err
+	}
+
+	return nil
+}
+
+func (c *Client) sendRequest(req *http.Request, v interface{}, useCookie bool) (*types.ExternalError, error) {
 	req.Header.Set("Content-Type", "application/json; charset=utf-8")
 	req.Header.Set("Content-Type", "application/json; charset=utf-8")
 	req.Header.Set("Accept", "application/json; charset=utf-8")
 	req.Header.Set("Accept", "application/json; charset=utf-8")
 
 
@@ -114,7 +179,7 @@ func (c *Client) sendRequest(req *http.Request, v interface{}, useCookie bool) (
 	}
 	}
 
 
 	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
 	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
-		var errRes HTTPError
+		var errRes types.ExternalError
 		if err = json.NewDecoder(res.Body).Decode(&errRes); err == nil {
 		if err = json.NewDecoder(res.Body).Decode(&errRes); err == nil {
 			return &errRes, nil
 			return &errRes, nil
 		}
 		}
@@ -149,44 +214,6 @@ func (c *Client) saveCookie(cookie *http.Cookie) error {
 	return ioutil.WriteFile(c.CookieFilePath, data, 0644)
 	return ioutil.WriteFile(c.CookieFilePath, data, 0644)
 }
 }
 
 
-// StreamEvent sends an event from deployment to the api
-func (c *Client) StreamEvent(event Event, projID uint, clusterID uint, name string, namespace string) error {
-	form := StreamEventForm{
-		Event:     event,
-		ClusterID: clusterID,
-		Name:      name,
-		Namespace: namespace,
-	}
-
-	body := new(bytes.Buffer)
-	err := json.NewEncoder(body).Encode(form)
-
-	if err != nil {
-		return err
-	}
-
-	req, err := http.NewRequest(
-		"POST",
-		fmt.Sprintf("%s/projects/%d/releases/%s/steps", c.BaseURL, projID, name),
-		body,
-	)
-
-	if err != nil {
-		return err
-	}
-
-	req = req.WithContext(context.Background())
-
-	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
-		if httpErr != nil {
-			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
-		}
-		return err
-	}
-
-	return nil
-}
-
 // retrieves single cookie from file
 // retrieves single cookie from file
 func (c *Client) getCookie() (*http.Cookie, error) {
 func (c *Client) getCookie() (*http.Cookie, error) {
 	data, err := ioutil.ReadFile(c.CookieFilePath)
 	data, err := ioutil.ReadFile(c.CookieFilePath)
@@ -206,6 +233,16 @@ func (c *Client) getCookie() (*http.Cookie, error) {
 	return cookie.Cookie, nil
 	return cookie.Cookie, nil
 }
 }
 
 
+// retrieves single cookie from file
+func (c *Client) deleteCookie() error {
+	// if file does not exist, return no error
+	if _, err := os.Stat(c.CookieFilePath); os.IsNotExist(err) {
+		return nil
+	}
+
+	return os.Remove(c.CookieFilePath)
+}
+
 type TokenProjectID struct {
 type TokenProjectID struct {
 	ProjectID uint `json:"project_id"`
 	ProjectID uint `json:"project_id"`
 }
 }

+ 93 - 0
api/client/deploy.go

@@ -0,0 +1,93 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+// GetReleaseWebhook retrieves the release webhook for a given release
+func (c *Client) GetReleaseWebhook(
+	ctx context.Context,
+	projID, clusterID uint,
+	name, namespace string,
+) (*types.PorterRelease, error) {
+	resp := &types.PorterRelease{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/namespaces/%s/releases/%s/webhook",
+			projID,
+			clusterID,
+			namespace,
+			name,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+// DeployWithWebhook deploys an application with an image tag using a unique webhook URI
+func (c *Client) DeployWithWebhook(
+	ctx context.Context,
+	webhook string,
+	req *types.WebhookRequest,
+) error {
+	return c.postRequest(
+		fmt.Sprintf(
+			"/webhooks/deploy/%s",
+			webhook,
+		),
+		req,
+		nil,
+	)
+}
+
+// UpdateBatchImage updates all releases that use a certain image with a new tag,
+// within a single namespace
+func (c *Client) UpdateBatchImage(
+	ctx context.Context,
+	projID, clusterID uint,
+	namespace string,
+	req *types.UpdateImageBatchRequest,
+) error {
+	return c.postRequest(
+		fmt.Sprintf("/projects/%d/clusters/%d/namespaces/%s/releases/image/batch", projID, clusterID, namespace),
+		req,
+		nil,
+	)
+}
+
+func (c *Client) DeployTemplate(
+	ctx context.Context,
+	projID, clusterID uint,
+	namespace string,
+	req *types.CreateReleaseRequest,
+) error {
+	return c.postRequest(
+		fmt.Sprintf("/projects/%d/clusters/%d/namespaces/%s/releases", projID, clusterID, namespace),
+		req,
+		nil,
+	)
+}
+
+// UpgradeRelease upgrades a specific release with new values or chart version
+func (c *Client) UpgradeRelease(
+	ctx context.Context,
+	projID, clusterID uint,
+	namespace, name string,
+	req *types.UpgradeReleaseRequest,
+) error {
+	return c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/namespaces/%s/releases/%s/0/upgrade",
+			projID, clusterID,
+			namespace, name,
+		),
+		req,
+		nil,
+	)
+}

+ 29 - 0
api/client/domain.go

@@ -0,0 +1,29 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+// CreateDNSRecord creates a Github action with basic authentication
+func (c *Client) CreateDNSRecord(
+	ctx context.Context,
+	projID, clusterID uint,
+	namespace, name string,
+) (*types.DNSRecord, error) {
+	resp := &types.DNSRecord{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/namespaces/%s/releases/%s/subdomain",
+			projID, clusterID,
+			namespace, name,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}

+ 26 - 0
api/client/event.go

@@ -0,0 +1,26 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+// CreateEvent sends an event from deployment to the api
+func (c *Client) CreateEvent(
+	ctx context.Context,
+	projID, clusterID uint,
+	namespace, name string,
+	req *types.UpdateReleaseStepsRequest,
+) error {
+	return c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/namespaces/%s/releases/%s/steps",
+			projID, clusterID,
+			namespace, name,
+		),
+		req,
+		nil,
+	)
+}

+ 69 - 0
api/client/git_repo.go

@@ -0,0 +1,69 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+// ListGitInstallationIDs returns a list of Git installation IDs for a user
+func (c *Client) ListGitInstallationIDs(
+	ctx context.Context,
+	projID uint,
+) (*types.ListGitInstallationIDsResponse, error) {
+	resp := &types.ListGitInstallationIDsResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/gitrepos",
+			projID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+// ListGitRepos returns a list of Git installation IDs for a user
+func (c *Client) ListGitRepos(
+	ctx context.Context,
+	projID uint,
+	gitInstallationID int64,
+) (*types.ListReposResponse, error) {
+	resp := &types.ListReposResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/gitrepos/%d/repos",
+			projID,
+			gitInstallationID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+func (c *Client) GetRepoZIPDownloadURL(
+	ctx context.Context,
+	projID uint,
+	gitInstallationID int64,
+	kind, owner, name, branch string,
+) (*types.GetTarballURLResponse, error) {
+	resp := &types.GetTarballURLResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/gitrepos/%d/repos/%s/%s/%s/%s/tarball_url",
+			projID, gitInstallationID,
+			kind, owner, name, branch,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}

+ 87 - 0
api/client/integration.go

@@ -0,0 +1,87 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+// CreateAWSIntegration creates an AWS integration with the given request options
+func (c *Client) CreateAWSIntegration(
+	ctx context.Context,
+	projectID uint,
+	req *types.CreateAWSRequest,
+) (*types.CreateAWSResponse, error) {
+	resp := &types.CreateAWSResponse{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/integrations/aws",
+			projectID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// CreateGCPIntegration creates a GCP integration with the given request options
+func (c *Client) CreateGCPIntegration(
+	ctx context.Context,
+	projectID uint,
+	req *types.CreateGCPRequest,
+) (*types.CreateGCPResponse, error) {
+	resp := &types.CreateGCPResponse{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/integrations/gcp",
+			projectID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// CreateBasicAuthIntegration creates a "basic auth" integration
+func (c *Client) CreateBasicAuthIntegration(
+	ctx context.Context,
+	projectID uint,
+	req *types.CreateBasicRequest,
+) (*types.CreateBasicResponse, error) {
+	resp := &types.CreateBasicResponse{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/integrations/basic",
+			projectID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// ListOAuthIntegrations lists the oauth integrations in a project
+func (c *Client) ListOAuthIntegrations(
+	ctx context.Context,
+	projectID uint,
+) (*types.ListOAuthResponse, error) {
+	resp := &types.ListOAuthResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/integrations/oauth",
+			projectID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}

+ 88 - 0
api/client/k8s.go

@@ -0,0 +1,88 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+// GetK8sNamespaces gets a namespaces list in a k8s cluster
+func (c *Client) GetK8sNamespaces(
+	ctx context.Context,
+	projectID uint,
+	clusterID uint,
+) (*types.ListNamespacesResponse, error) {
+	resp := &types.ListNamespacesResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/namespaces",
+			projectID, clusterID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+func (c *Client) GetKubeconfig(
+	ctx context.Context,
+	projectID uint,
+	clusterID uint,
+) (*types.GetTemporaryKubeconfigResponse, error) {
+	resp := &types.GetTemporaryKubeconfigResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/kubeconfig",
+			projectID, clusterID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+func (c *Client) GetRelease(
+	ctx context.Context,
+	projectID, clusterID uint,
+	namespace, name string,
+) (*types.GetReleaseResponse, error) {
+	resp := &types.GetReleaseResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/namespaces/%s/releases/%s/0",
+			projectID, clusterID,
+			namespace, name,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+// GetK8sAllPods gets all pods for a given release
+func (c *Client) GetK8sAllPods(
+	ctx context.Context,
+	projectID, clusterID uint,
+	namespace, name string,
+) (*types.GetReleaseAllPodsResponse, error) {
+	resp := &types.GetReleaseAllPodsResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/namespaces/%s/releases/%s/0/pods/all",
+			projectID, clusterID,
+			namespace, name,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}

+ 179 - 0
api/client/project.go

@@ -0,0 +1,179 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+// GetProject retrieves a project by id
+func (c *Client) GetProject(
+	ctx context.Context,
+	projectID uint,
+) (*types.ReadProjectResponse, error) {
+	resp := &types.ReadProjectResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d",
+			projectID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+// GetProjectCluster retrieves a project's cluster by id
+func (c *Client) GetProjectCluster(
+	ctx context.Context,
+	projectID uint,
+	clusterID uint,
+) (*types.ClusterGetResponse, error) {
+	resp := &types.ClusterGetResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d",
+			projectID, clusterID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+// ListProjectClusters creates a list of clusters for a given project
+func (c *Client) ListProjectClusters(
+	ctx context.Context,
+	projectID uint,
+) (*types.ListClusterResponse, error) {
+	resp := &types.ListClusterResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters",
+			projectID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+// CreateProject creates a project with the given request options
+func (c *Client) CreateProject(
+	ctx context.Context,
+	req *types.CreateProjectRequest,
+) (*types.CreateProjectResponse, error) {
+	resp := &types.CreateProjectResponse{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects",
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// CreateProjectCandidates creates a service account candidate for a given project,
+// accepting a kubeconfig that gets parsed into a candidate
+func (c *Client) CreateProjectCandidates(
+	ctx context.Context,
+	projectID uint,
+	req *types.CreateClusterCandidateRequest,
+) (*types.CreateClusterCandidateResponse, error) {
+	resp := &types.CreateClusterCandidateResponse{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/candidates",
+			projectID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// GetProjectCandidates returns the cluster candidates for a given
+// project id
+func (c *Client) GetProjectCandidates(
+	ctx context.Context,
+	projectID uint,
+) (*types.ListClusterCandidateResponse, error) {
+	resp := &types.ListClusterCandidateResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/candidates",
+			projectID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+// CreateProjectCluster creates a cluster given a project id
+// and a candidate id, which gets resolved using the list of actions
+func (c *Client) CreateProjectCluster(
+	ctx context.Context,
+	projectID, candidateID uint,
+	req *types.ClusterResolverAll,
+) (*types.Cluster, error) {
+	resp := &types.Cluster{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/candidates/%d/resolve",
+			projectID,
+			candidateID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// DeleteProjectCluster deletes a cluster given a project id and cluster id
+func (c *Client) DeleteProjectCluster(
+	ctx context.Context,
+	projectID uint,
+	clusterID uint,
+) error {
+	return c.deleteRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d",
+			projectID,
+			clusterID,
+		),
+		nil,
+		nil,
+	)
+}
+
+// // DeleteProject deletes a project by id
+func (c *Client) DeleteProject(
+	ctx context.Context,
+	projectID uint,
+) error {
+	return c.deleteRequest(
+		fmt.Sprintf(
+			"/projects/%d",
+			projectID,
+		),
+		nil,
+		nil,
+	)
+}

+ 203 - 0
api/client/registry.go

@@ -0,0 +1,203 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+// CreateRegistry creates a new registry integration
+func (c *Client) CreateRegistry(
+	ctx context.Context,
+	projectID uint,
+	req *types.CreateRegistryRequest,
+) (*types.Registry, error) {
+	resp := &types.Registry{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/registries",
+			projectID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// ListRegistries returns a list of registries for a project
+func (c *Client) ListRegistries(
+	ctx context.Context,
+	projectID uint,
+) (*types.RegistryListResponse, error) {
+	resp := &types.RegistryListResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/registries",
+			projectID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+// DeleteProjectRegistry deletes a registry given a project id and registry id
+func (c *Client) DeleteProjectRegistry(
+	ctx context.Context,
+	projectID uint,
+	registryID uint,
+) error {
+	return c.deleteRequest(
+		fmt.Sprintf(
+			"/projects/%d/registries/%d",
+			projectID,
+			registryID,
+		),
+		nil,
+		nil,
+	)
+}
+
+// GetECRAuthorizationToken gets an ECR authorization token
+func (c *Client) GetECRAuthorizationToken(
+	ctx context.Context,
+	projectID uint,
+	req *types.GetRegistryECRTokenRequest,
+) (*types.GetRegistryTokenResponse, error) {
+	resp := &types.GetRegistryTokenResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/registries/ecr/token",
+			projectID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// GetGCRAuthorizationToken gets a GCR authorization token
+func (c *Client) GetGCRAuthorizationToken(
+	ctx context.Context,
+	projectID uint,
+	req *types.GetRegistryGCRTokenRequest,
+) (*types.GetRegistryTokenResponse, error) {
+	resp := &types.GetRegistryTokenResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/registries/gcr/token",
+			projectID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// GetDockerhubAuthorizationToken gets a Docker Hub authorization token
+func (c *Client) GetDockerhubAuthorizationToken(
+	ctx context.Context,
+	projectID uint,
+) (*types.GetRegistryTokenResponse, error) {
+	resp := &types.GetRegistryTokenResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/registries/dockerhub/token",
+			projectID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+// GetDOCRAuthorizationToken gets a DOCR authorization token
+func (c *Client) GetDOCRAuthorizationToken(
+	ctx context.Context,
+	projectID uint,
+	req *types.GetRegistryGCRTokenRequest,
+) (*types.GetRegistryTokenResponse, error) {
+	resp := &types.GetRegistryTokenResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/registries/docr/token",
+			projectID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// ListRegistryRepositories lists the repositories in a registry
+func (c *Client) ListRegistryRepositories(
+	ctx context.Context,
+	projectID uint,
+	registryID uint,
+) (*types.ListRegistryRepositoryResponse, error) {
+	resp := &types.ListRegistryRepositoryResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/registries/%d/repositories",
+			projectID,
+			registryID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+// ListImages lists the images (repository+tag) in a repository
+func (c *Client) ListImages(
+	ctx context.Context,
+	projectID uint,
+	registryID uint,
+	repoName string,
+) (*types.ListImageResponse, error) {
+	resp := &types.ListImageResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/registries/%d/repositories/%s",
+			projectID,
+			registryID,
+			repoName,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+func (c *Client) CreateRepository(
+	ctx context.Context,
+	projectID, regID uint,
+	req *types.CreateRegistryRepositoryRequest,
+) error {
+	return c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/registries/%d/repository",
+			projectID,
+			regID,
+		),
+		req,
+		nil,
+	)
+}

+ 44 - 0
api/client/template.go

@@ -0,0 +1,44 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+func (c *Client) ListTemplates(
+	ctx context.Context,
+	req *types.ListTemplatesRequest,
+) (*types.ListTemplatesResponse, error) {
+	resp := &types.ListTemplatesResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/templates",
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+func (c *Client) GetTemplate(
+	ctx context.Context,
+	name, version string,
+	req *types.GetTemplateRequest,
+) (*types.GetTemplateResponse, error) {
+	resp := &types.GetTemplateResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/templates/%s/%s",
+			name, version,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}

+ 102 - 0
api/client/user.go

@@ -0,0 +1,102 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+// AuthCheck performs a check to ensure that the user is logged in
+func (c *Client) AuthCheck(ctx context.Context) (*types.GetAuthenticatedUserResponse, error) {
+	resp := &types.GetAuthenticatedUserResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/users/current",
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+// Login authorizes the user and grants them a cookie-based session
+func (c *Client) Login(ctx context.Context, req *types.LoginUserRequest) (*types.GetAuthenticatedUserResponse, error) {
+	resp := &types.GetAuthenticatedUserResponse{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/login",
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// Logout logs the user out and deauthorizes the cookie-based session
+func (c *Client) Logout(ctx context.Context) error {
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/logout",
+		),
+		nil,
+		nil,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	// remove the cookie, if it exists
+	return c.deleteCookie()
+}
+
+// CreateUser will create the user, authorize the user and grant them a cookie-based session
+func (c *Client) CreateUser(
+	ctx context.Context,
+	req *types.CreateUserRequest,
+) (*types.CreateUserResponse, error) {
+	resp := &types.CreateUserResponse{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/users",
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// ListUserProjects returns a list of projects associated with a user
+func (c *Client) ListUserProjects(ctx context.Context) (*types.ListUserProjectsResponse, error) {
+	resp := &types.ListUserProjectsResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects",
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+// DeleteUser deletes the current user
+func (c *Client) DeleteUser(
+	ctx context.Context,
+) error {
+	return c.deleteRequest(
+		fmt.Sprintf(
+			"/users/current",
+		),
+		nil,
+		nil,
+	)
+}

+ 181 - 0
api/server/authn/handler.go

@@ -0,0 +1,181 @@
+package authn
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/gorilla/sessions"
+	"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"
+)
+
+// AuthNFactory generates a middleware handler `AuthN`
+type AuthNFactory struct {
+	config *config.Config
+}
+
+// NewAuthNFactory returns an `AuthNFactory` that uses the passed-in server
+// config
+func NewAuthNFactory(
+	config *config.Config,
+) *AuthNFactory {
+	return &AuthNFactory{config}
+}
+
+// NewAuthenticated creates a new instance of `AuthN` that implements the http.Handler
+// interface.
+func (f *AuthNFactory) NewAuthenticated(next http.Handler) http.Handler {
+	return &AuthN{next, f.config, false}
+}
+
+// NewAuthenticatedWithRedirect creates a new instance of `AuthN` that implements the http.Handler
+// interface. This handler redirects the user to login if the user is not attached, and stores a
+// redirect URI in the session, if the session exists.
+func (f *AuthNFactory) NewAuthenticatedWithRedirect(next http.Handler) http.Handler {
+	return &AuthN{next, f.config, true}
+}
+
+// AuthN implements the authentication middleware
+type AuthN struct {
+	next     http.Handler
+	config   *config.Config
+	redirect bool
+}
+
+// ServeHTTP attaches an authenticated subject to the request context,
+// or serves a forbidden error. If authenticated, it calls the next handler.
+//
+// A token can either be issued for a specific project id or for a user. In the case
+// of a project id, we attach a service account to the context. In the case of a
+// user, we attach that user to the context.
+func (authn *AuthN) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// first check for a bearer token
+	tok, err := authn.getTokenFromRequest(r)
+
+	// if the error is not an invalid auth error, the token was invalid, and we throw error
+	// forbidden. If the error was an invalid auth error, we look for a cookie.
+	if err != nil && err != errInvalidAuthHeader {
+		authn.sendForbiddenError(err, w, r)
+		return
+	} else if err == nil && tok != nil {
+		authn.nextWithToken(w, r, tok)
+		return
+	}
+
+	// if the bearer token is not found, look for a request cookie
+	session, err := authn.config.Store.Get(r, authn.config.ServerConf.CookieName)
+
+	if err != nil {
+		session.Values["authenticated"] = false
+
+		// we attempt to save the session, but do not catch the error since we send the
+		// forbidden error regardless
+		session.Save(r, w)
+
+		authn.sendForbiddenError(err, w, r)
+		return
+	}
+
+	if auth, ok := session.Values["authenticated"].(bool); !auth || !ok {
+		authn.handleForbiddenForSession(w, r, fmt.Errorf("stored cookie was not authenticated"), session)
+		return
+	}
+
+	// read the user id in the token
+	userID, ok := session.Values["user_id"].(uint)
+
+	if !ok {
+		authn.handleForbiddenForSession(w, r, fmt.Errorf("could not cast user_id to uint"), session)
+		return
+	}
+
+	authn.nextWithUserID(w, r, userID)
+}
+
+func (authn *AuthN) handleForbiddenForSession(
+	w http.ResponseWriter,
+	r *http.Request,
+	err error,
+	session *sessions.Session,
+) {
+	if authn.redirect {
+		// need state parameter to validate when redirected
+		if r.URL.RawQuery == "" {
+			session.Values["redirect"] = r.URL.Path
+		} else {
+			session.Values["redirect"] = r.URL.Path + "?" + r.URL.RawQuery
+		}
+
+		session.Save(r, w)
+
+		http.Redirect(w, r, "/dashboard", 302)
+	} else {
+		authn.sendForbiddenError(err, w, r)
+	}
+
+	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
+
+	// for now, we just use nextWithUser using the `iby` field for the token
+	authn.nextWithUserID(w, r, tok.IBy)
+}
+
+// nextWithUserID calls the next handler with the user set in the context with key
+// `types.UserScope`.
+func (authn *AuthN) nextWithUserID(w http.ResponseWriter, r *http.Request, userID uint) {
+	// search for the user
+	user, err := authn.config.Repo.User().ReadUser(userID)
+
+	if err != nil {
+		authn.sendForbiddenError(fmt.Errorf("user with id %d not found in database", userID), w, r)
+		return
+	}
+
+	// add the user to the context
+	ctx := r.Context()
+	ctx = context.WithValue(ctx, types.UserScope, user)
+
+	r = r.Clone(ctx)
+	authn.next.ServeHTTP(w, r)
+}
+
+// sendForbiddenError sends a 403 Forbidden error to the end user while logging a
+// specific error
+func (authn *AuthN) sendForbiddenError(err error, w http.ResponseWriter, r *http.Request) {
+	reqErr := apierrors.NewErrForbidden(err)
+
+	apierrors.HandleAPIError(authn.config, w, r, reqErr)
+}
+
+var errInvalidToken = fmt.Errorf("authorization header exists, but token is not valid")
+var errInvalidAuthHeader = fmt.Errorf("invalid authorization header in request")
+
+// getTokenFromRequest finds an `Authorization` header of the form `Bearer <token>`,
+// and returns a valid token if it exists.
+func (authn *AuthN) getTokenFromRequest(r *http.Request) (*token.Token, error) {
+	reqToken := r.Header.Get("Authorization")
+	splitToken := strings.Split(reqToken, "Bearer")
+
+	if len(splitToken) != 2 {
+		return nil, errInvalidAuthHeader
+	}
+
+	reqToken = strings.TrimSpace(splitToken[1])
+
+	tok, err := token.GetTokenFromEncoded(reqToken, authn.config.TokenConf)
+
+	if err != nil {
+		return nil, errInvalidToken
+	}
+
+	return tok, nil
+}

+ 257 - 0
api/server/authn/handler_test.go

@@ -0,0 +1,257 @@
+package authn_test
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/porter-dev/porter/api/server/authn"
+	"github.com/porter-dev/porter/api/server/shared/apitest"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/stretchr/testify/assert"
+	"gorm.io/gorm"
+)
+
+func TestAuthenticatedUserWithCookie(t *testing.T) {
+	config, handler, next := loadHandlers(t)
+
+	req, err := http.NewRequest("GET", "/auth-endpoint", nil)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+
+	// create a new user and a cookie for them
+	user := apitest.CreateTestUser(t, config, true)
+	cookie := apitest.AuthenticateUserWithCookie(t, config, user, false)
+	req.AddCookie(cookie)
+
+	handler.ServeHTTP(rr, req)
+
+	assertNextHandlerCalled(t, next, rr, user)
+}
+
+func TestUnauthenticatedUserWithCookie(t *testing.T) {
+	_, handler, next := loadHandlers(t)
+
+	req, err := http.NewRequest("GET", "/auth-endpoint", nil)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+
+	// make the request without a cookie set
+	handler.ServeHTTP(rr, req)
+
+	assertForbiddenError(t, next, rr)
+}
+
+func TestUnauthenticatedUserWithCookieRedirect(t *testing.T) {
+	_, handler, next := loadHandlersWithRedirect(t)
+
+	req, err := http.NewRequest("GET", "/auth-endpoint", nil)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+
+	// make the request without a cookie set
+	handler.ServeHTTP(rr, req)
+
+	assert.Equal(t, http.StatusFound, rr.Result().StatusCode)
+	gotLoc, err := rr.Result().Location()
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	assert.Equal(t, "/dashboard", gotLoc.Path)
+	assert.False(t, next.WasCalled, "next handler should not have been called")
+}
+
+func TestAuthenticatedUserWithToken(t *testing.T) {
+	config, handler, next := loadHandlers(t)
+
+	req, err := http.NewRequest("GET", "/auth-endpoint", nil)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+
+	// create a new user for the token to reference
+	user := apitest.CreateTestUser(t, config, true)
+	tokenStr := apitest.AuthenticateUserWithToken(t, config, user.ID)
+	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tokenStr))
+
+	handler.ServeHTTP(rr, req)
+
+	assertNextHandlerCalled(t, next, rr, user)
+}
+
+func TestUnauthenticatedUserWithToken(t *testing.T) {
+	_, handler, next := loadHandlers(t)
+
+	req, err := http.NewRequest("GET", "/auth-endpoint", nil)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+
+	// create a new user and a cookie for them
+	tokenStr := "badtokenstring"
+	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tokenStr))
+
+	handler.ServeHTTP(rr, req)
+
+	assertForbiddenError(t, next, rr)
+}
+
+func TestAuthBadDatabaseRead(t *testing.T) {
+	config, handler, next := loadHandlers(t)
+
+	req, err := http.NewRequest("GET", "/auth-endpoint", nil)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+
+	// create a new user and a cookie for them
+	user := apitest.CreateTestUser(t, config, true)
+	cookie := apitest.AuthenticateUserWithCookie(t, config, user, false)
+	req.AddCookie(cookie)
+
+	// set the repository interface to one that can't query from the db
+	configLoader := apitest.NewTestConfigLoader(false)
+	config, err = configLoader.LoadConfig()
+	factory := authn.NewAuthNFactory(config)
+	handler = factory.NewAuthenticated(next)
+
+	handler.ServeHTTP(rr, req)
+
+	assertForbiddenError(t, next, rr)
+}
+
+func TestAuthBadSessionUserWrite(t *testing.T) {
+	config, handler, next := loadHandlers(t)
+
+	req, err := http.NewRequest("GET", "/auth-endpoint", nil)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+
+	// create a new user and a cookie for them
+	apitest.CreateTestUser(t, config, true)
+
+	// create cookie where session values are incorrect
+	// i.e. written for a user that doesn't exist (id 500)
+	cookie := apitest.AuthenticateUserWithCookie(t, config, &models.User{
+		Model: gorm.Model{
+			ID: 500,
+		},
+	}, false)
+
+	req.AddCookie(cookie)
+	handler.ServeHTTP(rr, req)
+
+	assertForbiddenError(t, next, rr)
+}
+
+func TestAuthBadSessionUserIDType(t *testing.T) {
+	config, handler, next := loadHandlers(t)
+
+	req, err := http.NewRequest("GET", "/auth-endpoint", nil)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+
+	// create a new user and a cookie for them
+	user := apitest.CreateTestUser(t, config, true)
+
+	// create cookie where session values are incorrect
+	// i.e. written for a user that doesn't exist (id 500)
+	cookie := apitest.AuthenticateUserWithCookie(t, config, user, true)
+
+	req.AddCookie(cookie)
+	handler.ServeHTTP(rr, req)
+
+	assertForbiddenError(t, next, rr)
+}
+
+type testHandler struct {
+	WasCalled bool
+	User      *models.User
+}
+
+func (t *testHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	t.WasCalled = true
+
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	t.User = user
+}
+
+func loadHandlers(t *testing.T) (*config.Config, http.Handler, *testHandler) {
+	config := apitest.LoadConfig(t)
+
+	factory := authn.NewAuthNFactory(config)
+
+	next := &testHandler{}
+	handler := factory.NewAuthenticated(next)
+
+	return config, handler, next
+}
+
+func loadHandlersWithRedirect(t *testing.T) (*config.Config, http.Handler, *testHandler) {
+	config := apitest.LoadConfig(t)
+
+	factory := authn.NewAuthNFactory(config)
+
+	next := &testHandler{}
+	handler := factory.NewAuthenticatedWithRedirect(next)
+
+	return config, handler, next
+}
+
+func assertForbiddenError(t *testing.T, next *testHandler, rr *httptest.ResponseRecorder) {
+	assert := assert.New(t)
+
+	// first assert that that the next middleware was not called
+	assert.False(next.WasCalled, "next handler should not have been called")
+
+	apitest.AssertResponseForbidden(t, rr)
+}
+
+func assertNextHandlerCalled(
+	t *testing.T,
+	next *testHandler,
+	rr *httptest.ResponseRecorder,
+	expUser *models.User,
+) {
+	// make sure the handler was called with the expected user, and resulted in 200 OK
+	assert := assert.New(t)
+
+	assert.True(next.WasCalled, "next handler should have been called")
+	assert.Equal(expUser, next.User, "user should be equal")
+	assert.Equal(http.StatusOK, rr.Result().StatusCode, "status code should be ok")
+}

+ 51 - 0
api/server/authn/session_helpers.go

@@ -0,0 +1,51 @@
+package authn
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+func SaveUserAuthenticated(
+	w http.ResponseWriter,
+	r *http.Request,
+	config *config.Config,
+	user *models.User,
+) error {
+	session, err := config.Store.Get(r, config.ServerConf.CookieName)
+
+	if err != nil {
+		return err
+	}
+
+	var redirect string
+
+	if valR := session.Values["redirect"]; valR != nil {
+		redirect = session.Values["redirect"].(string)
+	}
+
+	session.Values["authenticated"] = true
+	session.Values["user_id"] = user.ID
+	session.Values["email"] = user.Email
+	session.Values["redirect"] = redirect
+
+	return session.Save(r, w)
+}
+
+func SaveUserUnauthenticated(
+	w http.ResponseWriter,
+	r *http.Request,
+	config *config.Config,
+) error {
+	session, err := config.Store.Get(r, config.ServerConf.CookieName)
+
+	if err != nil {
+		return err
+	}
+
+	session.Values["authenticated"] = false
+	session.Values["user_id"] = nil
+	session.Values["email"] = nil
+	return session.Save(r, w)
+}

+ 169 - 0
api/server/authz/cluster.go

@@ -0,0 +1,169 @@
+package authz
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"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/helm"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+	"k8s.io/client-go/dynamic"
+)
+
+const KubernetesAgentCtxKey string = "k8s-agent"
+const KubernetesDynamicClientCtxKey string = "k8s-dyn-client"
+const HelmAgentCtxKey string = "helm-agent"
+
+type ClusterScopedFactory struct {
+	config *config.Config
+}
+
+func NewClusterScopedFactory(
+	config *config.Config,
+) *ClusterScopedFactory {
+	return &ClusterScopedFactory{config}
+}
+
+func (p *ClusterScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &ClusterScopedMiddleware{next, p.config}
+}
+
+type ClusterScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (p *ClusterScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project to check scopes
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// get the cluster id from the URL param context
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	clusterID := reqScopes[types.ClusterScope].Resource.UInt
+	cluster, err := p.config.Repo.Cluster().ReadCluster(proj.ID, clusterID)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
+				fmt.Errorf("cluster with id %d not found in project %d", clusterID, proj.ID),
+			))
+		} else {
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		}
+
+		return
+	}
+
+	ctx := NewClusterContext(r.Context(), cluster)
+	r = r.Clone(ctx)
+	p.next.ServeHTTP(w, r)
+}
+
+func NewClusterContext(ctx context.Context, cluster *models.Cluster) context.Context {
+	return context.WithValue(ctx, types.ClusterScope, cluster)
+}
+
+type KubernetesAgentGetter interface {
+	GetOutOfClusterConfig(cluster *models.Cluster) *kubernetes.OutOfClusterConfig
+	GetDynamicClient(r *http.Request, cluster *models.Cluster) (dynamic.Interface, error)
+	GetAgent(r *http.Request, cluster *models.Cluster) (*kubernetes.Agent, error)
+	GetHelmAgent(r *http.Request, cluster *models.Cluster) (*helm.Agent, error)
+}
+
+type OutOfClusterAgentGetter struct {
+	config *config.Config
+}
+
+func NewOutOfClusterAgentGetter(config *config.Config) KubernetesAgentGetter {
+	return &OutOfClusterAgentGetter{config}
+}
+
+func (d *OutOfClusterAgentGetter) GetOutOfClusterConfig(cluster *models.Cluster) *kubernetes.OutOfClusterConfig {
+	return &kubernetes.OutOfClusterConfig{
+		Repo:              d.config.Repo,
+		DigitalOceanOAuth: d.config.DOConf,
+		Cluster:           cluster,
+	}
+}
+
+func (d *OutOfClusterAgentGetter) GetAgent(r *http.Request, cluster *models.Cluster) (*kubernetes.Agent, error) {
+	// look for the agent in context
+	ctxAgentVal := r.Context().Value(KubernetesAgentCtxKey)
+
+	if ctxAgentVal != nil {
+		if agent, ok := ctxAgentVal.(*kubernetes.Agent); ok {
+			return agent, nil
+		}
+	}
+
+	// if agent not found in context, get the agent from out of cluster config
+	ooc := d.GetOutOfClusterConfig(cluster)
+
+	agent, err := kubernetes.GetAgentOutOfClusterConfig(ooc)
+
+	if err != nil {
+		return nil, fmt.Errorf("failed to get agent: %s", err.Error())
+	}
+
+	newCtx := context.WithValue(r.Context(), KubernetesAgentCtxKey, agent)
+
+	r = r.WithContext(newCtx)
+
+	return agent, nil
+}
+
+func (d *OutOfClusterAgentGetter) GetHelmAgent(r *http.Request, cluster *models.Cluster) (*helm.Agent, error) {
+	// look for the agent in context
+	ctxAgentVal := r.Context().Value(HelmAgentCtxKey)
+
+	if ctxAgentVal != nil {
+		if agent, ok := ctxAgentVal.(*helm.Agent); ok {
+			return agent, nil
+		}
+	}
+
+	// if helm agent not found in context, construct it from k8s agent
+	k8sAgent, err := d.GetAgent(r, cluster)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// look for namespace in context, otherwise go with default
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	namespace := "default"
+
+	if nsPolicy, ok := reqScopes[types.NamespaceScope]; ok {
+		namespace = nsPolicy.Resource.Name
+	}
+
+	helmAgent, err := helm.GetAgentFromK8sAgent("secret", namespace, d.config.Logger, k8sAgent)
+
+	if err != nil {
+		return nil, fmt.Errorf("failed to get Helm agent: %s", err.Error())
+	}
+
+	newCtx := context.WithValue(r.Context(), HelmAgentCtxKey, helmAgent)
+
+	r = r.WithContext(newCtx)
+
+	return helmAgent, nil
+}
+
+func (d *OutOfClusterAgentGetter) GetDynamicClient(r *http.Request, cluster *models.Cluster) (dynamic.Interface, error) {
+	// look for the agent in context
+	ctxDynClientVal := r.Context().Value(KubernetesDynamicClientCtxKey)
+
+	if ctxDynClientVal != nil {
+		if dynClient, ok := ctxDynClientVal.(dynamic.Interface); ok {
+			return dynClient, nil
+		}
+	}
+
+	return kubernetes.GetDynamicClientOutOfClusterConfig(d.GetOutOfClusterConfig(cluster))
+}

+ 132 - 0
api/server/authz/git_installation.go

@@ -0,0 +1,132 @@
+package authz
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/google/go-github/github"
+	"golang.org/x/oauth2"
+
+	"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"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/oauth"
+)
+
+type GitInstallationScopedFactory struct {
+	config *config.Config
+}
+
+func NewGitInstallationScopedFactory(
+	config *config.Config,
+) *GitInstallationScopedFactory {
+	return &GitInstallationScopedFactory{config}
+}
+
+func (p *GitInstallationScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &GitInstallationScopedMiddleware{next, p.config}
+}
+
+type GitInstallationScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (p *GitInstallationScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the user to perform authorization
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	// get the registry id from the URL param context
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	gitInstallationID := reqScopes[types.GitInstallationScope].Resource.UInt
+
+	gitInstallation, err := p.config.Repo.GithubAppInstallation().ReadGithubAppInstallationByInstallationID(gitInstallationID)
+
+	if err != nil {
+		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if err := p.doesUserHaveGitInstallationAccess(user.GithubAppIntegrationID, gitInstallationID); err != nil {
+		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	ctx := NewGitInstallationContext(r.Context(), gitInstallation)
+	r = r.Clone(ctx)
+	p.next.ServeHTTP(w, r)
+}
+
+func NewGitInstallationContext(ctx context.Context, ga *integrations.GithubAppInstallation) context.Context {
+	return context.WithValue(ctx, types.GitInstallationScope, ga)
+}
+
+// DoesUserHaveGitInstallationAccess checks that a user has access to an installation id
+// by ensuring the installation id exists for one org or account they have access to
+// note that this makes a github API request, but the endpoint is fast so this doesn't add
+// much overhead
+func (p *GitInstallationScopedMiddleware) doesUserHaveGitInstallationAccess(githubIntegrationID, gitInstallationID uint) error {
+	oauthInt, err := p.config.Repo.GithubAppOAuthIntegration().ReadGithubAppOauthIntegration(githubIntegrationID)
+
+	if err != nil {
+		return err
+	}
+
+	if _, _, err = oauth.GetAccessToken(oauthInt.SharedOAuthModel,
+		p.config.GithubConf,
+		oauth.MakeUpdateGithubAppOauthIntegrationFunction(oauthInt, p.config.Repo)); err != nil {
+		return err
+	}
+
+	client := github.NewClient(p.config.GithubConf.Client(oauth2.NoContext, &oauth2.Token{
+		AccessToken:  string(oauthInt.AccessToken),
+		RefreshToken: string(oauthInt.RefreshToken),
+		TokenType:    "Bearer",
+	}))
+
+	accountIDs := make([]int64, 0)
+
+	AuthUser, _, err := client.Users.Get(context.Background(), "")
+
+	if err != nil {
+		return err
+	}
+
+	accountIDs = append(accountIDs, *AuthUser.ID)
+
+	opts := &github.ListOptions{
+		PerPage: 100,
+		Page:    1,
+	}
+
+	for {
+		orgs, pages, err := client.Organizations.List(context.Background(), "", opts)
+
+		if err != nil {
+			return err
+		}
+
+		for _, org := range orgs {
+			accountIDs = append(accountIDs, *org.ID)
+		}
+
+		if pages.NextPage == 0 {
+			break
+		}
+	}
+
+	installations, err := p.config.Repo.GithubAppInstallation().ReadGithubAppInstallationByAccountIDs(accountIDs)
+
+	for _, installation := range installations {
+		if uint(installation.InstallationID) == gitInstallationID {
+			return nil
+		}
+	}
+
+	return apierrors.NewErrForbidden(
+		fmt.Errorf("user does not have access to github app installation %d", gitInstallationID),
+	)
+}

+ 63 - 0
api/server/authz/helm_repo.go

@@ -0,0 +1,63 @@
+package authz
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type HelmRepoScopedFactory struct {
+	config *config.Config
+}
+
+func NewHelmRepoScopedFactory(
+	config *config.Config,
+) *HelmRepoScopedFactory {
+	return &HelmRepoScopedFactory{config}
+}
+
+func (p *HelmRepoScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &HelmRepoScopedMiddleware{next, p.config}
+}
+
+type HelmRepoScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (p *HelmRepoScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project to check scopes
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// get the registry id from the URL param context
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	helmRepoID := reqScopes[types.HelmRepoScope].Resource.UInt
+
+	helmRepo, err := p.config.Repo.HelmRepo().ReadHelmRepo(proj.ID, helmRepoID)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
+				fmt.Errorf("helm repo with id %d not found in project %d", helmRepoID, proj.ID),
+			))
+		} else {
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		}
+
+		return
+	}
+
+	ctx := NewHelmRepoContext(r.Context(), helmRepo)
+	r = r.Clone(ctx)
+	p.next.ServeHTTP(w, r)
+}
+
+func NewHelmRepoContext(ctx context.Context, helmRepo *models.HelmRepo) context.Context {
+	return context.WithValue(ctx, types.HelmRepoScope, helmRepo)
+}

+ 63 - 0
api/server/authz/infra.go

@@ -0,0 +1,63 @@
+package authz
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type InfraScopedFactory struct {
+	config *config.Config
+}
+
+func NewInfraScopedFactory(
+	config *config.Config,
+) *InfraScopedFactory {
+	return &InfraScopedFactory{config}
+}
+
+func (p *InfraScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &InfraScopedMiddleware{next, p.config}
+}
+
+type InfraScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (p *InfraScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project to check scopes
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// get the registry id from the URL param context
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	infraID := reqScopes[types.InfraScope].Resource.UInt
+
+	infra, err := p.config.Repo.Infra().ReadInfra(proj.ID, infraID)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
+				fmt.Errorf("infra with id %d not found in project %d", infraID, proj.ID),
+			))
+		} else {
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		}
+
+		return
+	}
+
+	ctx := NewInfraContext(r.Context(), infra)
+	r = r.Clone(ctx)
+	p.next.ServeHTTP(w, r)
+}
+
+func NewInfraContext(ctx context.Context, infra *models.Infra) context.Context {
+	return context.WithValue(ctx, types.InfraScope, infra)
+}

+ 63 - 0
api/server/authz/invite.go

@@ -0,0 +1,63 @@
+package authz
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type InviteScopedFactory struct {
+	config *config.Config
+}
+
+func NewInviteScopedFactory(
+	config *config.Config,
+) *InviteScopedFactory {
+	return &InviteScopedFactory{config}
+}
+
+func (p *InviteScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &InviteScopedMiddleware{next, p.config}
+}
+
+type InviteScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (p *InviteScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project to check scopes
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// get the invite id from the URL param context
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	inviteID := reqScopes[types.InviteScope].Resource.UInt
+
+	invite, err := p.config.Repo.Invite().ReadInvite(proj.ID, inviteID)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
+				fmt.Errorf("invite with id %d not found in project %d", inviteID, proj.ID),
+			))
+		} else {
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		}
+
+		return
+	}
+
+	ctx := NewInviteContext(r.Context(), invite)
+	r = r.Clone(ctx)
+	p.next.ServeHTTP(w, r)
+}
+
+func NewInviteContext(ctx context.Context, invite *models.Invite) context.Context {
+	return context.WithValue(ctx, types.InviteScope, invite)
+}

+ 48 - 0
api/server/authz/namespace.go

@@ -0,0 +1,48 @@
+package authz
+
+import (
+	"context"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type NamespaceScopedFactory struct {
+	config *config.Config
+}
+
+func NewNamespaceScopedFactory(
+	config *config.Config,
+) *NamespaceScopedFactory {
+	return &NamespaceScopedFactory{config}
+}
+
+func (p *NamespaceScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &NamespaceScopedMiddleware{next, p.config}
+}
+
+type NamespaceScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (n *NamespaceScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// get the namespace from the URL param context
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+
+	namespace := reqScopes[types.NamespaceScope].Resource.Name
+
+	if strings.ToLower(namespace) == "all" {
+		namespace = ""
+	}
+
+	ctx := NewNamespaceContext(r.Context(), namespace)
+	r = r.Clone(ctx)
+	n.next.ServeHTTP(w, r)
+}
+
+func NewNamespaceContext(ctx context.Context, namespace string) context.Context {
+	return context.WithValue(ctx, types.NamespaceScope, namespace)
+}

+ 128 - 0
api/server/authz/policy.go

@@ -0,0 +1,128 @@
+package authz
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz/policy"
+	"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"
+)
+
+type PolicyMiddleware struct {
+	config       *config.Config
+	endpointMeta types.APIRequestMetadata
+	loader       policy.PolicyDocumentLoader
+}
+
+func NewPolicyMiddleware(
+	config *config.Config,
+	endpointMeta types.APIRequestMetadata,
+	loader policy.PolicyDocumentLoader,
+) *PolicyMiddleware {
+	return &PolicyMiddleware{config, endpointMeta, loader}
+}
+
+func (p *PolicyMiddleware) Middleware(next http.Handler) http.Handler {
+	return &PolicyHandler{next, p.config, p.endpointMeta, p.loader}
+}
+
+type PolicyHandler struct {
+	next         http.Handler
+	config       *config.Config
+	endpointMeta types.APIRequestMetadata
+	loader       policy.PolicyDocumentLoader
+}
+
+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)
+
+	if reqErr != nil {
+		apierrors.HandleAPIError(h.config, w, r, reqErr)
+		return
+	}
+
+	// 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)
+
+	if reqErr != nil {
+		apierrors.HandleAPIError(h.config, w, r, reqErr)
+		return
+	}
+
+	// validate that the policy permits the action
+	hasAccess := policy.HasScopeAccess(policyDocs, reqScopes)
+
+	if !hasAccess {
+		apierrors.HandleAPIError(
+			h.config,
+			w,
+			r,
+			apierrors.NewErrForbidden(fmt.Errorf("policy forbids action for user %d in project %d", user.ID, projID)),
+		)
+
+		return
+	}
+
+	// add the set of resource ids to the request context
+	ctx := NewRequestScopeCtx(r.Context(), reqScopes)
+	r = r.Clone(ctx)
+	h.next.ServeHTTP(w, r)
+}
+
+func NewRequestScopeCtx(ctx context.Context, reqScopes map[types.PermissionScope]*types.RequestAction) context.Context {
+	return context.WithValue(ctx, types.RequestScopeCtxKey, reqScopes)
+}
+
+func getRequestActionForEndpoint(
+	r *http.Request,
+	endpointMeta types.APIRequestMetadata,
+) (res map[types.PermissionScope]*types.RequestAction, reqErr apierrors.RequestError) {
+	res = make(map[types.PermissionScope]*types.RequestAction)
+
+	// iterate through scopes, attach policies as needed
+	for _, scope := range endpointMeta.Scopes {
+		// find the resource ID and create the resource
+		resource := types.NameOrUInt{}
+
+		switch scope {
+		case types.ProjectScope:
+			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamProjectID)
+		case types.ClusterScope:
+			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamClusterID)
+		case types.RegistryScope:
+			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamRegistryID)
+		case types.HelmRepoScope:
+			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamHelmRepoID)
+		case types.GitInstallationScope:
+			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamGitInstallationID)
+		case types.InfraScope:
+			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamInfraID)
+		case types.NamespaceScope:
+			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamNamespace)
+		case types.ReleaseScope:
+			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamReleaseName)
+		case types.InviteScope:
+			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamInviteID)
+		}
+
+		if reqErr != nil {
+			return nil, reqErr
+		}
+
+		res[scope] = &types.RequestAction{
+			Verb:     endpointMeta.Verb,
+			Resource: resource,
+		}
+	}
+
+	return res, nil
+}

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

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

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

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

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

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

+ 150 - 0
api/server/authz/policy/policy.go

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

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

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

+ 305 - 0
api/server/authz/policy_test.go

@@ -0,0 +1,305 @@
+package authz_test
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/authz/policy"
+	"github.com/porter-dev/porter/api/server/handlers/project"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/apitest"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestPolicyMiddlewareSuccessfulProjectCluster(t *testing.T) {
+	config, handler, next := loadHandlers(t, types.APIRequestMetadata{
+		Verb:   types.APIVerbCreate,
+		Method: types.HTTPVerbPost,
+		Scopes: []types.PermissionScope{
+			types.ProjectScope,
+			types.ClusterScope,
+		},
+	}, false, false)
+
+	user := apitest.CreateTestUser(t, config, true)
+	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+		Name: "test-project",
+	}, user)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, rr := apitest.GetRequestAndRecorder(t, string(types.HTTPVerbPost), "/api/projects/1/clusters/1", nil)
+
+	req = apitest.WithURLParams(t, req, map[string]string{
+		"project_id": "1",
+		"cluster_id": "1",
+	})
+
+	req = apitest.WithAuthenticatedUser(t, req, user)
+
+	handler.ServeHTTP(rr, req)
+
+	assertNextHandlerCalled(t, next, rr, map[types.PermissionScope]*types.RequestAction{
+		types.ProjectScope: {
+			Verb: types.APIVerbCreate,
+			Resource: types.NameOrUInt{
+				UInt: 1,
+			},
+		},
+		types.ClusterScope: {
+			Verb: types.APIVerbCreate,
+			Resource: types.NameOrUInt{
+				UInt: 1,
+			},
+		},
+	})
+}
+
+func TestPolicyMiddlewareSuccessfulApplication(t *testing.T) {
+	config, handler, next := loadHandlers(t, types.APIRequestMetadata{
+		Verb:   types.APIVerbCreate,
+		Method: types.HTTPVerbPost,
+		Scopes: []types.PermissionScope{
+			types.ProjectScope,
+			types.ClusterScope,
+			types.NamespaceScope,
+			types.ReleaseScope,
+		},
+	}, false, false)
+
+	user := apitest.CreateTestUser(t, config, true)
+	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+		Name: "test-project",
+	}, user)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, rr := apitest.GetRequestAndRecorder(
+		t,
+		string(types.HTTPVerbPost),
+		"/api/projects/1/clusters/1/default/app-1",
+		nil,
+	)
+
+	req = apitest.WithURLParams(t, req, map[string]string{
+		"project_id": "1",
+		"cluster_id": "1",
+		"namespace":  "default",
+		"name":       "app-1",
+	})
+
+	req = apitest.WithAuthenticatedUser(t, req, user)
+
+	handler.ServeHTTP(rr, req)
+
+	assertNextHandlerCalled(t, next, rr, map[types.PermissionScope]*types.RequestAction{
+		types.ProjectScope: {
+			Verb: types.APIVerbCreate,
+			Resource: types.NameOrUInt{
+				UInt: 1,
+			},
+		},
+		types.ClusterScope: {
+			Verb: types.APIVerbCreate,
+			Resource: types.NameOrUInt{
+				UInt: 1,
+			},
+		},
+		types.NamespaceScope: {
+			Verb: types.APIVerbCreate,
+			Resource: types.NameOrUInt{
+				Name: "default",
+			},
+		},
+		types.ReleaseScope: {
+			Verb: types.APIVerbCreate,
+			Resource: types.NameOrUInt{
+				Name: "app-1",
+			},
+		},
+	})
+}
+
+func TestPolicyMiddlewareInvalidPermissions(t *testing.T) {
+	config, handler, next := loadHandlers(t, types.APIRequestMetadata{
+		Verb:   types.APIVerbCreate,
+		Method: types.HTTPVerbPost,
+		Scopes: []types.PermissionScope{
+			types.ProjectScope,
+			types.ClusterScope,
+		},
+	}, false, true)
+
+	user := apitest.CreateTestUser(t, config, true)
+	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+		Name: "test-project",
+	}, user)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, rr := apitest.GetRequestAndRecorder(t, string(types.HTTPVerbPost), "/api/projects/1/clusters/1", nil)
+
+	req = apitest.WithURLParams(t, req, map[string]string{
+		"project_id": "1",
+		"cluster_id": "1",
+	})
+
+	req = apitest.WithAuthenticatedUser(t, req, user)
+
+	handler.ServeHTTP(rr, req)
+
+	assert.False(t, next.WasCalled, "next handler should not have been called")
+	apitest.AssertResponseForbidden(t, rr)
+}
+
+func TestPolicyMiddlewareFailInvalidLoader(t *testing.T) {
+	config, handler, next := loadHandlers(t, types.APIRequestMetadata{
+		Verb:   types.APIVerbCreate,
+		Method: types.HTTPVerbPost,
+		Scopes: []types.PermissionScope{
+			types.ProjectScope,
+			types.ClusterScope,
+		},
+	}, true, false)
+
+	user := apitest.CreateTestUser(t, config, true)
+	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+		Name: "test-project",
+	}, user)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, rr := apitest.GetRequestAndRecorder(t, string(types.HTTPVerbPost), "/api/projects/1/clusters/1", nil)
+
+	req = apitest.WithURLParams(t, req, map[string]string{
+		"project_id": "1",
+		"cluster_id": "1",
+	})
+
+	req = apitest.WithAuthenticatedUser(t, req, user)
+
+	handler.ServeHTTP(rr, req)
+
+	assertInternalError(t, next, rr)
+}
+
+func TestPolicyMiddlewareFailBadParam(t *testing.T) {
+	config, handler, next := loadHandlers(t, types.APIRequestMetadata{
+		Verb:   types.APIVerbCreate,
+		Method: types.HTTPVerbPost,
+		Scopes: []types.PermissionScope{
+			types.ProjectScope,
+			types.ClusterScope,
+		},
+	}, true, false)
+
+	user := apitest.CreateTestUser(t, config, true)
+	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+		Name: "test-project",
+	}, user)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, rr := apitest.GetRequestAndRecorder(t, string(types.HTTPVerbPost), "/api/projects/1/clusters/1", nil)
+
+	req = apitest.WithURLParams(t, req, map[string]string{
+		"project_id": "notuint",
+		"cluster_id": "1",
+	})
+
+	req = apitest.WithAuthenticatedUser(t, req, user)
+
+	handler.ServeHTTP(rr, req)
+
+	assert.False(t, next.WasCalled, "next handler should not have been called")
+	apitest.AssertResponseError(t, rr, http.StatusBadRequest, &types.ExternalError{
+		Error: fmt.Sprintf("could not convert url parameter %s to uint, got %s", "project_id", "notuint"),
+	})
+}
+
+func loadHandlers(
+	t *testing.T,
+	endpointMeta types.APIRequestMetadata,
+	shouldLoaderFail bool,
+	shouldLoaderLoadViewer bool,
+) (*config.Config, http.Handler, *testHandler) {
+	config := apitest.LoadConfig(t)
+	var loader policy.PolicyDocumentLoader = policy.NewBasicPolicyDocumentLoader(config.Repo.Project())
+
+	if shouldLoaderFail {
+		loader = &failingDocLoader{}
+	}
+
+	if shouldLoaderLoadViewer {
+		loader = &viewerDocLoader{}
+	}
+
+	mwFactory := authz.NewPolicyMiddleware(config, endpointMeta, loader)
+
+	next := &testHandler{}
+	handler := mwFactory.Middleware(next)
+
+	return config, handler, next
+}
+
+type failingDocLoader struct{}
+
+func (f *failingDocLoader) LoadPolicyDocuments(userID, projectID uint) ([]*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
+}
+
+type testHandler struct {
+	WasCalled bool
+	ReqScopes map[types.PermissionScope]*types.RequestAction
+}
+
+func (t *testHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	t.WasCalled = true
+
+	t.ReqScopes, _ = r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+}
+
+func assertNextHandlerCalled(
+	t *testing.T,
+	next *testHandler,
+	rr *httptest.ResponseRecorder,
+	expScopes map[types.PermissionScope]*types.RequestAction,
+) {
+	// make sure the handler was called with the expected user, and resulted in 200 OK
+	assert := assert.New(t)
+
+	assert.True(next.WasCalled, "next handler should have been called")
+	assert.Equal(expScopes, next.ReqScopes, "expected scopes should be equal")
+	assert.Equal(http.StatusOK, rr.Result().StatusCode, "status code should be ok")
+}
+
+func assertInternalError(t *testing.T, next *testHandler, rr *httptest.ResponseRecorder) {
+	assert := assert.New(t)
+
+	// first assert that that the next middleware was not called
+	assert.False(next.WasCalled, "next handler should not have been called")
+
+	apitest.AssertResponseInternalServerError(t, rr)
+}

+ 52 - 0
api/server/authz/project.go

@@ -0,0 +1,52 @@
+package authz
+
+import (
+	"context"
+	"net/http"
+
+	"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 ProjectScopedFactory struct {
+	config *config.Config
+}
+
+func NewProjectScopedFactory(
+	config *config.Config,
+) *ProjectScopedFactory {
+	return &ProjectScopedFactory{config}
+}
+
+func (p *ProjectScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &ProjectScopedMiddleware{next, p.config}
+}
+
+type ProjectScopedMiddleware struct {
+	next   http.Handler
+	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)
+
+	projID := reqScopes[types.ProjectScope].Resource.UInt
+
+	project, err := p.config.Repo.Project().ReadProject(projID)
+
+	if err != nil {
+		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	ctx := NewProjectContext(r.Context(), project)
+	r = r.Clone(ctx)
+	p.next.ServeHTTP(w, r)
+}
+
+func NewProjectContext(ctx context.Context, project *models.Project) context.Context {
+	return context.WithValue(ctx, types.ProjectScope, project)
+}

+ 97 - 0
api/server/authz/project_test.go

@@ -0,0 +1,97 @@
+package authz_test
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers/project"
+	"github.com/porter-dev/porter/api/server/shared/apitest"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository/test"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestProjectMiddlewareSuccessful(t *testing.T) {
+	config, handler, next := loadProjectHandlers(t)
+
+	user := apitest.CreateTestUser(t, config, true)
+	proj, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+		Name: "test-project",
+	}, user)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, rr := apitest.GetRequestAndRecorder(t, string(types.HTTPVerbPost), "/api/projects/1", nil)
+	req = apitest.WithAuthenticatedUser(t, req, user)
+	req = apitest.WithRequestScopes(t, req, map[types.PermissionScope]*types.RequestAction{
+		types.ProjectScope: {
+			Verb: types.APIVerbCreate,
+			Resource: types.NameOrUInt{
+				UInt: 1,
+			},
+		},
+	})
+
+	handler.ServeHTTP(rr, req)
+	assert.True(t, next.WasCalled, "next handler should have been called")
+	assert.Equal(t, proj, next.Project, "project should be equal")
+}
+
+func TestProjectMiddlewareFailedRead(t *testing.T) {
+	config, _, _ := loadProjectHandlers(t)
+
+	user := apitest.CreateTestUser(t, config, true)
+	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+		Name: "test-project",
+	}, user)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config, handler, next := loadProjectHandlers(t, test.ReadProjectMethod)
+
+	req, rr := apitest.GetRequestAndRecorder(t, string(types.HTTPVerbPost), "/api/projects/1", nil)
+	req = apitest.WithAuthenticatedUser(t, req, user)
+	req = apitest.WithRequestScopes(t, req, map[types.PermissionScope]*types.RequestAction{
+		types.ProjectScope: {
+			Verb: types.APIVerbCreate,
+			Resource: types.NameOrUInt{
+				UInt: 1,
+			},
+		},
+	})
+
+	handler.ServeHTTP(rr, req)
+	assert.False(t, next.WasCalled, "next handler should not have been called")
+	apitest.AssertResponseInternalServerError(t, rr)
+}
+
+func loadProjectHandlers(
+	t *testing.T,
+	failingRepoMethods ...string,
+) (*config.Config, http.Handler, *testProjectHandler) {
+	config := apitest.LoadConfig(t, failingRepoMethods...)
+	mwFactory := authz.NewProjectScopedFactory(config)
+
+	next := &testProjectHandler{}
+	handler := mwFactory.Middleware(next)
+
+	return config, handler, next
+}
+
+type testProjectHandler struct {
+	WasCalled bool
+	Project   *models.Project
+}
+
+func (t *testProjectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	t.WasCalled = true
+
+	t.Project, _ = r.Context().Value(types.ProjectScope).(*models.Project)
+}

+ 63 - 0
api/server/authz/registry.go

@@ -0,0 +1,63 @@
+package authz
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type RegistryScopedFactory struct {
+	config *config.Config
+}
+
+func NewRegistryScopedFactory(
+	config *config.Config,
+) *RegistryScopedFactory {
+	return &RegistryScopedFactory{config}
+}
+
+func (p *RegistryScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &RegistryScopedMiddleware{next, p.config}
+}
+
+type RegistryScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (p *RegistryScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project to check scopes
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// get the registry id from the URL param context
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	registryID := reqScopes[types.RegistryScope].Resource.UInt
+
+	registry, err := p.config.Repo.Registry().ReadRegistry(proj.ID, registryID)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
+				fmt.Errorf("registry with id %d not found in project %d", registryID, proj.ID),
+			))
+		} else {
+			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		}
+
+		return
+	}
+
+	ctx := NewRegistryContext(r.Context(), registry)
+	r = r.Clone(ctx)
+	p.next.ServeHTTP(w, r)
+}
+
+func NewRegistryContext(ctx context.Context, registry *models.Registry) context.Context {
+	return context.WithValue(ctx, types.RegistryScope, registry)
+}

+ 66 - 0
api/server/authz/release.go

@@ -0,0 +1,66 @@
+package authz
+
+import (
+	"context"
+	"net/http"
+
+	"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"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+type ReleaseScopedFactory struct {
+	config *config.Config
+}
+
+func NewReleaseScopedFactory(
+	config *config.Config,
+) *ReleaseScopedFactory {
+	return &ReleaseScopedFactory{config}
+}
+
+func (p *ReleaseScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &ReleaseScopedMiddleware{next, p.config, NewOutOfClusterAgentGetter(p.config)}
+}
+
+type ReleaseScopedMiddleware struct {
+	next        http.Handler
+	config      *config.Config
+	agentGetter KubernetesAgentGetter
+}
+
+func (p *ReleaseScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	helmAgent, err := p.agentGetter.GetHelmAgent(r, cluster)
+
+	if err != nil {
+		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get the name of the application
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	name := reqScopes[types.ReleaseScope].Resource.Name
+
+	// get the version for the application
+	version, _ := requestutils.GetURLParamUint(r, types.URLParamReleaseVersion)
+
+	release, err := helmAgent.GetRelease(name, int(version), false)
+
+	if err != nil {
+		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	ctx := NewReleaseContext(r.Context(), release)
+	r = r.Clone(ctx)
+	p.next.ServeHTTP(w, r)
+}
+
+func NewReleaseContext(ctx context.Context, helmRelease *release.Release) context.Context {
+	return context.WithValue(ctx, types.ReleaseScope, helmRelease)
+}

+ 158 - 0
api/server/handlers/cluster/create.go

@@ -0,0 +1,158 @@
+package cluster
+
+import (
+	"encoding/base64"
+	"fmt"
+	"net/http"
+	"regexp"
+
+	"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/kubernetes/resolver"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type CreateClusterManualHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewCreateClusterManualHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateClusterManualHandler {
+	return &CreateClusterManualHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *CreateClusterManualHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project from context
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.CreateClusterManualRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	cluster, err := getClusterModelFromManualRequest(c.Repo(), proj, request)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	cluster, err = c.Repo().Cluster().CreateCluster(cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, cluster.ToClusterType())
+}
+
+func getClusterModelFromManualRequest(
+	repo repository.Repository,
+	project *models.Project,
+	request *types.CreateClusterManualRequest,
+) (*models.Cluster, error) {
+	var authMechanism models.ClusterAuth
+
+	if request.GCPIntegrationID != 0 {
+		authMechanism = models.GCP
+
+		// check that the integration exists
+		_, err := repo.GCPIntegration().ReadGCPIntegration(project.ID, request.GCPIntegrationID)
+
+		if err != nil {
+			return nil, fmt.Errorf("gcp integration not found")
+		}
+	} else if request.AWSIntegrationID != 0 {
+		authMechanism = models.AWS
+
+		// check that the integration exists
+		_, err := repo.AWSIntegration().ReadAWSIntegration(project.ID, request.AWSIntegrationID)
+
+		if err != nil {
+			return nil, fmt.Errorf("aws integration not found")
+		}
+	} else {
+		return nil, fmt.Errorf("must include aws or gcp integration id")
+	}
+
+	cert := make([]byte, 0)
+
+	if request.CertificateAuthorityData != "" {
+		// determine if data is base64 decoded using regex
+		re := regexp.MustCompile(`^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$`)
+
+		// if it matches the base64 regex, decode it
+		if re.MatchString(request.CertificateAuthorityData) {
+			decoded, err := base64.StdEncoding.DecodeString(request.CertificateAuthorityData)
+
+			if err != nil {
+				return nil, err
+			}
+
+			cert = []byte(decoded)
+		}
+	}
+
+	return &models.Cluster{
+		ProjectID:                project.ID,
+		AuthMechanism:            authMechanism,
+		Name:                     request.Name,
+		Server:                   request.Server,
+		GCPIntegrationID:         request.GCPIntegrationID,
+		AWSIntegrationID:         request.AWSIntegrationID,
+		CertificateAuthorityData: cert,
+	}, nil
+}
+
+func createClusterFromCandidate(
+	repo repository.Repository,
+	project *models.Project,
+	user *models.User,
+	candidate *models.ClusterCandidate,
+	clResolver *types.ClusterResolverAll,
+) (*models.Cluster, *models.ClusterCandidate, error) {
+	// we query the repo again to get the decrypted version of the cluster candidate
+	cc, err := repo.Cluster().ReadClusterCandidate(project.ID, candidate.ID)
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	cResolver := &resolver.CandidateResolver{
+		Resolver:           clResolver,
+		ClusterCandidateID: cc.ID,
+		ProjectID:          project.ID,
+		UserID:             user.ID,
+	}
+
+	err = cResolver.ResolveIntegration(repo)
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	cluster, err := cResolver.ResolveCluster(repo)
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	cc, err = repo.Cluster().UpdateClusterCandidateCreatedClusterID(cc.ID, cluster.ID)
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return cluster, cc, nil
+}

+ 113 - 0
api/server/handlers/cluster/create_candidate.go

@@ -0,0 +1,113 @@
+package cluster
+
+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/analytics"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type CreateClusterCandidateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewCreateClusterCandidateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateClusterCandidateHandler {
+	return &CreateClusterCandidateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *CreateClusterCandidateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project from context
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	request := &types.CreateClusterCandidateRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	ccs, err := getClusterCandidateModelsFromRequest(c.Repo(), proj, request, c.Config().ServerConf.IsLocal)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make(types.CreateClusterCandidateResponse, 0)
+
+	for _, cc := range ccs {
+		// handle write to the database
+		cc, err = c.Repo().Cluster().CreateClusterCandidate(cc)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		c.Config().AnalyticsClient.Track(analytics.ClusterConnectionStartTrack(
+			&analytics.ClusterConnectionStartTrackOpts{
+				ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, proj.ID),
+				ClusterCandidateID:     cc.ID,
+			},
+		))
+
+		// if the ClusterCandidate does not have any actions to perform, create the Cluster
+		// automatically
+		if len(cc.Resolvers) == 0 {
+			cluster, cc, err := createClusterFromCandidate(c.Repo(), proj, user, cc, &types.ClusterResolverAll{})
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			c.Config().AnalyticsClient.Track(analytics.ClusterConnectionSuccessTrack(
+				&analytics.ClusterConnectionSuccessTrackOpts{
+					ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(user.ID, proj.ID, cluster.ID),
+					ClusterCandidateID:     cc.ID,
+				},
+			))
+		}
+
+		res = append(res, cc.ToClusterCandidateType())
+	}
+
+	c.WriteResult(w, r, res)
+}
+
+func getClusterCandidateModelsFromRequest(
+	repo repository.Repository,
+	project *models.Project,
+	request *types.CreateClusterCandidateRequest,
+	isServerLocal bool,
+) ([]*models.ClusterCandidate, error) {
+	candidates, err := kubernetes.GetClusterCandidatesFromKubeconfig(
+		[]byte(request.Kubeconfig),
+		project.ID,
+		// can only use "local" auth mechanism if the server is running locally
+		isServerLocal && request.IsLocal,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	for _, cc := range candidates {
+		cc.ProjectID = project.ID
+	}
+
+	return candidates, nil
+}

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

@@ -0,0 +1,59 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CreateNamespaceHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateNamespaceHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateNamespaceHandler {
+	return &CreateNamespaceHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreateNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.CreateNamespaceRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	namespace, err := agent.CreateNamespace(request.Name)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := types.CreateNamespaceResponse{
+		Namespace: namespace,
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 41 - 0
api/server/handlers/cluster/delete.go

@@ -0,0 +1,41 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ClusterDeleteHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewClusterDeleteHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ClusterDeleteHandler {
+	return &ClusterDeleteHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ClusterDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	err := c.Repo().Cluster().DeleteCluster(cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, cluster.ToClusterType())
+}

+ 52 - 0
api/server/handlers/cluster/delete_namespace.go

@@ -0,0 +1,52 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type DeleteNamespaceHandler struct {
+	handlers.PorterHandlerReader
+	authz.KubernetesAgentGetter
+}
+
+func NewDeleteNamespaceHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) *DeleteNamespaceHandler {
+	return &DeleteNamespaceHandler{
+		PorterHandlerReader:   handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *DeleteNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.DeleteNamespaceRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if err := agent.DeleteNamespace(request.Name); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 48 - 0
api/server/handlers/cluster/detect_prometheus_installed.go

@@ -0,0 +1,48 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type DetectPrometheusInstalledHandler struct {
+	handlers.PorterHandler
+	authz.KubernetesAgentGetter
+}
+
+func NewDetectPrometheusInstalledHandler(
+	config *config.Config,
+) *DetectPrometheusInstalledHandler {
+	return &DetectPrometheusInstalledHandler{
+		PorterHandler:         handlers.NewDefaultPorterHandler(config, nil, nil),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *DetectPrometheusInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if _, found, err := prometheus.GetPrometheusService(agent.Clientset); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	} else if !found {
+		http.NotFound(w, r)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 57 - 0
api/server/handlers/cluster/get.go

@@ -0,0 +1,57 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/domain"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ClusterGetHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewClusterGetHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ClusterGetHandler {
+	return &ClusterGetHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ClusterGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	res := &types.ClusterGetResponse{
+		Cluster: cluster.ToClusterType(),
+	}
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	endpoint, found, ingressErr := domain.GetNGINXIngressServiceIP(agent.Clientset)
+
+	if found {
+		res.IngressIP = endpoint
+	}
+
+	if !found && ingressErr != nil {
+		res.IngressError = kubernetes.CatchK8sConnectionError(ingressErr).Externalize()
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 55 - 0
api/server/handlers/cluster/get_kubeconfig.go

@@ -0,0 +1,55 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"k8s.io/client-go/tools/clientcmd"
+)
+
+type GetTemporaryKubeconfigHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetTemporaryKubeconfigHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetTemporaryKubeconfigHandler {
+	return &GetTemporaryKubeconfigHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetTemporaryKubeconfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	outOfClusterConfig := c.GetOutOfClusterConfig(cluster)
+
+	kubeconfig, err := outOfClusterConfig.CreateRawConfigFromCluster()
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	kubeconfigBytes, err := clientcmd.Write(*kubeconfig)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := &types.GetTemporaryKubeconfigResponse{
+		Kubeconfig: kubeconfigBytes,
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 46 - 0
api/server/handlers/cluster/get_node.go

@@ -0,0 +1,46 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/nodes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetNodeHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetNodeHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetNodeHandler {
+	return &GetNodeHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetNodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamNodeName)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := nodes.DescribeNode(agent.Clientset, name)
+
+	c.WriteResult(w, r, res)
+}

+ 65 - 0
api/server/handlers/cluster/get_pod_metrics.go

@@ -0,0 +1,65 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetPodMetricsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetPodMetricsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetPodMetricsHandler {
+	return &GetPodMetricsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetPodMetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetPodMetricsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get prometheus service
+	promSvc, found, err := prometheus.GetPrometheusService(agent.Clientset)
+
+	if err != nil || !found {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	rawQuery, err := prometheus.QueryPrometheus(agent.Clientset, promSvc, &request.QueryOpts)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, rawQuery)
+}

+ 63 - 0
api/server/handlers/cluster/get_pods.go

@@ -0,0 +1,63 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	v1 "k8s.io/api/core/v1"
+)
+
+type GetPodsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetPodsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetPodsHandler {
+	return &GetPodsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetPodsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	pods := []v1.Pod{}
+	for _, selector := range request.Selectors {
+		podsList, err := agent.GetPodsByLabel(selector, request.Namespace)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		for _, pod := range podsList.Items {
+			pods = append(pods, pod)
+		}
+	}
+
+	c.WriteResult(w, r, pods)
+}

+ 46 - 0
api/server/handlers/cluster/list.go

@@ -0,0 +1,46 @@
+package cluster
+
+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 ClusterListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewClusterListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ClusterListHandler {
+	return &ClusterListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *ClusterListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project from context
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// read all clusters for this project
+	clusters, err := p.Repo().Cluster().ListClustersByProjectID(proj.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make(types.ListClusterResponse, len(clusters))
+
+	for i, cluster := range clusters {
+		res[i] = cluster.ToClusterType()
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 44 - 0
api/server/handlers/cluster/list_candidates.go

@@ -0,0 +1,44 @@
+package cluster
+
+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 ListClusterCandidatesHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewListClusterCandidatesHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListClusterCandidatesHandler {
+	return &ListClusterCandidatesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *ListClusterCandidatesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	ccs, err := c.Repo().Cluster().ListClusterCandidatesByProjectID(proj.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make(types.ListClusterCandidateResponse, 0)
+
+	for _, cc := range ccs {
+		res = append(res, cc.ToClusterCandidateType())
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 52 - 0
api/server/handlers/cluster/list_namespaces.go

@@ -0,0 +1,52 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListNamespacesHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewListNamespacesHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListNamespacesHandler {
+	return &ListNamespacesHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ListNamespacesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	namespaceList, err := agent.ListNamespaces()
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := types.ListNamespacesResponse{
+		NamespaceList: namespaceList,
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 51 - 0
api/server/handlers/cluster/list_nginx_ingresses.go

@@ -0,0 +1,51 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListNGINXIngressesHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewListNGINXIngressesHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListNGINXIngressesHandler {
+	return &ListNGINXIngressesHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ListNGINXIngressesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	ingresses, err := prometheus.GetIngressesWithNGINXAnnotation(agent.Clientset)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.ListNGINXIngressesResponse = ingresses
+
+	c.WriteResult(w, r, res)
+}

+ 44 - 0
api/server/handlers/cluster/list_nodes.go

@@ -0,0 +1,44 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/nodes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListNodesHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewListNodesHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListNodesHandler {
+	return &ListNodesHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ListNodesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := nodes.GetNodesUsage(agent.Clientset)
+
+	c.WriteResult(w, r, res)
+}

+ 64 - 0
api/server/handlers/cluster/resolve_candidate.go

@@ -0,0 +1,64 @@
+package cluster
+
+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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/analytics"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ResolveClusterCandidateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewResolveClusterCandidateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ResolveClusterCandidateHandler {
+	return &ResolveClusterCandidateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *ResolveClusterCandidateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project from context
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	ccID, _ := requestutils.GetURLParamUint(r, types.URLParamCandidateID)
+
+	request := &types.ClusterResolverAll{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	cc, err := c.Repo().Cluster().ReadClusterCandidate(proj.ID, ccID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	cluster, cc, err := createClusterFromCandidate(c.Repo(), proj, user, cc, request)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.Config().AnalyticsClient.Track(analytics.ClusterConnectionSuccessTrack(
+		&analytics.ClusterConnectionSuccessTrackOpts{
+			ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(user.ID, proj.ID, cluster.ID),
+			ClusterCandidateID:     cc.ID,
+		},
+	))
+
+	c.WriteResult(w, r, cluster.ToClusterType())
+}

+ 60 - 0
api/server/handlers/cluster/stream_helm_release.go

@@ -0,0 +1,60 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StreamHelmReleaseHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStreamHelmReleaseHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StreamHelmReleaseHandler {
+	return &StreamHelmReleaseHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *StreamHelmReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	conn, err := c.Config().WSUpgrader.Upgrade(w, r, nil)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	request := &types.StreamHelmReleaseRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = agent.StreamHelmReleases(conn, request.Namespace, request.Charts, request.Selectors)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 63 - 0
api/server/handlers/cluster/stream_status.go

@@ -0,0 +1,63 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StreamStatusHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStreamStatusHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StreamStatusHandler {
+	return &StreamStatusHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *StreamStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	conn, err := c.Config().WSUpgrader.Upgrade(w, r, nil)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	request := &types.StreamStatusRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	kind, _ := requestutils.GetURLParamString(r, types.URLParamKind)
+
+	err = agent.StreamControllerStatus(conn, kind, request.Selectors)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 50 - 0
api/server/handlers/cluster/update.go

@@ -0,0 +1,50 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ClusterUpdateHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewClusterUpdateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ClusterUpdateHandler {
+	return &ClusterUpdateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ClusterUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.UpdateClusterRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	cluster.Name = request.Name
+
+	cluster, err := c.Repo().Cluster().UpdateCluster(cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, cluster.ToClusterType())
+}

+ 30 - 0
api/server/handlers/gitinstallation/get.go

@@ -0,0 +1,30 @@
+package gitinstallation
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type GitInstallationGetHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewGitInstallationGetHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GitInstallationGetHandler {
+	return &GitInstallationGetHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *GitInstallationGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ga, _ := r.Context().Value(types.GitInstallationScope).(*integrations.GithubAppInstallation)
+
+	c.WriteResult(w, r, ga.ToGitInstallationType())
+}

+ 88 - 0
api/server/handlers/gitinstallation/get_accounts.go

@@ -0,0 +1,88 @@
+package gitinstallation
+
+import (
+	"context"
+	"net/http"
+	"sort"
+
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+)
+
+type GetGithubAppAccountsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetGithubAppAccountsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetGithubAppAccountsHandler {
+	return &GetGithubAppAccountsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GetGithubAppAccountsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	tok, err := GetGithubAppOauthTokenFromRequest(c.Config(), r)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	client := github.NewClient(c.Config().GithubAppConf.Client(oauth2.NoContext, tok))
+	res := &types.GetGithubAppAccountsResponse{}
+
+	for {
+		orgs, pages, err := client.Organizations.List(context.Background(), "", &github.ListOptions{
+			PerPage: 100,
+			Page:    1,
+		})
+
+		if err != nil {
+			continue
+		}
+
+		for _, org := range orgs {
+			res.Accounts = append(res.Accounts, *org.Login)
+		}
+
+		if pages.NextPage == 0 {
+			break
+		}
+	}
+
+	authUser, _, err := client.Users.Get(context.Background(), "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res.Username = *authUser.Login
+
+	// check if user has app installed in their account
+	installation, err := c.Repo().GithubAppInstallation().ReadGithubAppInstallationByAccountID(*authUser.ID)
+
+	if err != nil && err != gorm.ErrRecordNotFound {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if installation != nil {
+		res.Accounts = append(res.Accounts, *authUser.Login)
+	}
+
+	sort.Strings(res.Accounts)
+
+	c.WriteResult(w, r, res)
+}

+ 104 - 0
api/server/handlers/gitinstallation/get_buildpack.go

@@ -0,0 +1,104 @@
+package gitinstallation
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type GithubGetBuildpackHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGithubGetBuildpackHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GithubGetBuildpackHandler {
+	return &GithubGetBuildpackHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GithubGetBuildpackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetBuildpackRequest{}
+
+	ok := c.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	owner, name, ok := GetOwnerAndNameParams(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	branch, ok := GetBranch(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	client, err := GetGithubAppClientFromRequest(c.Config(), r)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	repoContentOptions := github.RepositoryContentGetOptions{}
+	repoContentOptions.Ref = branch
+	_, directoryContents, _, err := client.Repositories.GetContents(
+		context.Background(),
+		owner,
+		name,
+		request.Dir,
+		&repoContentOptions,
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var BREQS = map[string]string{
+		"requirements.txt": "Python",
+		"Gemfile":          "Ruby",
+		"package.json":     "Node.js",
+		"pom.xml":          "Java",
+		"composer.json":    "PHP",
+	}
+
+	res := &types.GetBuildpackResponse{
+		Valid: true,
+	}
+
+	matches := 0
+
+	for i := range directoryContents {
+		name := *directoryContents[i].Name
+
+		bname, ok := BREQS[name]
+		if ok {
+			matches++
+			res.Name = bname
+		}
+	}
+
+	if matches != 1 {
+		res.Valid = false
+		res.Name = ""
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 84 - 0
api/server/handlers/gitinstallation/get_contents.go

@@ -0,0 +1,84 @@
+package gitinstallation
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type GithubGetContentsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGithubGetContentsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GithubGetContentsHandler {
+	return &GithubGetContentsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GithubGetContentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetContentsRequest{}
+
+	ok := c.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	owner, name, ok := GetOwnerAndNameParams(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	branch, ok := GetBranch(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	client, err := GetGithubAppClientFromRequest(c.Config(), r)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	repoContentOptions := github.RepositoryContentGetOptions{}
+	repoContentOptions.Ref = branch
+	_, directoryContents, _, err := client.Repositories.GetContents(
+		context.Background(),
+		owner,
+		name,
+		request.Dir,
+		&repoContentOptions,
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make(types.GetContentsResponse, 0)
+
+	for i := range directoryContents {
+		res = append(res, types.GithubDirectoryItem{
+			Path: *directoryContents[i].Path,
+			Type: *directoryContents[i].Type,
+		})
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 95 - 0
api/server/handlers/gitinstallation/get_procfile.go

@@ -0,0 +1,95 @@
+package gitinstallation
+
+import (
+	"context"
+	"net/http"
+	"regexp"
+	"strings"
+
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+var procfileRegex = regexp.MustCompile("^([A-Za-z0-9_]+):\\s*(.+)$")
+
+type GithubGetProcfileHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGithubGetProcfileHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GithubGetProcfileHandler {
+	return &GithubGetProcfileHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GithubGetProcfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetProcfileRequest{}
+
+	ok := c.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	owner, name, ok := GetOwnerAndNameParams(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	branch, ok := GetBranch(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	client, err := GetGithubAppClientFromRequest(c.Config(), r)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	resp, _, _, err := client.Repositories.GetContents(
+		context.TODO(),
+		owner,
+		name,
+		request.Path,
+		&github.RepositoryContentGetOptions{
+			Ref: branch,
+		},
+	)
+
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+
+	fileData, err := resp.GetContent()
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	parsedContents := make(types.GetProcfileResponse)
+
+	// parse the procfile information
+	for _, line := range strings.Split(fileData, "\n") {
+		if matches := procfileRegex.FindStringSubmatch(line); matches != nil {
+			parsedContents[matches[1]] = matches[2]
+		}
+	}
+
+	c.WriteResult(w, r, parsedContents)
+}

+ 84 - 0
api/server/handlers/gitinstallation/get_tarball_url.go

@@ -0,0 +1,84 @@
+package gitinstallation
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type GithubGetTarballURLHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGithubGetTarballURLHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GithubGetTarballURLHandler {
+	return &GithubGetTarballURLHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GithubGetTarballURLHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	owner, name, ok := GetOwnerAndNameParams(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	branch, ok := GetBranch(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	client, err := GetGithubAppClientFromRequest(c.Config(), r)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	branchResp, _, err := client.Repositories.GetBranch(
+		context.TODO(),
+		owner,
+		name,
+		branch,
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	ghURL, _, err := client.Repositories.GetArchiveLink(
+		context.TODO(),
+		owner,
+		name,
+		github.Zipball,
+		&github.RepositoryContentGetOptions{
+			Ref: *branchResp.Commit.SHA,
+		},
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	apiResp := &types.GetTarballURLResponse{
+		URLString:       ghURL.String(),
+		LatestCommitSHA: *branchResp.Commit.SHA,
+	}
+
+	c.WriteResult(w, r, apiResp)
+}

+ 114 - 0
api/server/handlers/gitinstallation/helpers.go

@@ -0,0 +1,114 @@
+package gitinstallation
+
+import (
+	"net/http"
+	"net/url"
+
+	"github.com/bradleyfalzon/ghinstallation"
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"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"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+)
+
+// GetGithubAppOauthTokenFromRequest gets the GH oauth token from the request based on the currently
+// logged in user
+func GetGithubAppOauthTokenFromRequest(config *config.Config, r *http.Request) (*oauth2.Token, error) {
+	// read the user from context
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	getOAuthInt := config.Repo.GithubAppOAuthIntegration().ReadGithubAppOauthIntegration
+	oauthInt, err := getOAuthInt(user.GithubAppIntegrationID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	_, _, err = oauth.GetAccessToken(oauthInt.SharedOAuthModel,
+		&config.GithubAppConf.Config,
+		oauth.MakeUpdateGithubAppOauthIntegrationFunction(oauthInt, config.Repo),
+	)
+
+	if err != nil {
+		// try again, in case the token got updated
+		oauthInt2, err := getOAuthInt(user.GithubAppIntegrationID)
+
+		if err != nil || oauthInt2.Expiry == oauthInt.Expiry {
+			return nil, err
+		}
+		oauthInt.AccessToken = oauthInt2.AccessToken
+		oauthInt.RefreshToken = oauthInt2.RefreshToken
+		oauthInt.Expiry = oauthInt2.Expiry
+	}
+
+	return &oauth2.Token{
+		AccessToken:  string(oauthInt.AccessToken),
+		RefreshToken: string(oauthInt.RefreshToken),
+		Expiry:       oauthInt.Expiry,
+		TokenType:    "Bearer",
+	}, nil
+}
+
+// GetGithubAppClientFromRequest gets the github app installation id from the request and authenticates
+// using it and a private key file
+func GetGithubAppClientFromRequest(config *config.Config, r *http.Request) (*github.Client, error) {
+	// get installation id from context
+	ga, _ := r.Context().Value(types.GitInstallationScope).(*integrations.GithubAppInstallation)
+
+	itr, err := ghinstallation.NewKeyFromFile(
+		http.DefaultTransport,
+		config.GithubAppConf.AppID,
+		ga.InstallationID,
+		config.GithubAppConf.SecretPath,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return github.NewClient(&http.Client{Transport: itr}), nil
+}
+
+// GetOwnerAndNameParams gets the owner and name ref for the Github repo
+func GetOwnerAndNameParams(c handlers.PorterHandler, w http.ResponseWriter, r *http.Request) (string, string, bool) {
+	owner, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoOwner)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return "", "", false
+	}
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoName)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return "", "", false
+	}
+
+	return owner, name, true
+}
+
+// GetBranch gets the unencoded branch
+func GetBranch(c handlers.PorterHandler, w http.ResponseWriter, r *http.Request) (string, bool) {
+	branch, reqErr := requestutils.GetURLParamString(r, types.URLParamGitBranch)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return "", false
+	}
+
+	branch, err := url.QueryUnescape(branch)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return "", false
+	}
+
+	return branch, true
+}

+ 25 - 0
api/server/handlers/gitinstallation/install.go

@@ -0,0 +1,25 @@
+package gitinstallation
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+type GithubAppInstallHandler struct {
+	handlers.PorterHandler
+}
+
+func NewGithubAppInstallHandler(
+	config *config.Config,
+) *GithubAppInstallHandler {
+	return &GithubAppInstallHandler{
+		PorterHandler: handlers.NewDefaultPorterHandler(config, nil, nil),
+	}
+}
+
+func (c *GithubAppInstallHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	http.Redirect(w, r, fmt.Sprintf("https://github.com/apps/%s/installations/new", c.Config().GithubAppConf.AppName), 302)
+}

+ 96 - 0
api/server/handlers/gitinstallation/list.go

@@ -0,0 +1,96 @@
+package gitinstallation
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+)
+
+type GitRepoListHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGitRepoListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GitRepoListHandler {
+	return &GitRepoListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *GitRepoListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	res := types.ListGitInstallationIDsResponse{}
+	tok, err := GetGithubAppOauthTokenFromRequest(c.Config(), r)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			// return empty array, this is not an error
+			c.WriteResult(w, r, res)
+		} else {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		}
+
+		return
+	}
+
+	client := github.NewClient(c.Config().GithubAppConf.Client(oauth2.NoContext, tok))
+
+	accountIds := make([]int64, 0)
+
+	ghAuthUser, _, err := client.Users.Get(context.Background(), "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	accountIds = append(accountIds, *ghAuthUser.ID)
+
+	opts := &github.ListOptions{
+		PerPage: 100,
+		Page:    1,
+	}
+
+	for {
+		orgs, pages, err := client.Organizations.List(context.Background(), "", opts)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		for _, org := range orgs {
+			accountIds = append(accountIds, *org.ID)
+		}
+
+		if pages.NextPage == 0 {
+			break
+		}
+	}
+
+	installationData, err := c.Repo().GithubAppInstallation().ReadGithubAppInstallationByAccountIDs(accountIds)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	installationIds := types.ListGitInstallationIDsResponse{}
+
+	for _, v := range installationData {
+		installationIds = append(installationIds, v.InstallationID)
+	}
+
+	c.WriteResult(w, r, installationIds)
+}

+ 115 - 0
api/server/handlers/gitinstallation/list_branches.go

@@ -0,0 +1,115 @@
+package gitinstallation
+
+import (
+	"context"
+	"net/http"
+	"sync"
+
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type GithubListBranchesHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGithubListBranchesHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GithubListBranchesHandler {
+	return &GithubListBranchesHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *GithubListBranchesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	owner, name, ok := GetOwnerAndNameParams(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	client, err := GetGithubAppClientFromRequest(c.Config(), r)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// List all branches for a specified repo
+	allBranches, resp, err := client.Repositories.ListBranches(context.Background(), owner, name, &github.ListOptions{
+		PerPage: 100,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// make workers to get branches concurrently
+	const WCOUNT = 5
+	numPages := resp.LastPage + 1
+	var workerErr error
+	var mu sync.Mutex
+	var wg sync.WaitGroup
+
+	worker := func(cp int) {
+		defer wg.Done()
+
+		for cp < numPages {
+			opts := &github.ListOptions{
+				Page:    cp,
+				PerPage: 100,
+			}
+
+			branches, _, err := client.Repositories.ListBranches(context.Background(), owner, name, opts)
+
+			if err != nil {
+				mu.Lock()
+				workerErr = err
+				mu.Unlock()
+				return
+			}
+
+			mu.Lock()
+			allBranches = append(allBranches, branches...)
+			mu.Unlock()
+
+			cp += WCOUNT
+		}
+	}
+
+	var numJobs int
+	if numPages > WCOUNT {
+		numJobs = WCOUNT
+	} else {
+		numJobs = numPages
+	}
+
+	wg.Add(numJobs)
+
+	// page 1 is already loaded so we start with 2
+	for i := 1; i <= numJobs; i++ {
+		go worker(i + 1)
+	}
+
+	wg.Wait()
+
+	if workerErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make(types.ListRepoBranchesResponse, 0)
+	for _, b := range allBranches {
+		res = append(res, b.GetName())
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 115 - 0
api/server/handlers/gitinstallation/list_repos.go

@@ -0,0 +1,115 @@
+package gitinstallation
+
+import (
+	"context"
+	"net/http"
+	"sync"
+
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type GithubListReposHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGithubListReposHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GithubListReposHandler {
+	return &GithubListReposHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *GithubListReposHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	client, err := GetGithubAppClientFromRequest(c.Config(), r)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// figure out number of repositories
+	opt := &github.ListOptions{
+		PerPage: 100,
+	}
+
+	allRepos, resp, err := client.Apps.ListRepos(context.Background(), opt)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// make workers to get pages concurrently
+	const WCOUNT = 5
+	numPages := resp.LastPage + 1
+	var workerErr error
+	var mu sync.Mutex
+	var wg sync.WaitGroup
+
+	worker := func(cp int) {
+		defer wg.Done()
+
+		for cp < numPages {
+			cur_opt := &github.ListOptions{
+				Page:    cp,
+				PerPage: 100,
+			}
+
+			repos, _, err := client.Apps.ListRepos(context.Background(), cur_opt)
+
+			if err != nil {
+				mu.Lock()
+				workerErr = err
+				mu.Unlock()
+				return
+			}
+
+			mu.Lock()
+			allRepos = append(allRepos, repos...)
+			mu.Unlock()
+
+			cp += WCOUNT
+		}
+	}
+
+	var numJobs int
+	if numPages > WCOUNT {
+		numJobs = WCOUNT
+	} else {
+		numJobs = numPages
+	}
+
+	wg.Add(numJobs)
+
+	// page 1 is already loaded so we start with 2
+	for i := 1; i <= numJobs; i++ {
+		go worker(i + 1)
+	}
+
+	wg.Wait()
+
+	if workerErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make(types.ListReposResponse, 0)
+
+	for _, repo := range allRepos {
+		res = append(res, types.Repo{
+			FullName: repo.GetFullName(),
+			Kind:     "github",
+		})
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 90 - 0
api/server/handlers/gitinstallation/oauth_callback.go

@@ -0,0 +1,90 @@
+package gitinstallation
+
+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/analytics"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"golang.org/x/oauth2"
+)
+
+type GithubAppOAuthCallbackHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewGithubAppOAuthCallbackHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GithubAppOAuthCallbackHandler {
+	return &GithubAppOAuthCallbackHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GithubAppOAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	session, err := c.Config().Store.Get(r, c.Config().ServerConf.CookieName)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	token, err := c.Config().GithubAppConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil || !token.Valid() {
+		if session.Values["query_params"] != "" {
+			http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+		} else {
+			http.Redirect(w, r, "/dashboard", 302)
+		}
+
+		return
+	}
+
+	oauthInt := &integrations.GithubAppOAuthIntegration{
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(token.AccessToken),
+			RefreshToken: []byte(token.RefreshToken),
+			Expiry:       token.Expiry,
+		},
+		UserID: user.ID,
+	}
+
+	oauthInt, err = c.Repo().GithubAppOAuthIntegration().CreateGithubAppOAuthIntegration(oauthInt)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	user.GithubAppIntegrationID = oauthInt.ID
+
+	user, err = c.Repo().User().UpdateUser(user)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.Config().AnalyticsClient.Track(analytics.GithubConnectionSuccessTrack(
+		&analytics.GithubConnectionSuccessTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		},
+	))
+
+	if session.Values["query_params"] != "" {
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	} else {
+		http.Redirect(w, r, "/dashboard", 302)
+	}
+}

+ 36 - 0
api/server/handlers/gitinstallation/oauth_start.go

@@ -0,0 +1,36 @@
+package gitinstallation
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/analytics"
+	"github.com/porter-dev/porter/internal/models"
+	"golang.org/x/oauth2"
+)
+
+type GithubAppOAuthStartHandler struct {
+	handlers.PorterHandler
+}
+
+func NewGithubAppOAuthStartHandler(
+	config *config.Config,
+) *GithubAppOAuthStartHandler {
+	return &GithubAppOAuthStartHandler{
+		PorterHandler: handlers.NewDefaultPorterHandler(config, nil, nil),
+	}
+}
+
+func (c *GithubAppOAuthStartHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	c.Config().AnalyticsClient.Track(analytics.GithubConnectionStartTrack(
+		&analytics.GithubConnectionStartTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		},
+	))
+
+	http.Redirect(w, r, c.Config().GithubAppConf.AuthCodeURL("", oauth2.AccessTypeOffline), 302)
+}

+ 115 - 0
api/server/handlers/gitinstallation/webhook.go

@@ -0,0 +1,115 @@
+package gitinstallation
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"io/ioutil"
+	"net/http"
+	"strings"
+
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"gorm.io/gorm"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type GithubAppWebhookHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGithubAppWebhookHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GithubAppWebhookHandler {
+	return &GithubAppWebhookHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GithubAppWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	payload, err := ioutil.ReadAll(r.Body)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// verify webhook secret
+	signature := r.Header.Get("X-Hub-Signature-256")
+
+	if !verifySignature([]byte(c.Config().GithubAppConf.WebhookSecret), signature, payload) {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	event, err := github.ParseWebHook(github.WebHookType(r), payload)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	switch e := event.(type) {
+	case *github.InstallationEvent:
+		if *e.Action == "created" {
+			_, err := c.Repo().GithubAppInstallation().ReadGithubAppInstallationByAccountID(*e.Installation.Account.ID)
+
+			if err != nil && err == gorm.ErrRecordNotFound {
+				// insert account/installation pair into database
+				_, err := c.Repo().GithubAppInstallation().CreateGithubAppInstallation(&ints.GithubAppInstallation{
+					AccountID:      *e.Installation.Account.ID,
+					InstallationID: *e.Installation.ID,
+				})
+
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				}
+
+				return
+			} else if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		}
+		if *e.Action == "deleted" {
+			err := c.Repo().GithubAppInstallation().DeleteGithubAppInstallationByAccountID(*e.Installation.Account.ID)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		}
+	}
+}
+
+// verifySignature verifies a signature based on hmac protocal
+// https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks
+func verifySignature(secret []byte, signature string, body []byte) bool {
+	if len(signature) != 71 || !strings.HasPrefix(signature, "sha256=") {
+		return false
+	}
+
+	actual := make([]byte, 32)
+	_, err := hex.Decode(actual, []byte(signature[7:]))
+
+	if err != nil {
+		return false
+	}
+
+	computed := hmac.New(sha256.New, secret)
+	_, err = computed.Write(body)
+
+	if err != nil {
+		return false
+	}
+
+	return hmac.Equal(computed.Sum(nil), actual)
+}

+ 105 - 0
api/server/handlers/handler.go

@@ -0,0 +1,105 @@
+package handlers
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type PorterHandler interface {
+	Config() *config.Config
+	Repo() repository.Repository
+	HandleAPIError(w http.ResponseWriter, r *http.Request, err apierrors.RequestError)
+	PopulateOAuthSession(w http.ResponseWriter, r *http.Request, state string, isProject bool) error
+}
+
+type PorterHandlerWriter interface {
+	PorterHandler
+	shared.ResultWriter
+}
+
+type PorterHandlerReader interface {
+	PorterHandler
+	shared.RequestDecoderValidator
+}
+
+type PorterHandlerReadWriter interface {
+	PorterHandlerWriter
+	PorterHandlerReader
+}
+
+type DefaultPorterHandler struct {
+	config           *config.Config
+	decoderValidator shared.RequestDecoderValidator
+	writer           shared.ResultWriter
+}
+
+func NewDefaultPorterHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) PorterHandlerReadWriter {
+	return &DefaultPorterHandler{config, decoderValidator, writer}
+}
+
+func (d *DefaultPorterHandler) Config() *config.Config {
+	return d.config
+}
+
+func (d *DefaultPorterHandler) Repo() repository.Repository {
+	return d.config.Repo
+}
+
+func (d *DefaultPorterHandler) HandleAPIError(w http.ResponseWriter, r *http.Request, err apierrors.RequestError) {
+	apierrors.HandleAPIError(d.Config(), w, r, err)
+}
+
+func (d *DefaultPorterHandler) WriteResult(w http.ResponseWriter, r *http.Request, v interface{}) {
+	d.writer.WriteResult(w, r, v)
+}
+
+func (d *DefaultPorterHandler) DecodeAndValidate(w http.ResponseWriter, r *http.Request, v interface{}) bool {
+	return d.decoderValidator.DecodeAndValidate(w, r, v)
+}
+
+func (d *DefaultPorterHandler) DecodeAndValidateNoWrite(r *http.Request, v interface{}) error {
+	return d.decoderValidator.DecodeAndValidateNoWrite(r, v)
+}
+
+func IgnoreAPIError(w http.ResponseWriter, r *http.Request, err apierrors.RequestError) {
+	return
+}
+
+func (d *DefaultPorterHandler) PopulateOAuthSession(w http.ResponseWriter, r *http.Request, state string, isProject bool) error {
+	session, err := d.Config().Store.Get(r, d.Config().ServerConf.CookieName)
+
+	if err != nil {
+		return err
+	}
+
+	// need state parameter to validate when redirected
+	session.Values["state"] = state
+	session.Values["query_params"] = r.URL.RawQuery
+
+	if isProject {
+		project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+		if project == nil {
+			return fmt.Errorf("could not read project")
+		}
+
+		session.Values["project_id"] = project.ID
+	}
+
+	if err := session.Save(r, w); err != nil {
+		return err
+	}
+
+	return nil
+}

+ 26 - 0
api/server/handlers/healthcheck/livez.go

@@ -0,0 +1,26 @@
+package healthcheck
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+type LivezHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewLivezHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *LivezHandler {
+	return &LivezHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (v *LivezHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	writeHealthy(w)
+}

+ 45 - 0
api/server/handlers/healthcheck/readyz.go

@@ -0,0 +1,45 @@
+package healthcheck
+
+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"
+)
+
+type ReadyzHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewReadyzHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ReadyzHandler {
+	return &ReadyzHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (v *ReadyzHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	db, err := v.Config().DB.DB()
+
+	if err != nil {
+		v.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if err := db.Ping(); err != nil {
+		v.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	writeHealthy(w)
+}
+
+func writeHealthy(w http.ResponseWriter) {
+	w.Header().Set("Content-Type", "text/plain")
+	w.WriteHeader(http.StatusOK)
+	w.Write([]byte("."))
+}

+ 30 - 0
api/server/handlers/helmrepo/get.go

@@ -0,0 +1,30 @@
+package helmrepo
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type HelmRepoGetHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewHelmRepoGetHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *HelmRepoGetHandler {
+	return &HelmRepoGetHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *HelmRepoGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	helmRepo, _ := r.Context().Value(types.HelmRepoScope).(*models.HelmRepo)
+
+	c.WriteResult(w, r, helmRepo.ToHelmRepoType())
+}

+ 195 - 0
api/server/handlers/infra/delete.go

@@ -0,0 +1,195 @@
+package infra
+
+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/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type InfraDeleteHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewInfraDeleteHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *InfraDeleteHandler {
+	return &InfraDeleteHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *InfraDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+
+	request := &types.DeleteInfraRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	infra.Status = types.StatusDestroying
+	infra, err := c.Repo().Infra().UpdateInfra(infra)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	switch infra.Kind {
+	case types.InfraECR:
+		err = destroyECR(c.Repo(), c.Config(), infra, request.Name)
+	case types.InfraEKS:
+		err = destroyEKS(c.Repo(), c.Config(), infra, request.Name)
+	case types.InfraDOCR:
+		err = destroyDOCR(c.Repo(), c.Config(), infra, request.Name)
+	case types.InfraDOKS:
+		err = destroyDOKS(c.Repo(), c.Config(), infra, request.Name)
+	case types.InfraGKE:
+		err = destroyGKE(c.Repo(), c.Config(), infra, request.Name)
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}
+
+func destroyECR(repo repository.Repository, conf *config.Config, infra *models.Infra, name string) error {
+	awsInt, err := repo.AWSIntegration().ReadAWSIntegration(infra.ProjectID, infra.AWSIntegrationID)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = conf.ProvisionerAgent.ProvisionECR(
+		&kubernetes.SharedProvisionOpts{
+			ProjectID:           infra.ProjectID,
+			Repo:                repo,
+			Infra:               infra,
+			Operation:           provisioner.Destroy,
+			PGConf:              conf.DBConf,
+			RedisConf:           conf.RedisConf,
+			ProvImageTag:        conf.ServerConf.ProvisionerImageTag,
+			ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
+		},
+		awsInt,
+		name,
+	)
+
+	return err
+}
+
+func destroyEKS(repo repository.Repository, conf *config.Config, infra *models.Infra, name string) error {
+	awsInt, err := repo.AWSIntegration().ReadAWSIntegration(infra.ProjectID, infra.AWSIntegrationID)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = conf.ProvisionerAgent.ProvisionEKS(
+		&kubernetes.SharedProvisionOpts{
+			ProjectID:           infra.ProjectID,
+			Repo:                repo,
+			Infra:               infra,
+			Operation:           provisioner.Destroy,
+			PGConf:              conf.DBConf,
+			RedisConf:           conf.RedisConf,
+			ProvImageTag:        conf.ServerConf.ProvisionerImageTag,
+			ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
+		},
+		awsInt,
+		name,
+		"",
+	)
+
+	return err
+}
+
+func destroyDOCR(repo repository.Repository, conf *config.Config, infra *models.Infra, name string) error {
+	doInt, err := repo.OAuthIntegration().ReadOAuthIntegration(infra.ProjectID, infra.DOIntegrationID)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = conf.ProvisionerAgent.ProvisionDOCR(
+		&kubernetes.SharedProvisionOpts{
+			ProjectID:           infra.ProjectID,
+			Repo:                repo,
+			Infra:               infra,
+			Operation:           provisioner.Destroy,
+			PGConf:              conf.DBConf,
+			RedisConf:           conf.RedisConf,
+			ProvImageTag:        conf.ServerConf.ProvisionerImageTag,
+			ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
+		},
+		doInt,
+		conf.DOConf,
+		name,
+		"",
+	)
+
+	return err
+}
+
+func destroyDOKS(repo repository.Repository, conf *config.Config, infra *models.Infra, name string) error {
+	doInt, err := repo.OAuthIntegration().ReadOAuthIntegration(infra.ProjectID, infra.DOIntegrationID)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = conf.ProvisionerAgent.ProvisionDOKS(
+		&kubernetes.SharedProvisionOpts{
+			ProjectID:           infra.ProjectID,
+			Repo:                repo,
+			Infra:               infra,
+			Operation:           provisioner.Destroy,
+			PGConf:              conf.DBConf,
+			RedisConf:           conf.RedisConf,
+			ProvImageTag:        conf.ServerConf.ProvisionerImageTag,
+			ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
+		},
+		doInt,
+		conf.DOConf,
+		"",
+		name,
+	)
+
+	return err
+}
+
+func destroyGKE(repo repository.Repository, conf *config.Config, infra *models.Infra, name string) error {
+	gcpInt, err := repo.GCPIntegration().ReadGCPIntegration(infra.ProjectID, infra.GCPIntegrationID)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = conf.ProvisionerAgent.ProvisionGKE(
+		&kubernetes.SharedProvisionOpts{
+			ProjectID:           infra.ProjectID,
+			Repo:                repo,
+			Infra:               infra,
+			Operation:           provisioner.Destroy,
+			PGConf:              conf.DBConf,
+			RedisConf:           conf.RedisConf,
+			ProvImageTag:        conf.ServerConf.ProvisionerImageTag,
+			ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
+		},
+		gcpInt,
+		name,
+	)
+
+	return err
+}

+ 30 - 0
api/server/handlers/infra/get.go

@@ -0,0 +1,30 @@
+package infra
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type InfraGetHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInfraGetHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InfraGetHandler {
+	return &InfraGetHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InfraGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+
+	c.WriteResult(w, r, infra.ToInfraType())
+}

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

@@ -0,0 +1,45 @@
+package infra
+
+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 InfraListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInfraListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InfraListHandler {
+	return &InfraListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *InfraListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	infras, err := p.Repo().Infra().ListInfrasByProjectID(proj.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	infraList := make([]*types.Infra, 0)
+
+	for _, infra := range infras {
+		infraList = append(infraList, infra.ToInfraType())
+	}
+
+	var res types.ListProjectInfraResponse = infraList
+
+	p.WriteResult(w, r, res)
+}

+ 52 - 0
api/server/handlers/infra/stream_logs.go

@@ -0,0 +1,52 @@
+package infra
+
+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/adapter"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type InfraStreamLogsHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInfraStreamLogsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InfraStreamLogsHandler {
+	return &InfraStreamLogsHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InfraStreamLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	conn, err := c.Config().WSUpgrader.Upgrade(w, r, nil)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+
+	client, err := adapter.NewRedisClient(c.Config().RedisConf)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = provisioner.ResourceStream(client, infra.GetUniqueName(), conn)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 117 - 0
api/server/handlers/invite/accept.go

@@ -0,0 +1,117 @@
+package invite
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+
+	"github.com/go-chi/chi"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"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"
+)
+
+type InviteAcceptHandler struct {
+	handlers.PorterHandler
+}
+
+func NewInviteAcceptHandler(
+	config *config.Config,
+) *InviteAcceptHandler {
+	return &InviteAcceptHandler{
+		PorterHandler: handlers.NewDefaultPorterHandler(config, nil, nil),
+	}
+}
+
+func (c *InviteAcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	token, _ := requestutils.GetURLParamString(r, types.URLParamInviteToken)
+
+	session, err := c.Config().Store.Get(r, c.Config().ServerConf.CookieName)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	user, err := c.Repo().User().ReadUser(userID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	projectID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projectID == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	invite, err := c.Repo().Invite().ReadInviteByToken(token)
+
+	if err != nil || invite.ProjectID != uint(projectID) {
+		vals := url.Values{}
+		vals.Add("error", "Invalid invite token")
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
+
+		return
+	}
+
+	// check that the invite has not expired and has not been accepted
+	if invite.IsExpired() || invite.IsAccepted() {
+		vals := url.Values{}
+		vals.Add("error", "Invite has expired")
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
+
+		return
+	}
+
+	// check that the invite email matches the user's email
+	if user.Email != invite.Email {
+		vals := url.Values{}
+		vals.Add("error", "Wrong email for invite")
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
+
+		return
+	}
+
+	kind := invite.Kind
+
+	if kind == "" {
+		kind = models.RoleDeveloper
+	}
+
+	project, err := c.Repo().Project().ReadProject(uint(projectID))
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if _, err = c.Repo().Project().CreateProjectRole(project, &models.Role{
+		Role: types.Role{
+			UserID:    userID,
+			ProjectID: project.ID,
+			Kind:      types.RoleKind(kind),
+		},
+	}); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// update the invite
+	invite.UserID = userID
+
+	if _, err = c.Repo().Invite().UpdateInvite(invite); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	http.Redirect(w, r, "/dashboard", 302)
+}

+ 90 - 0
api/server/handlers/invite/create.go

@@ -0,0 +1,90 @@
+package invite
+
+import (
+	"fmt"
+	"net/http"
+	"time"
+
+	"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"
+	"github.com/porter-dev/porter/internal/notifier"
+	"github.com/porter-dev/porter/internal/oauth"
+)
+
+type InviteCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewInviteCreateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *InviteCreateHandler {
+	return &InviteCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *InviteCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.CreateInviteRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// create invite model
+	invite, err := CreateInviteWithProject(request, project.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// write to database
+	invite, err = c.Repo().Invite().CreateInvite(invite)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// app.Logger.Info().Msgf("New invite created: %d", invite.ID)
+
+	if err := c.Config().UserNotifier.SendProjectInviteEmail(
+		&notifier.SendProjectInviteEmailOpts{
+			InviteeEmail:      request.Email,
+			URL:               fmt.Sprintf("%s/api/projects/%d/invites/%s", c.Config().ServerConf.ServerURL, project.ID, invite.Token),
+			Project:           project.Name,
+			ProjectOwnerEmail: user.Email,
+		},
+	); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := types.CreateInviteResponse{
+		Invite: invite.ToInviteType(),
+	}
+
+	c.WriteResult(w, r, res)
+}
+
+func CreateInviteWithProject(invite *types.CreateInviteRequest, projectID uint) (*models.Invite, error) {
+	// generate a token and an expiry time
+	expiry := time.Now().Add(24 * time.Hour)
+
+	return &models.Invite{
+		Email:     invite.Email,
+		Kind:      invite.Kind,
+		Expiry:    &expiry,
+		ProjectID: projectID,
+		Token:     oauth.CreateRandomState(),
+	}, nil
+}

+ 36 - 0
api/server/handlers/invite/delete.go

@@ -0,0 +1,36 @@
+package invite
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared/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 InviteDeleteHandler struct {
+	handlers.PorterHandler
+	authz.KubernetesAgentGetter
+}
+
+func NewInviteDeleteHandler(
+	config *config.Config,
+) *InviteDeleteHandler {
+	return &InviteDeleteHandler{
+		PorterHandler:         handlers.NewDefaultPorterHandler(config, nil, nil),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *InviteDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	invite, _ := r.Context().Value(types.InviteScope).(*models.Invite)
+
+	if err := c.Repo().Invite().DeleteInvite(invite); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 44 - 0
api/server/handlers/invite/list.go

@@ -0,0 +1,44 @@
+package invite
+
+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 InvitesListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInvitesListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InvitesListHandler {
+	return &InvitesListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InvitesListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	invites, err := c.Repo().Invite().ListInvitesByProjectID(project.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.ListInvitesResponse = make([]*types.Invite, 0)
+
+	for _, invite := range invites {
+		res = append(res, invite.ToInviteType())
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 43 - 0
api/server/handlers/invite/update_role.go

@@ -0,0 +1,43 @@
+package invite
+
+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 InviteUpdateRoleHandler struct {
+	handlers.PorterHandlerReader
+}
+
+func NewInviteUpdateRoleHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) *InviteUpdateRoleHandler {
+	return &InviteUpdateRoleHandler{
+		PorterHandlerReader: handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
+	}
+}
+
+func (c *InviteUpdateRoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	invite, _ := r.Context().Value(types.InviteScope).(*models.Invite)
+
+	request := &types.UpdateInviteRoleRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	invite.Kind = request.Kind
+
+	if _, err := c.Repo().Invite().UpdateInvite(invite); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 46 - 0
api/server/handlers/job/delete.go

@@ -0,0 +1,46 @@
+package job
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared/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"
+)
+
+type DeleteHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewDeleteHandler(
+	config *config.Config,
+) *DeleteHandler {
+	return &DeleteHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, nil),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *DeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	agent, err := c.GetAgent(r, cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamJobName)
+	namespace, _ := requestutils.GetURLParamString(r, types.URLParamNamespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = agent.DeleteJob(name, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 50 - 0
api/server/handlers/job/get_pods.go

@@ -0,0 +1,50 @@
+package job
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetPodsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetPodsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetPodsHandler {
+	return &GetPodsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	agent, err := c.GetAgent(r, cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamJobName)
+	namespace, _ := requestutils.GetURLParamString(r, types.URLParamNamespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	pods, err := agent.GetJobPods(namespace, name)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, pods)
+}

+ 49 - 0
api/server/handlers/job/stop.go

@@ -0,0 +1,49 @@
+package job
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared/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"
+)
+
+type StopHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStopHandler(
+	config *config.Config,
+) *StopHandler {
+	return &StopHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, nil),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *StopHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	agent, err := c.GetAgent(r, cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamJobName)
+	namespace, _ := requestutils.GetURLParamString(r, types.URLParamNamespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = agent.StopJobWithJobSidecar(namespace, name)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			err,
+			http.StatusBadRequest,
+		))
+		return
+	}
+}

+ 26 - 0
api/server/handlers/metadata/get.go

@@ -0,0 +1,26 @@
+package metadata
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+type MetadataGetHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewMetadataGetHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *MetadataGetHandler {
+	return &MetadataGetHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (v *MetadataGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	v.WriteResult(w, r, v.Config().Metadata)
+}

+ 27 - 0
api/server/handlers/metadata/list_cluster_ints.go

@@ -0,0 +1,27 @@
+package metadata
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type ListClusterIntegrationsHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewListClusterIntegrationsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListClusterIntegrationsHandler {
+	return &ListClusterIntegrationsHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (v *ListClusterIntegrationsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	v.WriteResult(w, r, types.PorterClusterIntegrations)
+}

+ 27 - 0
api/server/handlers/metadata/list_helm_repo_ints.go

@@ -0,0 +1,27 @@
+package metadata
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type ListHelmRepoIntegrationsHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewListHelmRepoIntegrationsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListHelmRepoIntegrationsHandler {
+	return &ListHelmRepoIntegrationsHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (v *ListHelmRepoIntegrationsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	v.WriteResult(w, r, types.PorterHelmRepoIntegrations)
+}

+ 27 - 0
api/server/handlers/metadata/list_registry_ints.go

@@ -0,0 +1,27 @@
+package metadata
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type ListRegistryIntegrationsHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewListRegistryIntegrationsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListRegistryIntegrationsHandler {
+	return &ListRegistryIntegrationsHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (v *ListRegistryIntegrationsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	v.WriteResult(w, r, types.PorterRegistryIntegrations)
+}

+ 102 - 0
api/server/handlers/namespace/create_configmap.go

@@ -0,0 +1,102 @@
+package namespace
+
+import (
+	"fmt"
+	"net/http"
+
+	v1 "k8s.io/api/core/v1"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CreateConfigMapHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateConfigMapHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateConfigMapHandler {
+	return &CreateConfigMapHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreateConfigMapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.CreateConfigMapRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	configMap, err := createConfigMap(agent, types.ConfigMapInput{
+		Name:            request.Name,
+		Namespace:       namespace,
+		Variables:       request.Variables,
+		SecretVariables: request.SecretVariables,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res = types.CreateConfigMapResponse{
+		ConfigMap: configMap,
+	}
+
+	c.WriteResult(w, r, res)
+}
+
+func createConfigMap(agent *kubernetes.Agent, input types.ConfigMapInput) (*v1.ConfigMap, error) {
+	secretData := encodeSecrets(input.SecretVariables)
+
+	// create secret first
+	if _, err := agent.CreateLinkedSecret(input.Name, input.Namespace, input.Name, secretData); err != nil {
+		return nil, err
+	}
+
+	// add all secret env variables to configmap with value PORTERSECRET_${configmap_name}
+	for key := range input.SecretVariables {
+		input.Variables[key] = fmt.Sprintf("PORTERSECRET_%s", input.Name)
+	}
+
+	return agent.CreateConfigMap(input.Name, input.Namespace, input.Variables)
+}
+
+func encodeSecrets(data map[string]string) map[string][]byte {
+	res := make(map[string][]byte)
+
+	for key, rawValue := range data {
+		// encodedValue := base64.StdEncoding.EncodeToString([]byte(rawValue))
+
+		// if err != nil {
+		// 	app.handleErrorInternal(err, w)
+		// 	return
+		// }
+
+		res[key] = []byte(rawValue)
+	}
+
+	return res
+}

+ 66 - 0
api/server/handlers/namespace/delete_configmap.go

@@ -0,0 +1,66 @@
+package namespace
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type DeleteConfigMapHandler struct {
+	handlers.PorterHandlerReader
+	authz.KubernetesAgentGetter
+}
+
+func NewDeleteConfigMapHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) *DeleteConfigMapHandler {
+	return &DeleteConfigMapHandler{
+		PorterHandlerReader:   handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *DeleteConfigMapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.DeleteConfigMapRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if err := deleteConfigMap(agent, request.Name, namespace); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
+func deleteConfigMap(agent *kubernetes.Agent, name, namespace string) error {
+	if err := agent.DeleteLinkedSecret(name, namespace); err != nil {
+		return err
+	}
+
+	if err := agent.DeleteConfigMap(name, namespace); err != nil {
+		return err
+	}
+
+	return nil
+}

+ 46 - 0
api/server/handlers/namespace/delete_pod.go

@@ -0,0 +1,46 @@
+package namespace
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared/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"
+)
+
+type DeletePodHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewDeletePodHandler(
+	config *config.Config,
+) *DeletePodHandler {
+	return &DeletePodHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, nil),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *DeletePodHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	agent, err := c.GetAgent(r, cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamPodName)
+	namespace, _ := requestutils.GetURLParamString(r, types.URLParamNamespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = agent.DeletePod(namespace, name)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 60 - 0
api/server/handlers/namespace/get_configmap.go

@@ -0,0 +1,60 @@
+package namespace
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetConfigMapHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetConfigMapHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetConfigMapHandler {
+	return &GetConfigMapHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetConfigMapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetConfigMapRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	configMap, err := agent.GetConfigMap(request.Name, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res = types.GetConfigMapResponse{
+		ConfigMap: configMap,
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 50 - 0
api/server/handlers/namespace/get_ingress.go

@@ -0,0 +1,50 @@
+package namespace
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetIngressHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetIngressHandler(
+	config *config.Config,
+	resultWriter shared.ResultWriter,
+) *GetIngressHandler {
+	return &GetIngressHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, resultWriter),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetIngressHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	agent, err := c.GetAgent(r, cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamIngressName)
+	namespace, _ := requestutils.GetURLParamString(r, types.URLParamNamespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	ingress, err := agent.GetIngress(namespace, name)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, ingress)
+}

+ 50 - 0
api/server/handlers/namespace/get_pod_events.go

@@ -0,0 +1,50 @@
+package namespace
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetPodEventsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetPodEventsHandler(
+	config *config.Config,
+	resultWriter shared.ResultWriter,
+) *GetPodEventsHandler {
+	return &GetPodEventsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, resultWriter),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetPodEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	agent, err := c.GetAgent(r, cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamPodName)
+	namespace, _ := requestutils.GetURLParamString(r, types.URLParamNamespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	events, err := agent.ListEvents(name, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, events)
+}

+ 48 - 0
api/server/handlers/namespace/list_configmaps.go

@@ -0,0 +1,48 @@
+package namespace
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListConfigMapsHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewListConfigMapsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListConfigMapsHandler {
+	return &ListConfigMapsHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ListConfigMapsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	configMaps, err := agent.ListConfigMaps(namespace)
+
+	var res = types.ListConfigMapsResponse{
+		ConfigMapList: configMaps,
+	}
+
+	c.WriteResult(w, r, res)
+}

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

@@ -0,0 +1,58 @@
+package namespace
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListReleasesHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewListReleasesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListReleasesHandler {
+	return &ListReleasesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ListReleasesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.ListReleasesRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	helmAgent, err := c.GetHelmAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	releases, err := helmAgent.ListReleases(namespace, request.ReleaseListFilter)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.ListReleasesResponse = releases
+
+	c.WriteResult(w, r, res)
+}

+ 93 - 0
api/server/handlers/namespace/rename_configmap.go

@@ -0,0 +1,93 @@
+package namespace
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type RenameConfigMapHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewRenameConfigMapHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RenameConfigMapHandler {
+	return &RenameConfigMapHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *RenameConfigMapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.RenameConfigMapRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if request.NewName == request.Name {
+		w.WriteHeader(http.StatusBadRequest)
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	configMap, err := agent.GetConfigMap(request.Name, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	secret, err := agent.GetSecret(configMap.Name, configMap.Namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var decodedSecretData = make(map[string]string)
+	for k, v := range secret.Data {
+		decodedSecretData[k] = string(v)
+	}
+
+	newConfigMap, err := createConfigMap(agent, types.ConfigMapInput{
+		Name:            request.NewName,
+		Namespace:       namespace,
+		Variables:       configMap.Data,
+		SecretVariables: decodedSecretData,
+	})
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if err := deleteConfigMap(agent, configMap.Name, configMap.Namespace); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := types.RenameConfigMapResponse{
+		ConfigMap: newConfigMap,
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 58 - 0
api/server/handlers/namespace/stream_pod_logs.go

@@ -0,0 +1,58 @@
+package namespace
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StreamPodLogsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStreamPodLogsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StreamPodLogsHandler {
+	return &StreamPodLogsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *StreamPodLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	conn, err := c.Config().WSUpgrader.Upgrade(w, r, nil)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamPodName)
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = agent.GetPodLogs(namespace, name, conn)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 76 - 0
api/server/handlers/namespace/update_configmap.go

@@ -0,0 +1,76 @@
+package namespace
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type UpdateConfigMapHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateConfigMapHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateConfigMapHandler {
+	return &UpdateConfigMapHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpdateConfigMapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.UpdateConfigMapRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	secretData := encodeSecrets(request.SecretVariables)
+
+	// create secret first
+	err = agent.UpdateLinkedSecret(request.Name, namespace, request.Name, secretData)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// add all secret env variables to configmap with value PORTERSECRET_${configmap_name}
+	for key, val := range request.SecretVariables {
+		// if val is empty and key does not exist in configmap already, set to empty
+		if _, found := request.Variables[key]; val == "" && !found {
+			request.Variables[key] = ""
+		} else if val != "" {
+			request.Variables[key] = fmt.Sprintf("PORTERSECRET_%s", request.Name)
+		}
+	}
+
+	configMap, err := agent.UpdateConfigMap(request.Name, namespace, request.Variables)
+
+	res := types.UpdateConfigMapResponse{
+		ConfigMap: configMap,
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 86 - 0
api/server/handlers/oauth_callback/digitalocean.go

@@ -0,0 +1,86 @@
+package oauth_callback
+
+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/integrations"
+	"golang.org/x/oauth2"
+)
+
+type OAuthCallbackDOHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewOAuthCallbackDOHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *OAuthCallbackDOHandler {
+	return &OAuthCallbackDOHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *OAuthCallbackDOHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	session, err := p.Config().Store.Get(r, p.Config().ServerConf.CookieName)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	token, err := p.Config().DOConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	if !token.Valid() {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("invalid token")))
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+	projID, _ := session.Values["project_id"].(uint)
+
+	oauthInt := &integrations.OAuthIntegration{
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(token.AccessToken),
+			RefreshToken: []byte(token.RefreshToken),
+		},
+		Client:    types.OAuthDigitalOcean,
+		UserID:    userID,
+		ProjectID: projID,
+	}
+
+	// create the oauth integration first
+	oauthInt, err = p.Repo().OAuthIntegration().CreateOAuthIntegration(oauthInt)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if session.Values["query_params"] != "" {
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	} else {
+		http.Redirect(w, r, "/dashboard", 302)
+	}
+}

+ 77 - 0
api/server/handlers/oauth_callback/slack.go

@@ -0,0 +1,77 @@
+package oauth_callback
+
+import (
+	"context"
+	"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/internal/integrations/slack"
+)
+
+type OAuthCallbackSlackHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewOAuthCallbackSlackHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *OAuthCallbackSlackHandler {
+	return &OAuthCallbackSlackHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *OAuthCallbackSlackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	session, err := p.Config().Store.Get(r, p.Config().ServerConf.CookieName)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	token, err := p.Config().SlackConf.Exchange(context.TODO(), r.URL.Query().Get("code"))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	slackInt, err := slack.TokenToSlackIntegration(token)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+	projID, _ := session.Values["project_id"].(uint)
+
+	slackInt.UserID = userID
+	slackInt.ProjectID = projID
+
+	if _, err = p.Repo().SlackIntegration().CreateSlackIntegration(slackInt); err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if session.Values["query_params"] != "" {
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	} else {
+		http.Redirect(w, r, "/dashboard", 302)
+	}
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов