ソースを参照

Merge branch 'beta.2.project-backend' of https://github.com/porter-dev/porter into frontend-paas

jusrhee 5 年 前
コミット
362c7f1471
98 ファイル変更9442 行追加2130 行削除
  1. 1 1
      .air.toml
  2. 1 0
      .gitignore
  3. 124 0
      cli/cmd/api/api.go
  4. 74 0
      cli/cmd/api/helper_test.go
  5. 51 0
      cli/cmd/api/k8s.go
  6. 242 0
      cli/cmd/api/project.go
  7. 378 0
      cli/cmd/api/project_test.go
  8. 255 0
      cli/cmd/api/user.go
  9. 164 0
      cli/cmd/api/user_test.go
  10. 155 0
      cli/cmd/auth.go
  11. 92 0
      cli/cmd/config.go
  12. 75 0
      cli/cmd/connect.go
  13. 314 0
      cli/cmd/connect/kubeconfig.go
  14. 0 36
      cli/cmd/credstore/credstore.go
  15. 0 5
      cli/cmd/credstore/credstore_darwin.go
  16. 0 5
      cli/cmd/credstore/credstore_linux.go
  17. 0 34
      cli/cmd/credstore/credstore_test.go
  18. 0 5
      cli/cmd/credstore/credstore_windows.go
  19. 10 6
      cli/cmd/docker/agent.go
  20. 203 8
      cli/cmd/docker/porter.go
  21. 53 0
      cli/cmd/errors.go
  22. 0 98
      cli/cmd/generate.go
  23. 0 63
      cli/cmd/helpers.go
  24. 135 0
      cli/cmd/project.go
  25. 30 2
      cli/cmd/root.go
  26. 146 0
      cli/cmd/server.go
  27. 0 337
      cli/cmd/start.go
  28. 24 0
      cli/cmd/utils/browser.go
  29. 26 0
      cli/cmd/utils/close.go
  30. 57 0
      cli/cmd/utils/prompt.go
  31. 24 0
      cli/cmd/utils/random_string.go
  32. 70 64
      cmd/app/main.go
  33. 7 0
      cmd/migrate/main.go
  34. 25 0
      cmd/ready/main.go
  35. 0 17
      docker/.env
  36. 3 1
      docker/Dockerfile
  37. 0 849
      docs/API.md
  38. 7 3
      go.mod
  39. 13 0
      go.sum
  40. 6 2
      internal/adapter/gorm.go
  41. 7 0
      internal/config/config.go
  42. 323 0
      internal/forms/action.go
  43. 677 0
      internal/forms/action_test.go
  44. 29 0
      internal/forms/candidate.go
  45. 26 12
      internal/forms/k8s.go
  46. 43 0
      internal/forms/project.go
  47. 34 16
      internal/forms/release.go
  48. 0 61
      internal/forms/user.go
  49. 7 8
      internal/helm/config.go
  50. 25 9
      internal/kubernetes/config.go
  51. 344 0
      internal/kubernetes/kubeconfig.go
  52. 602 9
      internal/kubernetes/kubeconfig_test.go
  53. 52 4
      internal/kubernetes/local/kubeconfig.go
  54. 118 0
      internal/models/action.go
  55. 43 0
      internal/models/cluster.go
  56. 47 0
      internal/models/project.go
  57. 51 0
      internal/models/repoclient.go
  58. 38 0
      internal/models/role.go
  59. 145 0
      internal/models/serviceaccount.go
  60. 6 25
      internal/models/user.go
  61. 2 12
      internal/models/user_test.go
  62. 37 0
      internal/oauth/config.go
  63. 158 0
      internal/providers/gcp/agent.go
  64. 95 0
      internal/providers/gcp/local/config.go
  65. 70 0
      internal/repository/encrypt.go
  66. 194 0
      internal/repository/gorm/helpers_test.go
  67. 66 0
      internal/repository/gorm/project.go
  68. 170 0
      internal/repository/gorm/project_test.go
  69. 134 0
      internal/repository/gorm/repoclient.go
  70. 112 0
      internal/repository/gorm/repoclient_test.go
  71. 6 3
      internal/repository/gorm/repository.go
  72. 487 0
      internal/repository/gorm/serviceaccount.go
  73. 344 0
      internal/repository/gorm/serviceaccount_test.go
  74. 1 1
      internal/repository/gorm/user.go
  75. 16 0
      internal/repository/project.go
  76. 11 0
      internal/repository/repoclient.go
  77. 5 2
      internal/repository/repository.go
  78. 16 0
      internal/repository/serviceaccount.go
  79. 86 0
      internal/repository/test/project.go
  80. 66 0
      internal/repository/test/repoclient.go
  81. 4 2
      internal/repository/test/repository.go
  82. 154 0
      internal/repository/test/serviceaccount.go
  83. 1 1
      internal/repository/test/user.go
  84. 31 16
      server/api/api.go
  85. 44 0
      server/api/health_handler.go
  86. 2 2
      server/api/helpers_test.go
  87. 1 11
      server/api/k8s_handler.go
  88. 5 2
      server/api/k8s_handler_test.go
  89. 178 0
      server/api/oauth_github_handler.go
  90. 389 0
      server/api/project_handler.go
  91. 442 0
      server/api/project_handler_test.go
  92. 32 15
      server/api/release_handler.go
  93. 68 51
      server/api/release_handler_test.go
  94. 95 22
      server/api/repo_handler.go
  95. 14 33
      server/api/user_handler.go
  96. 35 240
      server/api/user_handler_test.go
  97. 273 17
      server/router/middleware/auth.go
  98. 216 20
      server/router/router.go

+ 1 - 1
.air.toml

@@ -7,7 +7,7 @@ tmp_dir = "tmp"
 
 
 [build]
 [build]
 # Just plain old shell command. You could use `make` as well.
 # Just plain old shell command. You could use `make` as well.
-cmd = "go build -o ./tmp/migrate ./cmd/migrate; go build -o ./tmp/app ./cmd/app"
+cmd = "go build -o ./tmp/ready ./cmd/ready; go build -o ./tmp/migrate ./cmd/migrate; go build -o ./tmp/app ./cmd/app"
 # Binary file yields from `cmd`.
 # Binary file yields from `cmd`.
 bin = "tmp/migrate; tmp/app"
 bin = "tmp/migrate; tmp/app"
 # Customize binary.
 # Customize binary.

+ 1 - 0
.gitignore

@@ -1,5 +1,6 @@
 .DS_Store
 .DS_Store
 .env
 .env
+docker/.env
 app
 app
 *.db
 *.db
 test.yaml
 test.yaml

+ 124 - 0
cli/cmd/api/api.go

@@ -0,0 +1,124 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"path/filepath"
+	"time"
+
+	"k8s.io/client-go/util/homedir"
+)
+
+// Client represents the client for the Porter API
+type Client struct {
+	BaseURL        string
+	HTTPClient     *http.Client
+	Cookie         *http.Cookie
+	CookieFilePath string
+}
+
+// HTTPError is the Porter error response returned if a request fails
+type HTTPError struct {
+	Code   uint     `json:"code"`
+	Errors []string `json:"errors"`
+}
+
+// NewClient constructs a new client based on a set of options
+func NewClient(baseURL string, cookieFileName string) *Client {
+	home := homedir.HomeDir()
+	cookieFilePath := filepath.Join(home, ".porter", cookieFileName)
+
+	client := &Client{
+		BaseURL:        baseURL,
+		CookieFilePath: cookieFilePath,
+		HTTPClient: &http.Client{
+			Timeout: time.Minute,
+		},
+	}
+
+	cookie, _ := client.getCookie()
+
+	if cookie != nil {
+		client.Cookie = cookie
+	}
+
+	return client
+}
+
+func (c *Client) sendRequest(req *http.Request, v interface{}, useCookie bool) (*HTTPError, error) {
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	req.Header.Set("Accept", "application/json; charset=utf-8")
+
+	if cookie, _ := c.getCookie(); useCookie && cookie != nil {
+		c.Cookie = cookie
+		req.AddCookie(c.Cookie)
+	}
+
+	res, err := c.HTTPClient.Do(req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	defer res.Body.Close()
+
+	if cookies := res.Cookies(); len(cookies) == 1 {
+		c.saveCookie(cookies[0])
+	}
+
+	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
+		var errRes HTTPError
+		if err = json.NewDecoder(res.Body).Decode(&errRes); err == nil {
+			return &errRes, nil
+		}
+
+		return nil, fmt.Errorf("unknown error, status code: %d", res.StatusCode)
+	}
+
+	if v != nil {
+		if err = json.NewDecoder(res.Body).Decode(v); err != nil {
+			return nil, err
+		}
+	}
+
+	return nil, nil
+}
+
+// CookieStorage for temporary fs-based cookie storage before jwt tokens
+type CookieStorage struct {
+	Cookie *http.Cookie `json:"cookie"`
+}
+
+// saves single cookie to file
+func (c *Client) saveCookie(cookie *http.Cookie) error {
+	data, err := json.Marshal(&CookieStorage{
+		Cookie: cookie,
+	})
+
+	if err != nil {
+		return err
+	}
+
+	return ioutil.WriteFile(c.CookieFilePath, data, 0644)
+}
+
+// retrieves single cookie from file
+func (c *Client) getCookie() (*http.Cookie, error) {
+	data, err := ioutil.ReadFile(c.CookieFilePath)
+
+	if err != nil {
+		return nil, err
+	}
+
+	cookie := &CookieStorage{}
+
+	err = json.Unmarshal(data, cookie)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return cookie.Cookie, nil
+}

+ 74 - 0
cli/cmd/api/helper_test.go

@@ -0,0 +1,74 @@
+package api_test
+
+import (
+	"fmt"
+	"os"
+	"testing"
+
+	"github.com/porter-dev/porter/cli/cmd/docker"
+)
+
+const baseURL string = "http://localhost:10000/api"
+
+func TestMain(m *testing.M) {
+	err := startPorterServerWithDocker("user", 10000, docker.SQLite)
+
+	if err != nil {
+		fmt.Printf("%v\n", err)
+		os.Exit(1)
+	}
+
+	code := m.Run()
+	stopPorterServerWithDocker("user")
+
+	os.Exit(code)
+}
+
+type db int
+
+const (
+	pg db = iota
+	sqlite
+)
+
+// Spins up and shuts down the Docker api server with the given options
+func startPorterServerWithDocker(processID string, port int, db docker.PorterDB) error {
+	env := []string{
+		"ADMIN_INIT=false",
+	}
+
+	startOpts := &docker.PorterStartOpts{
+		ProcessID:      processID,
+		ServerImageTag: "testing",
+		ServerPort:     port,
+		DB:             db,
+		Env:            env,
+	}
+
+	_, _, err := docker.StartPorter(startOpts)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func stopPorterServerWithDocker(processID string) error {
+	agent, err := docker.NewAgentFromEnv()
+
+	if err != nil {
+		return err
+	}
+
+	err = agent.StopPorterContainersWithProcessID(processID, true)
+
+	if err != nil {
+		return err
+	}
+
+	// remove volumes
+	err = agent.RemoveLocalVolume("porter_sqlite_" + processID)
+
+	return nil
+}

+ 51 - 0
cli/cmd/api/k8s.go

@@ -0,0 +1,51 @@
+package api
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+
+	v1 "k8s.io/api/core/v1"
+)
+
+// GetK8sNamespacesResponse is the list of namespaces returned when a
+// user has successfully authenticated
+type GetK8sNamespacesResponse v1.NamespaceList
+
+// GetK8sNamespaces gets a namespaces list in a k8s cluster
+func (c *Client) GetK8sNamespaces(
+	ctx context.Context,
+	projectID uint,
+	serviceAccountID uint,
+	clusterID uint,
+) (*GetK8sNamespacesResponse, error) {
+	sa := fmt.Sprintf("%d", serviceAccountID)
+	cl := fmt.Sprintf("%d", clusterID)
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/k8s/namespaces?"+url.Values{
+			"service_account_id": []string{sa},
+			"cluster_id":         []string{cl},
+		}.Encode(), c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &GetK8sNamespacesResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}

+ 242 - 0
cli/cmd/api/project.go

@@ -0,0 +1,242 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// GetProjectResponse is the response returned after querying for a
+// given project
+type GetProjectResponse models.ProjectExternal
+
+// GetProject retrieves a project by id
+func (c *Client) GetProject(ctx context.Context, projectID uint) (*GetProjectResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &GetProjectResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// ListProjectClustersResponse lists the linked clusters for a project
+type ListProjectClustersResponse []models.ClusterExternal
+
+// ListProjectClusters creates a list of clusters for a given project
+func (c *Client) ListProjectClusters(
+	ctx context.Context,
+	projectID uint,
+) (ListProjectClustersResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/clusters", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := ListProjectClustersResponse{}
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// CreateProjectRequest represents the accepted fields for creating a project
+type CreateProjectRequest struct {
+	Name string `json:"name" form:"required"`
+}
+
+// CreateProjectResponse is the resulting project after creation
+type CreateProjectResponse models.ProjectExternal
+
+// CreateProject creates a project with the given request options
+func (c *Client) CreateProject(
+	ctx context.Context,
+	createProjectRequest *CreateProjectRequest,
+) (*CreateProjectResponse, error) {
+	data, err := json.Marshal(createProjectRequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects", c.BaseURL),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateProjectResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// CreateProjectCandidatesRequest creates a project service account candidate,
+// which can be resolved to create a service account
+type CreateProjectCandidatesRequest struct {
+	Kubeconfig string `json:"kubeconfig"`
+}
+
+// CreateProjectCandidatesResponse is the list of candidates returned after
+// creating the candidates
+type CreateProjectCandidatesResponse []*models.ServiceAccountCandidateExternal
+
+// 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,
+	createCandidatesRequest *CreateProjectCandidatesRequest,
+) (CreateProjectCandidatesResponse, error) {
+	data, err := json.Marshal(createCandidatesRequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/candidates", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := CreateProjectCandidatesResponse{}
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// GetProjectCandidatesResponse is the list of service account candidates
+type GetProjectCandidatesResponse []*models.ServiceAccountCandidateExternal
+
+// GetProjectCandidates returns the service account candidates for a given
+// project id
+func (c *Client) GetProjectCandidates(
+	ctx context.Context,
+	projectID uint,
+) (GetProjectCandidatesResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/candidates", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := GetProjectCandidatesResponse{}
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// CreateProjectServiceAccountRequest is a list of service account actions,
+// which resolve a given service account
+type CreateProjectServiceAccountRequest []*models.ServiceAccountAllActions
+
+// CreateProjectServiceAccountResponse is the service account that gets
+// returned after the actions have been resolved
+type CreateProjectServiceAccountResponse models.ServiceAccountExternal
+
+// CreateProjectServiceAccount creates a service account given a project id
+// and a candidate id, which gets resolved using the list of actions
+func (c *Client) CreateProjectServiceAccount(
+	ctx context.Context,
+	projectID uint,
+	candidateID uint,
+	createSARequest CreateProjectServiceAccountRequest,
+) (*CreateProjectServiceAccountResponse, error) {
+	data, err := json.Marshal(&createSARequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/candidates/%d/resolve", c.BaseURL, projectID, candidateID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateProjectServiceAccountResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}

+ 378 - 0
cli/cmd/api/project_test.go

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

+ 255 - 0
cli/cmd/api/user.go

@@ -0,0 +1,255 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// AuthCheckResponse is the user model response that is returned if the
+// user is logged in
+type AuthCheckResponse models.UserExternal
+
+// AuthCheck performs a check to ensure that the user is logged in
+func (c *Client) AuthCheck(ctx context.Context) (*AuthCheckResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/auth/check", c.BaseURL),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+
+	bodyResp := &AuthCheckResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// LoginRequest is the email/password associated with a login request
+type LoginRequest struct {
+	Email    string `json:"email"`
+	Password string `json:"password"`
+}
+
+// LoginResponse is the user model response that is returned after successfully
+// logging in
+type LoginResponse models.UserExternal
+
+// Login authorizes the user and grants them a cookie-based session
+func (c *Client) Login(ctx context.Context, loginRequest *LoginRequest) (*LoginResponse, error) {
+	data, err := json.Marshal(loginRequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/login", c.BaseURL),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &LoginResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, false); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// Logout logs the user out and deauthorizes the cookie-based session
+func (c *Client) Logout(ctx context.Context) error {
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/logout", c.BaseURL),
+		nil,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	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
+}
+
+// CreateUserRequest is the email/password associated with creating a user
+type CreateUserRequest struct {
+	Email    string `json:"email"`
+	Password string `json:"password"`
+}
+
+// CreateUserResponse is the user model response that is returned after successfully
+// creating a user
+type CreateUserResponse models.UserExternal
+
+// CreateUser will create the user, authorize the user and grant them a cookie-based session
+func (c *Client) CreateUser(
+	ctx context.Context,
+	createUserRequest *CreateUserRequest,
+) (*CreateUserResponse, error) {
+	data, err := json.Marshal(createUserRequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/users", c.BaseURL),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateUserResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, false); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// GetUserResponse is the user model response that is returned after successfully
+// getting a user
+type GetUserResponse models.UserExternal
+
+// GetUser retrieves a user given a user id
+func (c *Client) GetUser(ctx context.Context, userID uint) (*GetUserResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/users/%d", c.BaseURL, userID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+
+	bodyResp := &GetUserResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// ListUserProjectsResponse is the list of projects returned
+type ListUserProjectsResponse []*models.ProjectExternal
+
+// ListUserProjects returns a list of projects associated with a user
+func (c *Client) ListUserProjects(ctx context.Context, userID uint) (ListUserProjectsResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/users/%d/projects", c.BaseURL, userID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+
+	bodyResp := ListUserProjectsResponse{}
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// DeleteUserRequest is the password needed to verify a user should be deleted
+type DeleteUserRequest struct {
+	Password string `json:"password"`
+}
+
+// DeleteUser deletes a user of a given user id
+func (c *Client) DeleteUser(
+	ctx context.Context,
+	userID uint,
+	deleteUserRequest *DeleteUserRequest,
+) error {
+	data, err := json.Marshal(deleteUserRequest)
+
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(
+		"DELETE",
+		fmt.Sprintf("%s/users/%d", c.BaseURL, userID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	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
+}

+ 164 - 0
cli/cmd/api/user_test.go

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

+ 155 - 0
cli/cmd/auth.go

@@ -0,0 +1,155 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/fatih/color"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/spf13/cobra"
+)
+
+var authCmd = &cobra.Command{
+	Use:   "auth",
+	Short: "Commands for authenticating to a Porter server",
+}
+
+var loginCmd = &cobra.Command{
+	Use:   "login",
+	Short: "Authorizes a user for a given Porter server",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := login()
+
+		if err != nil {
+			color.Red("Error logging in:", err.Error())
+			os.Exit(1)
+		}
+	},
+}
+
+var registerCmd = &cobra.Command{
+	Use:   "register",
+	Short: "Creates a user for a given Porter server",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := register()
+
+		if err != nil {
+			color.Red("Error registering:", err.Error())
+			os.Exit(1)
+		}
+	},
+}
+
+var logoutCmd = &cobra.Command{
+	Use:   "logout",
+	Short: "Logs a user out of a given Porter server",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, logout)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(authCmd)
+
+	authCmd.AddCommand(loginCmd)
+	authCmd.AddCommand(registerCmd)
+	authCmd.AddCommand(logoutCmd)
+
+	authCmd.PersistentFlags().StringVar(
+		&host,
+		"host",
+		getHost(),
+		"host url of Porter instance",
+	)
+}
+
+func login() error {
+	client := api.NewClient(getHost()+"/api", "cookie.json")
+	user, _ := client.AuthCheck(context.Background())
+
+	if user != nil {
+		color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
+		return nil
+	}
+
+	var username, pw string
+
+	fmt.Println("Please log in with an email and password:")
+
+	username, err := utils.PromptPlaintext("Email: ")
+
+	if err != nil {
+		return err
+	}
+
+	pw, err = utils.PromptPassword("Password: ")
+
+	if err != nil {
+		return err
+	}
+
+	_, err = client.Login(context.Background(), &api.LoginRequest{
+		Email:    username,
+		Password: pw,
+	})
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Println("Successfully logged in!")
+
+	return nil
+}
+
+func register() error {
+	host := getHost()
+
+	fmt.Println("Please register your admin account with an email and password:")
+
+	username, err := utils.PromptPlaintext("Email: ")
+
+	if err != nil {
+		return err
+	}
+
+	pw, err := utils.PromptPasswordWithConfirmation()
+
+	if err != nil {
+		return err
+	}
+
+	client := api.NewClient(host+"/api", "cookie.json")
+
+	resp, err := client.CreateUser(context.Background(), &api.CreateUserRequest{
+		Email:    username,
+		Password: pw,
+	})
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Printf("Created user with email %s and id %d\n", username, resp.ID)
+
+	return nil
+}
+
+func logout(user *api.AuthCheckResponse, client *api.Client, args []string) error {
+	err := client.Logout(context.Background())
+
+	if err != nil {
+		return err
+	}
+
+	color.Green("Successfully logged out")
+
+	return nil
+}

+ 92 - 0
cli/cmd/config.go

@@ -0,0 +1,92 @@
+package cmd
+
+import (
+	"os"
+	"strconv"
+
+	"github.com/fatih/color"
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+)
+
+// a set of shared flags
+var (
+	host      string
+	projectID uint
+)
+
+var configCmd = &cobra.Command{
+	Use:   "config",
+	Short: "Commands that control local configuration settings",
+}
+
+var setProjectCmd = &cobra.Command{
+	Use:   "set-project [id]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Saves the project id in the default configuration",
+	Run: func(cmd *cobra.Command, args []string) {
+		projID, err := strconv.ParseUint(args[0], 10, 64)
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+
+		err = setProject(uint(projID))
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
+var setHostCmd = &cobra.Command{
+	Use:   "set-host [host]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Saves the host in the default configuration",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := setHost(args[0])
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(configCmd)
+
+	configCmd.AddCommand(setProjectCmd)
+	configCmd.AddCommand(setHostCmd)
+}
+
+func setProject(id uint) error {
+	viper.Set("project", id)
+	color.New(color.FgGreen).Printf("Set the current project id as %d\n", id)
+	return viper.WriteConfig()
+}
+
+func setHost(host string) error {
+	viper.Set("host", host)
+	err := viper.WriteConfig()
+	color.New(color.FgGreen).Printf("Set the current host as %s\n", host)
+	return err
+}
+
+func getHost() string {
+	if host != "" {
+		return host
+	}
+
+	return viper.GetString("host")
+}
+
+func getProjectID() uint {
+	if projectID != 0 {
+		return projectID
+	}
+
+	return viper.GetUint("project")
+}

+ 75 - 0
cli/cmd/connect.go

@@ -0,0 +1,75 @@
+package cmd
+
+import (
+	"os"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/connect"
+	"github.com/spf13/cobra"
+)
+
+var (
+	kubeconfigPath string
+	print          *bool
+	contexts       *[]string
+)
+
+var connectCmd = &cobra.Command{
+	Use:   "connect",
+	Short: "Commands that connect to external clusters and providers",
+}
+
+var connectKubeconfigCmd = &cobra.Command{
+	Use:   "kubeconfig",
+	Short: "Uses the local kubeconfig to connect to a cluster",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnect)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(connectCmd)
+
+	connectCmd.AddCommand(connectKubeconfigCmd)
+
+	connectCmd.PersistentFlags().StringVar(
+		&host,
+		"host",
+		getHost(),
+		"host url of Porter instance",
+	)
+
+	projectID = *connectCmd.PersistentFlags().UintP(
+		"project-id",
+		"p",
+		getProjectID(),
+		"project id to use",
+	)
+
+	connectKubeconfigCmd.PersistentFlags().StringVarP(
+		&kubeconfigPath,
+		"kubeconfig",
+		"k",
+		"",
+		"path to kubeconfig",
+	)
+
+	contexts = connectKubeconfigCmd.PersistentFlags().StringArray(
+		"contexts",
+		nil,
+		"the list of contexts to connect (defaults to the current context)",
+	)
+}
+
+func runConnect(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	return connect.Kubeconfig(
+		client,
+		kubeconfigPath,
+		*contexts,
+		getProjectID(),
+	)
+}

+ 314 - 0
cli/cmd/connect/kubeconfig.go

@@ -0,0 +1,314 @@
+package connect
+
+import (
+	"context"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"strings"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/porter-dev/porter/internal/kubernetes/local"
+	gcpLocal "github.com/porter-dev/porter/internal/providers/gcp/local"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// Kubeconfig creates a service account for a project by parsing the local
+// kubeconfig and resolving actions that must be performed.
+func Kubeconfig(
+	client *api.Client,
+	kubeconfigPath string,
+	contexts []string,
+	projectID uint,
+) error {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// get the kubeconfig
+	rawBytes, err := local.GetKubeconfigFromHost(kubeconfigPath, contexts)
+
+	if err != nil {
+		return err
+	}
+
+	// send kubeconfig to client
+	saCandidates, err := client.CreateProjectCandidates(
+		context.Background(),
+		projectID,
+		&api.CreateProjectCandidatesRequest{
+			Kubeconfig: string(rawBytes),
+		},
+	)
+
+	if err != nil {
+		return err
+	}
+
+	for _, saCandidate := range saCandidates {
+		resolvers := make(api.CreateProjectServiceAccountRequest, 0)
+
+		for _, action := range saCandidate.Actions {
+			switch action.Name {
+			case models.ClusterCADataAction:
+				resolveAction, err := resolveClusterCAAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.ClientCertDataAction:
+				resolveAction, err := resolveClientCertAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.ClientKeyDataAction:
+				resolveAction, err := resolveClientKeyAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.OIDCIssuerDataAction:
+				resolveAction, err := resolveOIDCIssuerAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.TokenDataAction:
+				resolveAction, err := resolveTokenDataAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.GCPKeyDataAction:
+				resolveAction, err := resolveGCPKeyAction(
+					saCandidate.ClusterEndpoint,
+					saCandidate.ClusterName,
+				)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.AWSKeyDataAction:
+			}
+		}
+
+		sa, err := client.CreateProjectServiceAccount(
+			context.Background(),
+			projectID,
+			saCandidate.ID,
+			resolvers,
+		)
+
+		if err != nil {
+			return err
+		}
+
+		for _, cluster := range sa.Clusters {
+			color.New(color.FgGreen).Printf("created service account for cluster %s with id %d\n", cluster.Name, sa.ID)
+
+			// sanity check to ensure it's working
+			// namespaces, err := client.GetK8sNamespaces(
+			// 	context.Background(),
+			// 	projectID,
+			// 	sa.ID,
+			// 	cluster.ID,
+			// )
+
+			// if err != nil {
+			// 	return err
+			// }
+
+			// for _, ns := range namespaces.Items {
+			// 	fmt.Println(ns.ObjectMeta.GetName())
+			// }
+		}
+	}
+
+	return nil
+}
+
+// resolves a cluster ca data action
+func resolveClusterCAAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:          models.ClusterCADataAction,
+		ClusterCAData: base64.StdEncoding.EncodeToString(fileBytes),
+	}, nil
+}
+
+// resolves a client cert data action
+func resolveClientCertAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:           models.ClientCertDataAction,
+		ClientCertData: base64.StdEncoding.EncodeToString(fileBytes),
+	}, nil
+}
+
+// resolves a client key data action
+func resolveClientKeyAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:          models.ClientKeyDataAction,
+		ClientKeyData: base64.StdEncoding.EncodeToString(fileBytes),
+	}, nil
+}
+
+// resolves an oidc issuer data action
+func resolveOIDCIssuerAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:             models.OIDCIssuerDataAction,
+		OIDCIssuerCAData: base64.StdEncoding.EncodeToString(fileBytes),
+	}, nil
+}
+
+// resolves a token data action
+func resolveTokenDataAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:      models.TokenDataAction,
+		TokenData: string(fileBytes),
+	}, nil
+}
+
+// resolves a gcp key data action
+func resolveGCPKeyAction(endpoint string, clusterName string) (*models.ServiceAccountAllActions, error) {
+	userResp, err := utils.PromptPlaintext(
+		fmt.Sprintf(
+			`Detected GKE cluster in kubeconfig for the endpoint %s (%s). 
+Porter can set up a service account in your GCP project to connect to this cluster automatically.
+Would you like to proceed? %s `,
+			color.New(color.FgCyan).Sprintf("%s", endpoint),
+			clusterName,
+			color.New(color.FgCyan).Sprintf("[y/n]"),
+		),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if userResp := strings.ToLower(userResp); userResp == "y" || userResp == "yes" {
+		agent, _ := gcpLocal.NewDefaultAgent()
+		projID, err := agent.GetProjectIDForGKECluster(endpoint)
+
+		if err != nil {
+			return nil, err
+		}
+
+		agent.ProjectID = projID
+
+		name := "porter-dashboard-" + utils.StringWithCharset(6, "abcdefghijklmnopqrstuvwxyz1234567890")
+
+		// create the service account and give it the correct iam permissions
+		resp, err := agent.CreateServiceAccount(name)
+
+		if err != nil {
+			color.New(color.FgRed).Println("Automatic creation failed, manual input required.")
+			return resolveGCPKeyActionManual(endpoint, clusterName)
+		}
+
+		err = agent.SetServiceAccountIAMPolicy(resp)
+
+		if err != nil {
+			return nil, err
+		}
+
+		// get the service account key data to send to the server
+		bytes, err := agent.CreateServiceAccountKey(resp)
+
+		if err != nil {
+			return nil, err
+		}
+
+		return &models.ServiceAccountAllActions{
+			Name:       models.GCPKeyDataAction,
+			GCPKeyData: string(bytes),
+		}, nil
+	}
+
+	return resolveGCPKeyActionManual(endpoint, clusterName)
+}
+
+func resolveGCPKeyActionManual(endpoint string, clusterName string) (*models.ServiceAccountAllActions, error) {
+	keyFileLocation, err := utils.PromptPlaintext(fmt.Sprintf(`Please provide the full path to a service account key file.
+Key file location: `))
+
+	if err != nil {
+		return nil, err
+	}
+
+	// attempt to read the key file location
+	if info, err := os.Stat(keyFileLocation); !os.IsNotExist(err) && !info.IsDir() {
+		// read the file
+		bytes, err := ioutil.ReadFile(keyFileLocation)
+
+		if err != nil {
+			return nil, err
+		}
+
+		return &models.ServiceAccountAllActions{
+			Name:       models.GCPKeyDataAction,
+			GCPKeyData: string(bytes),
+		}, nil
+	}
+
+	return nil, errors.New("Key file not found")
+}
+
+// resolves an aws key data action

+ 0 - 36
cli/cmd/credstore/credstore.go

@@ -1,36 +0,0 @@
-package credstore
-
-import "github.com/docker/docker-credential-helpers/credentials"
-
-const (
-	url   = "https://github.com/porter-dev/porter"
-	label = "Porter Credentials"
-)
-
-// Set stores a given username/pw with a given credentials label in the OS-specific
-// credentials store
-func Set(username, pw string) error {
-	cr := &credentials.Credentials{
-		ServerURL: url,
-		Username:  username,
-		Secret:    pw,
-	}
-
-	credentials.SetCredsLabel(label)
-
-	return ns.Add(cr)
-}
-
-// Get retrieves a given username/pw with a given credentials label in the OS-specific
-// credentials store
-func Get() (string, string, error) {
-	credentials.SetCredsLabel(label)
-	return ns.Get(url)
-}
-
-// Del removes a given credential that uses a label in the OS-specific
-// credentials store
-func Del() error {
-	credentials.SetCredsLabel(label)
-	return ns.Delete(url)
-}

+ 0 - 5
cli/cmd/credstore/credstore_darwin.go

@@ -1,5 +0,0 @@
-package credstore
-
-import "github.com/docker/docker-credential-helpers/osxkeychain"
-
-var ns = osxkeychain.Osxkeychain{}

+ 0 - 5
cli/cmd/credstore/credstore_linux.go

@@ -1,5 +0,0 @@
-package credstore
-
-import "github.com/docker/docker-credential-helpers/pass"
-
-var ns = pass.Pass{}

+ 0 - 34
cli/cmd/credstore/credstore_test.go

@@ -1,34 +0,0 @@
-package credstore_test
-
-import (
-	"log"
-	"testing"
-
-	"github.com/porter-dev/porter/cli/cmd/credstore"
-)
-
-func TestSetGet(t *testing.T) {
-	credstore.Set("user", "password")
-
-	user, secret, err := credstore.Get()
-	if err == nil {
-		if user != "user" {
-			t.Errorf("Expecting user, got %s", user)
-		}
-
-		if secret != "password" {
-			t.Errorf("Expecting password, got %s", secret)
-		}
-	} else {
-		log.Println("got error:", err)
-	}
-
-	credstore.Del()
-
-	_, _, err = credstore.Get()
-
-	if err == nil {
-		t.Fatalf("Expecting an error, got nil")
-	}
-
-}

+ 0 - 5
cli/cmd/credstore/credstore_windows.go

@@ -1,5 +0,0 @@
-package credstore
-
-import "github.com/docker/docker-credential-helpers/wincred"
-
-var ns = wincred.Wincred{}

+ 10 - 6
cli/cmd/docker/agent.go

@@ -3,6 +3,7 @@ package docker
 import (
 import (
 	"context"
 	"context"
 	"encoding/json"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
 	"strings"
 	"strings"
@@ -68,6 +69,11 @@ func (a *Agent) CreateLocalVolume(name string) (*types.Volume, error) {
 	return &vol, nil
 	return &vol, nil
 }
 }
 
 
+// RemoveLocalVolume removes a volume by name
+func (a *Agent) RemoveLocalVolume(name string) error {
+	return a.client.VolumeRemove(a.ctx, name, true)
+}
+
 // CreateBridgeNetworkIfNotExist creates a volume using driver type "local" with the
 // CreateBridgeNetworkIfNotExist creates a volume using driver type "local" with the
 // given name if it does not exist. If the volume does exist but does not contain
 // given name if it does not exist. If the volume does exist but does not contain
 // the required label (a.label), an error is thrown.
 // the required label (a.label), an error is thrown.
@@ -141,8 +147,6 @@ type PullImageEvent struct {
 
 
 // PullImage pulls an image specified by the image string
 // PullImage pulls an image specified by the image string
 func (a *Agent) PullImage(image string) error {
 func (a *Agent) PullImage(image string) error {
-	fmt.Println("Pulling image:", image)
-
 	// pull the specified image
 	// pull the specified image
 	out, err := a.client.ImagePull(a.ctx, image, types.ImagePullOptions{})
 	out, err := a.client.ImagePull(a.ctx, image, types.ImagePullOptions{})
 
 
@@ -164,8 +168,6 @@ func (a *Agent) PullImage(image string) error {
 		}
 		}
 	}
 	}
 
 
-	fmt.Println("Finished pulling image:", image)
-
 	return nil
 	return nil
 }
 }
 
 
@@ -198,14 +200,16 @@ func (a *Agent) WaitForContainerHealthy(id string, streak int) error {
 
 
 		health := cont.State.Health
 		health := cont.State.Health
 
 
-		if health == nil || health.Status == "healthy" || health.FailingStreak >= streak {
+		if health == nil || health.Status == "healthy" {
+			return nil
+		} else if health.FailingStreak >= streak {
 			break
 			break
 		}
 		}
 
 
 		time.Sleep(time.Second)
 		time.Sleep(time.Second)
 	}
 	}
 
 
-	return nil
+	return errors.New("container not healthy")
 }
 }
 
 
 // ------------------------- AGENT HELPER FUNCTIONS ------------------------- //
 // ------------------------- AGENT HELPER FUNCTIONS ------------------------- //

+ 203 - 8
cli/cmd/docker/porter.go

@@ -2,6 +2,7 @@ package docker
 
 
 import (
 import (
 	"fmt"
 	"fmt"
+	"strings"
 	"time"
 	"time"
 
 
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types"
@@ -10,8 +11,156 @@ import (
 	"github.com/docker/go-connections/nat"
 	"github.com/docker/go-connections/nat"
 )
 )
 
 
-// PorterStartOpts are the options for starting the Porter container
+// PorterDB is used for enumerating DB types
+type PorterDB int
+
+// The supported DB types
+const (
+	Postgres PorterDB = iota
+	SQLite
+)
+
+// PorterStartOpts are the options for starting the Porter stack
 type PorterStartOpts struct {
 type PorterStartOpts struct {
+	ProcessID      string
+	ServerImageTag string
+	ServerPort     int
+	DB             PorterDB
+	Env            []string
+}
+
+// StartPorter creates a new Docker agent using the host environment, and creates a
+// new Porter instance
+func StartPorter(opts *PorterStartOpts) (agent *Agent, id string, err error) {
+	agent, err = NewAgentFromEnv()
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	// the volume mounts to use
+	mounts := make([]mount.Mount, 0)
+
+	// the volumes passed to the Porter container
+	volumesMap := make(map[string]struct{})
+
+	netID, err := agent.CreateBridgeNetworkIfNotExist("porter_network_" + opts.ProcessID)
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	switch opts.DB {
+	case SQLite:
+		// check if sqlite volume exists, create it if not
+		vol, err := agent.CreateLocalVolumeIfNotExist("porter_sqlite_" + opts.ProcessID)
+
+		if err != nil {
+			return nil, "", err
+		}
+
+		// create mount
+		mount := mount.Mount{
+			Type:        mount.TypeVolume,
+			Source:      vol.Name,
+			Target:      "/sqlite",
+			ReadOnly:    false,
+			Consistency: mount.ConsistencyFull,
+		}
+
+		mounts = append(mounts, mount)
+		volumesMap[vol.Name] = struct{}{}
+
+		opts.Env = append(opts.Env, []string{
+			"SQL_LITE=true",
+			"SQL_LITE_PATH=/sqlite/porter.db",
+		}...)
+	case Postgres:
+		// check if postgres volume exists, create it if not
+		vol, err := agent.CreateLocalVolumeIfNotExist("porter_postgres_" + opts.ProcessID)
+
+		if err != nil {
+			return nil, "", err
+		}
+
+		// pgMount is mount for postgres container
+		pgMount := []mount.Mount{
+			mount.Mount{
+				Type:        mount.TypeVolume,
+				Source:      vol.Name,
+				Target:      "/var/lib/postgresql/data",
+				ReadOnly:    false,
+				Consistency: mount.ConsistencyFull,
+			},
+		}
+
+		// create postgres container with mount
+		startOpts := PostgresOpts{
+			Name:   "porter_postgres_" + opts.ProcessID,
+			Image:  "postgres:latest",
+			Mounts: pgMount,
+			VolumeMap: map[string]struct{}{
+				"porter_postgres": struct{}{},
+			},
+			NetworkID: netID,
+			Env: []string{
+				"POSTGRES_USER=porter",
+				"POSTGRES_PASSWORD=porter",
+				"POSTGRES_DB=porter",
+			},
+		}
+
+		pgID, err := agent.StartPostgresContainer(startOpts)
+
+		if err != nil {
+			return nil, "", err
+		}
+
+		err = agent.WaitForContainerHealthy(pgID, 10)
+
+		if err != nil {
+			return nil, "", err
+		}
+
+		opts.Env = append(opts.Env, []string{
+			"SQL_LITE=false",
+			"DB_USER=porter",
+			"DB_PASS=porter",
+			"DB_NAME=porter",
+			"DB_HOST=porter_postgres_" + opts.ProcessID,
+			"DB_PORT=5432",
+		}...)
+	}
+
+	// create Porter container
+	startOpts := PorterServerStartOpts{
+		Name:          "porter_server_" + opts.ProcessID,
+		Image:         "porter1/porter:" + opts.ServerImageTag,
+		HostPort:      uint(opts.ServerPort),
+		ContainerPort: 8080,
+		Mounts:        mounts,
+		VolumeMap:     volumesMap,
+		NetworkID:     netID,
+		Env:           opts.Env,
+	}
+
+	id, err = agent.StartPorterContainer(startOpts)
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	err = agent.WaitForContainerHealthy(id, 10)
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	return agent, id, nil
+}
+
+// PorterServerStartOpts are the options for starting the Porter server
+type PorterServerStartOpts struct {
 	Name          string
 	Name          string
 	Image         string
 	Image         string
 	StartCmd      []string
 	StartCmd      []string
@@ -25,7 +174,7 @@ type PorterStartOpts struct {
 
 
 // StartPorterContainer pulls a specific Porter image and starts a container
 // StartPorterContainer pulls a specific Porter image and starts a container
 // using the Docker engine. It returns the container ID
 // using the Docker engine. It returns the container ID
-func (a *Agent) StartPorterContainer(opts PorterStartOpts) (string, error) {
+func (a *Agent) StartPorterContainer(opts PorterServerStartOpts) (string, error) {
 	id, err := a.upsertPorterContainer(opts)
 	id, err := a.upsertPorterContainer(opts)
 
 
 	if err != nil {
 	if err != nil {
@@ -52,7 +201,7 @@ func (a *Agent) StartPorterContainer(opts PorterStartOpts) (string, error) {
 // if spec has changed, remove and recreate container
 // if spec has changed, remove and recreate container
 // if container does not exist, create the container
 // if container does not exist, create the container
 // otherwise, return stopped container
 // otherwise, return stopped container
-func (a *Agent) upsertPorterContainer(opts PorterStartOpts) (id string, err error) {
+func (a *Agent) upsertPorterContainer(opts PorterServerStartOpts) (id string, err error) {
 	containers, err := a.getContainersCreatedByStart()
 	containers, err := a.getContainersCreatedByStart()
 
 
 	// remove the matching container
 	// remove the matching container
@@ -78,7 +227,7 @@ func (a *Agent) upsertPorterContainer(opts PorterStartOpts) (id string, err erro
 }
 }
 
 
 // create the container and return its id
 // create the container and return its id
-func (a *Agent) pullAndCreatePorterContainer(opts PorterStartOpts) (id string, err error) {
+func (a *Agent) pullAndCreatePorterContainer(opts PorterServerStartOpts) (id string, err error) {
 	a.PullImage(opts.Image)
 	a.PullImage(opts.Image)
 
 
 	// format the port array for binding to host machine
 	// format the port array for binding to host machine
@@ -101,6 +250,12 @@ func (a *Agent) pullAndCreatePorterContainer(opts PorterStartOpts) (id string, e
 		Labels:  labels,
 		Labels:  labels,
 		Volumes: opts.VolumeMap,
 		Volumes: opts.VolumeMap,
 		Env:     opts.Env,
 		Env:     opts.Env,
+		Healthcheck: &container.HealthConfig{
+			Test:     []string{"CMD-SHELL", "/porter/ready"},
+			Interval: 10 * time.Second,
+			Timeout:  5 * time.Second,
+			Retries:  3,
+		},
 	}, &container.HostConfig{
 	}, &container.HostConfig{
 		PortBindings: portBindings,
 		PortBindings: portBindings,
 		Mounts:       opts.Mounts,
 		Mounts:       opts.Mounts,
@@ -226,10 +381,8 @@ func (a *Agent) startPostgresContainer(id string) error {
 }
 }
 
 
 // StopPorterContainers finds all containers that were started via the CLI and stops them
 // StopPorterContainers finds all containers that were started via the CLI and stops them
-// without removal.
-func (a *Agent) StopPorterContainers() error {
-	fmt.Println("Stopping containers...")
-
+// -- removes the container if remove is set to true
+func (a *Agent) StopPorterContainers(remove bool) error {
 	containers, err := a.getContainersCreatedByStart()
 	containers, err := a.getContainersCreatedByStart()
 
 
 	if err != nil {
 	if err != nil {
@@ -245,6 +398,48 @@ func (a *Agent) StopPorterContainers() error {
 		if err != nil {
 		if err != nil {
 			return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
 			return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
 		}
 		}
+
+		if remove {
+			err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+
+			if err != nil {
+				return a.handleDockerClientErr(err, "Could not remove container "+container.ID)
+			}
+		}
+	}
+
+	return nil
+}
+
+// StopPorterContainersWithProcessID finds all containers that were started via the CLI
+// and have a given process id and stops them -- removes the container if remove is set
+// to true
+func (a *Agent) StopPorterContainersWithProcessID(processID string, remove bool) error {
+	containers, err := a.getContainersCreatedByStart()
+
+	if err != nil {
+		return err
+	}
+
+	// remove all Porter containers
+	for _, container := range containers {
+		if strings.Contains(container.Names[0], "_"+processID) {
+			timeout, _ := time.ParseDuration("15s")
+
+			err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+
+			if err != nil {
+				return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
+			}
+
+			if remove {
+				err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+
+				if err != nil {
+					return a.handleDockerClientErr(err, "Could not remove container "+container.ID)
+				}
+			}
+		}
 	}
 	}
 
 
 	return nil
 	return nil

+ 53 - 0
cli/cmd/errors.go

@@ -0,0 +1,53 @@
+package cmd
+
+import (
+	"context"
+	"strings"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+)
+
+func checkLoginAndRun(args []string, runner func(user *api.AuthCheckResponse, client *api.Client, args []string) error) error {
+	client := api.NewClient(getHost()+"/api", "cookie.json")
+
+	user, err := client.AuthCheck(context.Background())
+
+	if err != nil {
+		red := color.New(color.FgRed)
+
+		if strings.Contains(err.Error(), "403") {
+			red.Print("You are not logged in. Log in using \"porter auth login\"\n")
+			return nil
+		} else if strings.Contains(err.Error(), "connection refused") {
+			red.Printf("Unable to connect to the Porter server at %s\n", getHost())
+			red.Print("To set a different host, run \"porter config set-host [HOST]\"\n")
+			red.Print("To start a local server, run \"porter server start\"\n")
+			return nil
+		}
+
+		red.Printf("Error: %v\n", err.Error())
+		return err
+	}
+
+	err = runner(user, client, args)
+
+	if err != nil {
+		red := color.New(color.FgRed)
+
+		if strings.Contains(err.Error(), "403") {
+			red.Print("You do not have the necessary permissions to view this resource")
+			return nil
+		} else if strings.Contains(err.Error(), "connection refused") {
+			red.Printf("Unable to connect to the Porter server at %s\n", getHost())
+			red.Print("To set a different host, run \"porter config set-host [HOST]\"")
+			red.Print("To start a local server, run \"porter server start\"")
+			return nil
+		}
+
+		red.Printf("Error: %v\n", err.Error())
+		return err
+	}
+
+	return nil
+}

+ 0 - 98
cli/cmd/generate.go

@@ -1,98 +0,0 @@
-package cmd
-
-import (
-	"fmt"
-	"path/filepath"
-
-	"github.com/porter-dev/porter/internal/kubernetes/local"
-
-	"k8s.io/client-go/tools/clientcmd"
-	"k8s.io/client-go/util/homedir"
-
-	"github.com/spf13/cobra"
-)
-
-var (
-	outputFile     string
-	kubeconfigPath string
-	print          *bool
-	contexts       *[]string
-)
-
-// generateCmd represents the generate command
-var generateCmd = &cobra.Command{
-	Use:   "generate",
-	Short: "Generates a kubeconfig with certificate data added",
-	Run: func(cmd *cobra.Command, args []string) {
-		generate(kubeconfigPath, outputFile, *print, *contexts)
-	},
-}
-
-func init() {
-	home := homedir.HomeDir()
-
-	rootCmd.AddCommand(generateCmd)
-
-	generateCmd.PersistentFlags().StringVarP(
-		&outputFile,
-		"output",
-		"o",
-		filepath.Join(home, ".porter", "porter.kubeconfig"),
-		"output file location",
-	)
-
-	generateCmd.PersistentFlags().StringVarP(
-		&kubeconfigPath,
-		"kubeconfig",
-		"k",
-		"",
-		"path to kubeconfig",
-	)
-
-	contexts = generateCmd.PersistentFlags().StringArray(
-		"contexts",
-		nil,
-		"the list of contexts to use (defaults to the current context)",
-	)
-
-	print = generateCmd.PersistentFlags().BoolP(
-		"print",
-		"p",
-		false,
-		"print result to stdout, without writing to the fs",
-	)
-}
-
-func generate(kubeconfigPath string, output string, print bool, contexts []string) error {
-	conf, err := local.GetConfigFromHostWithCertData(kubeconfigPath, contexts)
-
-	if err != nil {
-		return err
-	}
-
-	rawConf, err := conf.RawConfig()
-
-	if err != nil {
-		return err
-	}
-
-	if print {
-		bytes, err := clientcmd.Write(rawConf)
-
-		if err != nil {
-			return err
-		}
-
-		fmt.Printf(string(bytes))
-
-		return nil
-	}
-
-	err = clientcmd.WriteToFile(rawConf, output)
-
-	if err != nil {
-		return err
-	}
-
-	return nil
-}

+ 0 - 63
cli/cmd/helpers.go

@@ -1,63 +0,0 @@
-package cmd
-
-import (
-	"bufio"
-	"errors"
-	"fmt"
-	"os"
-	"os/signal"
-	"strings"
-	"syscall"
-
-	"golang.org/x/crypto/ssh/terminal"
-)
-
-func closeHandler(closer func() error) {
-	sig := make(chan os.Signal)
-	signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
-	go func() {
-		<-sig
-		err := closer()
-
-		if err == nil {
-			fmt.Println("shutdown successful")
-			os.Exit(0)
-		}
-
-		fmt.Printf("shutdown unsuccessful: %s\n", err.Error())
-		os.Exit(1)
-	}()
-}
-
-func promptPlaintext(prompt string) (string, error) {
-	reader := bufio.NewReader(os.Stdin)
-
-	fmt.Print(prompt)
-	text, err := reader.ReadString('\n')
-
-	if err != nil {
-		return "", err
-	}
-
-	return strings.TrimSpace(text), nil
-}
-
-func promptPasswordWithConfirmation() (string, error) {
-	fmt.Print("Password: ")
-	pw, err := terminal.ReadPassword(0)
-	fmt.Print("\r")
-
-	if err != nil {
-		return "", err
-	}
-
-	fmt.Print("Confirm password: ")
-	confirmPw, err := terminal.ReadPassword(0)
-	fmt.Print("\n")
-
-	if strings.TrimSpace(string(pw)) != strings.TrimSpace(string(confirmPw)) {
-		return "", errors.New("Passwords do not match")
-	}
-
-	return strings.TrimSpace(string(pw)), nil
-}

+ 135 - 0
cli/cmd/project.go

@@ -0,0 +1,135 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"text/tabwriter"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/spf13/cobra"
+)
+
+// projectCmd represents the "porter project" base command when called
+// without any subcommands
+var projectCmd = &cobra.Command{
+	Use:   "project",
+	Short: "Commands that control Porter project settings",
+}
+
+var createProjectCmd = &cobra.Command{
+	Use:   "create [name]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Creates a project with the authorized user as admin",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, createProject)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var listProjectCmd = &cobra.Command{
+	Use:   "list",
+	Short: "Lists the projects for the logged in user",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, listProjects)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var listProjectClustersCmd = &cobra.Command{
+	Use:   "clusters list",
+	Short: "Lists the linked clusters for a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, listProjectClusters)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(projectCmd)
+
+	projectCmd.AddCommand(createProjectCmd)
+
+	projectCmd.PersistentFlags().StringVar(
+		&host,
+		"host",
+		getHost(),
+		"host url of Porter instance",
+	)
+
+	projectCmd.AddCommand(listProjectCmd)
+
+	projectCmd.AddCommand(listProjectClustersCmd)
+}
+
+func createProject(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
+	resp, err := client.CreateProject(context.Background(), &api.CreateProjectRequest{
+		Name: args[0],
+	})
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Printf("Created project with name %s and id %d\n", args[0], resp.ID)
+
+	return setProject(resp.ID)
+}
+
+func listProjects(user *api.AuthCheckResponse, client *api.Client, args []string) error {
+	projects, err := client.ListUserProjects(context.Background(), user.ID)
+
+	if err != nil {
+		return err
+	}
+
+	w := new(tabwriter.Writer)
+	w.Init(os.Stdout, 3, 8, 0, '\t', tabwriter.AlignRight)
+
+	fmt.Fprintf(w, "%s\t%s\n", "ID", "NAME")
+
+	currProjectID := getProjectID()
+
+	for _, project := range projects {
+		if currProjectID == project.ID {
+			color.New(color.FgGreen).Fprintf(w, "%d\t%s (current project)\n", project.ID, project.Name)
+		} else {
+			fmt.Fprintf(w, "%d\t%s\n", project.ID, project.Name)
+		}
+	}
+
+	w.Flush()
+
+	return nil
+}
+
+func listProjectClusters(user *api.AuthCheckResponse, client *api.Client, args []string) error {
+	clusters, err := client.ListProjectClusters(context.Background(), getProjectID())
+
+	if err != nil {
+		return err
+	}
+
+	w := new(tabwriter.Writer)
+	w.Init(os.Stdout, 3, 8, 0, '\t', tabwriter.AlignRight)
+
+	fmt.Fprintf(w, "%s\t%s\t%s\n", "ID", "NAME", "SERVER")
+
+	for _, cluster := range clusters {
+		fmt.Fprintf(w, "%d\t%s\t%s\n", cluster.ID, cluster.Name, cluster.Server)
+	}
+
+	w.Flush()
+
+	return nil
+}

+ 30 - 2
cli/cmd/root.go

@@ -1,10 +1,14 @@
 package cmd
 package cmd
 
 
 import (
 import (
-	"fmt"
+	"io/ioutil"
 	"os"
 	"os"
+	"path/filepath"
 
 
+	"github.com/fatih/color"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+	"k8s.io/client-go/util/homedir"
 )
 )
 
 
 // rootCmd represents the base command when called without any subcommands
 // rootCmd represents the base command when called without any subcommands
@@ -14,11 +18,35 @@ var rootCmd = &cobra.Command{
 	Long:  `Porter is a tool for creating, versioning, and updating Kubernetes deployments using a visual dashboard. For more information, visit github.com/porter-dev/porter`,
 	Long:  `Porter is a tool for creating, versioning, and updating Kubernetes deployments using a visual dashboard. For more information, visit github.com/porter-dev/porter`,
 }
 }
 
 
+var home = homedir.HomeDir()
+
 // Execute adds all child commands to the root command and sets flags appropriately.
 // Execute adds all child commands to the root command and sets flags appropriately.
 // This is called by main.main(). It only needs to happen once to the rootCmd.
 // This is called by main.main(). It only needs to happen once to the rootCmd.
 func Execute() {
 func Execute() {
+	viper.SetConfigName("porter")
+	viper.SetConfigType("yaml")
+	viper.AddConfigPath(filepath.Join(home, ".porter"))
+
+	err := viper.ReadInConfig()
+
+	if err != nil {
+		if _, ok := err.(viper.ConfigFileNotFoundError); ok {
+			// create blank config file
+			err := ioutil.WriteFile(filepath.Join(home, ".porter", "porter.yaml"), []byte{}, 0644)
+
+			if err != nil {
+				color.New(color.FgRed).Printf("%v\n", err)
+				os.Exit(1)
+			}
+		} else {
+			// Config file was found but another error was produced
+			color.New(color.FgRed).Printf("%v\n", err)
+			os.Exit(1)
+		}
+	}
+
 	if err := rootCmd.Execute(); err != nil {
 	if err := rootCmd.Execute(); err != nil {
-		fmt.Println(err)
+		color.New(color.FgRed).Println(err)
 		os.Exit(1)
 		os.Exit(1)
 	}
 	}
 }
 }

+ 146 - 0
cli/cmd/server.go

@@ -0,0 +1,146 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/docker"
+
+	"github.com/spf13/cobra"
+)
+
+type startOps struct {
+	imageTag string `form:"required"`
+	db       string `form:"oneof=sqlite postgres"`
+	port     *int   `form:"required"`
+}
+
+var opts = &startOps{}
+
+var serverCmd = &cobra.Command{
+	Use:   "server",
+	Short: "Commands to control a local Porter server",
+}
+
+// startCmd represents the start command
+var startCmd = &cobra.Command{
+	Use:   "start",
+	Short: "Starts a Porter instance using the Docker engine",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := start(
+			opts.imageTag,
+			opts.db,
+			*opts.port,
+		)
+
+		if err != nil {
+			red := color.New(color.FgRed)
+			red.Println("Error running start:", err.Error())
+			red.Println("Shutting down...")
+
+			err = stop()
+
+			if err != nil {
+				red.Println("Shutdown unsuccessful:", err.Error())
+			}
+
+			os.Exit(1)
+		}
+	},
+}
+
+var stopCmd = &cobra.Command{
+	Use:   "stop",
+	Short: "Stops a Porter instance running on the Docker engine",
+	Run: func(cmd *cobra.Command, args []string) {
+		if err := stop(); err != nil {
+			color.New(color.FgRed).Println("Shutdown unsuccessful:", err.Error())
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(serverCmd)
+
+	serverCmd.AddCommand(startCmd)
+	serverCmd.AddCommand(stopCmd)
+
+	startCmd.PersistentFlags().StringVar(
+		&opts.db,
+		"db",
+		"sqlite",
+		"the db to use, one of sqlite or postgres",
+	)
+
+	startCmd.PersistentFlags().StringVar(
+		&opts.imageTag,
+		"image-tag",
+		"latest",
+		"the Porter image tag to use",
+	)
+
+	opts.port = startCmd.PersistentFlags().IntP(
+		"port",
+		"p",
+		8080,
+		"the host port to run the server on",
+	)
+}
+
+func start(
+	imageTag string,
+	db string,
+	port int,
+) error {
+	env := make([]string, 0)
+	var porterDB docker.PorterDB
+
+	switch db {
+	case "postgres":
+		porterDB = docker.Postgres
+	case "sqlite":
+		porterDB = docker.SQLite
+	}
+
+	startOpts := &docker.PorterStartOpts{
+		ProcessID:      "main",
+		ServerImageTag: imageTag,
+		ServerPort:     port,
+		DB:             porterDB,
+		Env:            env,
+	}
+
+	_, _, err := docker.StartPorter(startOpts)
+
+	if err != nil {
+		return err
+	}
+
+	green := color.New(color.FgGreen)
+
+	green.Printf("Server ready: listening on localhost:%d\n", port)
+
+	return setHost(fmt.Sprintf("http://localhost:%d", port))
+}
+
+func stop() error {
+	agent, err := docker.NewAgentFromEnv()
+
+	if err != nil {
+		return err
+	}
+
+	err = agent.StopPorterContainersWithProcessID("main", false)
+
+	if err != nil {
+		return err
+	}
+
+	green := color.New(color.FgGreen)
+
+	green.Println("Successfully stopped the Porter server.")
+
+	return nil
+}

+ 0 - 337
cli/cmd/start.go

@@ -1,337 +0,0 @@
-package cmd
-
-import (
-	"fmt"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"runtime"
-	"time"
-
-	"github.com/porter-dev/porter/cli/cmd/docker"
-	"k8s.io/client-go/util/homedir"
-
-	"github.com/porter-dev/porter/cli/cmd/credstore"
-
-	"github.com/spf13/cobra"
-
-	"github.com/docker/docker/api/types/mount"
-)
-
-type startOps struct {
-	insecure       *bool
-	skipKubeconfig *bool
-	kubeconfigPath string
-	contexts       *[]string
-	imageTag       string `form:"required"`
-	db             string `form:"oneof=sqlite postgres"`
-}
-
-var opts = &startOps{}
-
-// startCmd represents the start command
-var startCmd = &cobra.Command{
-	Args: func(cmd *cobra.Command, args []string) error {
-		return nil
-	},
-	Use:   "start",
-	Short: "Starts a Porter instance using the Docker engine.",
-	Run: func(cmd *cobra.Command, args []string) {
-		closeHandler(stop)
-
-		err := start(
-			opts.imageTag,
-			opts.kubeconfigPath,
-			opts.db,
-			*opts.contexts,
-			*opts.insecure,
-			*opts.skipKubeconfig,
-		)
-
-		if err != nil {
-			fmt.Println("Error running start:", err.Error())
-			fmt.Println("Shutting down...")
-
-			err = stop()
-
-			if err != nil {
-				fmt.Println("Shutdown unsuccessful:", err.Error())
-			}
-
-			os.Exit(1)
-		}
-	},
-}
-
-func init() {
-	rootCmd.AddCommand(startCmd)
-
-	opts.insecure = startCmd.PersistentFlags().Bool(
-		"insecure",
-		false,
-		"skip admin setup and authorization",
-	)
-
-	opts.skipKubeconfig = startCmd.PersistentFlags().Bool(
-		"skip-kubeconfig",
-		false,
-		"skip initialization of the kubeconfig",
-	)
-
-	opts.contexts = startCmd.PersistentFlags().StringArray(
-		"contexts",
-		nil,
-		"the list of contexts to use (defaults to the current context)",
-	)
-
-	startCmd.PersistentFlags().StringVar(
-		&opts.db,
-		"db",
-		"sqlite",
-		"the db to use, one of sqlite or postgres",
-	)
-
-	startCmd.PersistentFlags().StringVar(
-		&opts.kubeconfigPath,
-		"kubeconfig",
-		"",
-		"path to kubeconfig",
-	)
-
-	startCmd.PersistentFlags().StringVar(
-		&opts.imageTag,
-		"image-tag",
-		"latest",
-		"the Porter image tag to use",
-	)
-}
-
-func stop() error {
-	agent, err := docker.NewAgentFromEnv()
-
-	if err != nil {
-		return err
-	}
-
-	err = agent.StopPorterContainers()
-
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func start(
-	imageTag string,
-	kubeconfigPath string,
-	db string,
-	contexts []string,
-	insecure bool,
-	skipKubeconfig bool,
-) error {
-	var username, pw string
-	var err error
-	home := homedir.HomeDir()
-	outputConfPath := filepath.Join(home, ".porter", "porter.kubeconfig")
-	containerConfPath := "/porter/porter.kubeconfig"
-	port := 8080
-
-	// if not insecure, or username/pw set incorrectly, prompt for new username/pw
-	if username, pw, err = credstore.Get(); !insecure && err != nil {
-		fmt.Println("Please register your admin account with an email and password:")
-
-		username, err = promptPlaintext("Email: ")
-
-		if err != nil {
-			return err
-		}
-
-		pw, err = promptPasswordWithConfirmation()
-
-		if err != nil {
-			return err
-		}
-
-		credstore.Set(username, pw)
-	}
-
-	if !skipKubeconfig {
-		err = generate(
-			kubeconfigPath,
-			outputConfPath,
-			false,
-			contexts,
-		)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	agent, err := docker.NewAgentFromEnv()
-
-	if err != nil {
-		return err
-	}
-
-	// the volume mounts to use
-	mounts := make([]mount.Mount, 0)
-
-	// the volumes passed to the Porter container
-	volumesMap := make(map[string]struct{})
-
-	if !skipKubeconfig {
-		// add a bind mount with the kubeconfig
-		mount := mount.Mount{
-			Type:        mount.TypeBind,
-			Source:      outputConfPath,
-			Target:      containerConfPath,
-			ReadOnly:    true,
-			Consistency: mount.ConsistencyFull,
-		}
-
-		mounts = append(mounts, mount)
-	}
-
-	netID, err := agent.CreateBridgeNetworkIfNotExist("porter_network")
-
-	if err != nil {
-		return err
-	}
-
-	env := make([]string, 0)
-
-	env = append(env, []string{
-		"ADMIN_INIT=true",
-		"ADMIN_EMAIL=" + username,
-		"ADMIN_PASSWORD=" + pw,
-	}...)
-
-	switch db {
-	case "sqlite":
-		// check if sqlite volume exists, create it if not
-		vol, err := agent.CreateLocalVolumeIfNotExist("porter_sqlite")
-
-		if err != nil {
-			return err
-		}
-
-		// create mount
-		mount := mount.Mount{
-			Type:        mount.TypeVolume,
-			Source:      vol.Name,
-			Target:      "/sqlite",
-			ReadOnly:    false,
-			Consistency: mount.ConsistencyFull,
-		}
-
-		mounts = append(mounts, mount)
-		volumesMap[vol.Name] = struct{}{}
-
-		env = append(env, []string{
-			"SQL_LITE=true",
-			"SQL_LITE_PATH=/sqlite/porter.db",
-		}...)
-	case "postgres":
-		// check if postgres volume exists, create it if not
-		vol, err := agent.CreateLocalVolumeIfNotExist("porter_postgres")
-
-		if err != nil {
-			return err
-		}
-
-		// pgMount is mount for postgres container
-		pgMount := []mount.Mount{
-			mount.Mount{
-				Type:        mount.TypeVolume,
-				Source:      vol.Name,
-				Target:      "/var/lib/postgresql/data",
-				ReadOnly:    false,
-				Consistency: mount.ConsistencyFull,
-			},
-		}
-
-		// create postgres container with mount
-		startOpts := docker.PostgresOpts{
-			Name:   "porter_postgres",
-			Image:  "postgres:latest",
-			Mounts: pgMount,
-			VolumeMap: map[string]struct{}{
-				"porter_postgres": struct{}{},
-			},
-			NetworkID: netID,
-			Env: []string{
-				"POSTGRES_USER=porter",
-				"POSTGRES_PASSWORD=porter",
-				"POSTGRES_DB=porter",
-			},
-		}
-
-		pgID, err := agent.StartPostgresContainer(startOpts)
-
-		fmt.Println("Waiting for postgres:latest to be healthy...")
-		agent.WaitForContainerHealthy(pgID, 10)
-
-		if err != nil {
-			return err
-		}
-
-		env = append(env, []string{
-			"SQL_LITE=false",
-			"DB_USER=porter",
-			"DB_PASS=porter",
-			"DB_NAME=porter",
-			"DB_HOST=porter_postgres",
-			"DB_PORT=5432",
-		}...)
-
-		defer agent.WaitForContainerStop(pgID)
-	}
-
-	// create Porter container
-	// TODO -- look for unused port
-	startOpts := docker.PorterStartOpts{
-		Name:          "porter_server",
-		Image:         "porter1/porter:" + imageTag,
-		HostPort:      uint(port),
-		ContainerPort: 8080,
-		Mounts:        mounts,
-		VolumeMap:     volumesMap,
-		NetworkID:     netID,
-		Env:           env,
-	}
-
-	id, err := agent.StartPorterContainer(startOpts)
-
-	if err != nil {
-		return err
-	}
-
-	fmt.Println("Spinning up the server...")
-	time.Sleep(7 * time.Second)
-	openBrowser(fmt.Sprintf("http://localhost:%d/login?email=%s", port, username))
-	fmt.Printf("Server ready: listening on localhost:%d\n", port)
-
-	agent.WaitForContainerStop(id)
-
-	return nil
-}
-
-// openBrowser opens the specified URL in the default browser of the user.
-func openBrowser(url string) error {
-	var cmd string
-	var args []string
-
-	switch runtime.GOOS {
-	case "windows":
-		cmd = "cmd"
-		args = []string{"/c", "start"}
-	case "darwin":
-		cmd = "open"
-	default: // "linux", "freebsd", "openbsd", "netbsd"
-		cmd = "xdg-open"
-	}
-	args = append(args, url)
-	return exec.Command(cmd, args...).Start()
-}

+ 24 - 0
cli/cmd/utils/browser.go

@@ -0,0 +1,24 @@
+package utils
+
+import (
+	"os/exec"
+	"runtime"
+)
+
+// OpenBrowser opens the specified URL in the default browser of the user.
+func OpenBrowser(url string) error {
+	var cmd string
+	var args []string
+
+	switch runtime.GOOS {
+	case "windows":
+		cmd = "cmd"
+		args = []string{"/c", "start"}
+	case "darwin":
+		cmd = "open"
+	default: // "linux", "freebsd", "openbsd", "netbsd"
+		cmd = "xdg-open"
+	}
+	args = append(args, url)
+	return exec.Command(cmd, args...).Start()
+}

+ 26 - 0
cli/cmd/utils/close.go

@@ -0,0 +1,26 @@
+package utils
+
+import (
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/fatih/color"
+)
+
+func closeHandler(closer func() error) {
+	sig := make(chan os.Signal)
+	signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		<-sig
+		err := closer()
+
+		if err == nil {
+			color.New(color.FgRed).Println("shutdown successful")
+			os.Exit(0)
+		}
+
+		color.New(color.FgRed).Printf("shutdown unsuccessful: %s\n", err.Error())
+		os.Exit(1)
+	}()
+}

+ 57 - 0
cli/cmd/utils/prompt.go

@@ -0,0 +1,57 @@
+package utils
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+
+	"golang.org/x/crypto/ssh/terminal"
+)
+
+// PromptPlaintext prompts a user to input plain text
+func PromptPlaintext(prompt string) (string, error) {
+	reader := bufio.NewReader(os.Stdin)
+
+	fmt.Print(prompt)
+	text, err := reader.ReadString('\n')
+
+	if err != nil {
+		return "", err
+	}
+
+	return strings.TrimSpace(text), nil
+}
+
+// PromptPassword prompts a user to input a hidden field
+func PromptPassword(prompt string) (string, error) {
+	fmt.Print(prompt)
+	pw, err := terminal.ReadPassword(0)
+	fmt.Print("\r")
+
+	if err != nil {
+		return "", err
+	}
+
+	return strings.TrimSpace(string(pw)), nil
+}
+
+// PromptPasswordWithConfirmation is a helper function to prompt
+// for a password twice
+func PromptPasswordWithConfirmation() (string, error) {
+	pw, err := PromptPassword("Password: ")
+	if err != nil {
+		return "", err
+	}
+	confirmPw, err := PromptPassword("Confirm password: ")
+	if err != nil {
+		return "", err
+	}
+
+	if pw != confirmPw {
+		return "", errors.New("Passwords do not match")
+	}
+
+	return pw, nil
+}

+ 24 - 0
cli/cmd/utils/random_string.go

@@ -0,0 +1,24 @@
+package utils
+
+import (
+	"math/rand"
+	"time"
+)
+
+const charset = "abcdefghijklmnopqrstuvwxyz" +
+	"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+
+var seededRand *rand.Rand = rand.New(
+	rand.NewSource(time.Now().UnixNano()))
+
+func StringWithCharset(length int, charset string) string {
+	b := make([]byte, length)
+	for i := range b {
+		b[i] = charset[seededRand.Intn(len(charset))]
+	}
+	return string(b)
+}
+
+func String(length int) string {
+	return StringWithCharset(length, charset)
+}

+ 70 - 64
cmd/app/main.go

@@ -2,21 +2,16 @@ package main
 
 
 import (
 import (
 	"fmt"
 	"fmt"
-	"io/ioutil"
 	"log"
 	"log"
 	"net/http"
 	"net/http"
-	"os"
-
-	"github.com/porter-dev/porter/internal/kubernetes"
 
 
 	"github.com/gorilla/sessions"
 	"github.com/gorilla/sessions"
-	"github.com/porter-dev/porter/internal/forms"
-	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/api"
 
 
-	adapter "github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/adapter"
 	sessionstore "github.com/porter-dev/porter/internal/auth"
 	sessionstore "github.com/porter-dev/porter/internal/auth"
 	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
 	lr "github.com/porter-dev/porter/internal/logger"
@@ -35,26 +30,37 @@ func main() {
 		return
 		return
 	}
 	}
 
 
-	repo := gorm.NewRepository(db)
-
-	// upsert admin if config requires
-	if appConf.Db.AdminInit {
-		err := upsertAdmin(repo.User, appConf.Db.AdminEmail, appConf.Db.AdminPassword)
+	var key [32]byte
 
 
-		if err != nil {
-			fmt.Println("Error while upserting admin: " + err.Error())
-		}
+	for i, b := range []byte(appConf.Db.EncryptionKey) {
+		key[i] = b
 	}
 	}
 
 
+	repo := gorm.NewRepository(db, &key)
+
 	// declare as Store interface (methods Get, New, Save)
 	// declare as Store interface (methods Get, New, Save)
 	var store sessions.Store
 	var store sessions.Store
 	store, _ = sessionstore.NewStore(repo, appConf.Server)
 	store, _ = sessionstore.NewStore(repo, appConf.Server)
 
 
 	validator := vr.New()
 	validator := vr.New()
 
 
-	a := api.New(logger, repo, validator, store, appConf.Server.CookieName, false)
-
-	appRouter := router.New(a, store, appConf.Server.CookieName, appConf.Server.StaticFilePath)
+	a := api.New(
+		logger,
+		nil,
+		repo,
+		validator,
+		store,
+		appConf.Server.CookieName,
+		false,
+		&oauth.Config{
+			ClientID:     appConf.Server.GithubClientID,
+			ClientSecret: appConf.Server.GithubClientSecret,
+			Scopes:       []string{"repo", "user", "read:user"},
+			BaseURL:      appConf.Server.ServerURL,
+		},
+	)
+
+	appRouter := router.New(a, store, appConf.Server.CookieName, appConf.Server.StaticFilePath, repo)
 
 
 	address := fmt.Sprintf(":%d", appConf.Server.Port)
 	address := fmt.Sprintf(":%d", appConf.Server.Port)
 
 
@@ -69,68 +75,68 @@ func main() {
 	}
 	}
 
 
 	if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
 	if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
-		log.Fatal("Server startup failed")
+		log.Fatal("Server startup failed", err)
 	}
 	}
 }
 }
 
 
-func upsertAdmin(repo repository.UserRepository, email, pw string) error {
-	admUser, err := repo.ReadUserByEmail(email)
+// func upsertAdmin(repo repository.UserRepository, email, pw string) error {
+// 	admUser, err := repo.ReadUserByEmail(email)
 
 
-	// create the user in this case
-	if err != nil {
-		form := forms.CreateUserForm{
-			Email:    email,
-			Password: pw,
-		}
+// 	// create the user in this case
+// 	if err != nil {
+// 		form := forms.CreateUserForm{
+// 			Email:    email,
+// 			Password: pw,
+// 		}
 
 
-		admUser, err = form.ToUser(repo)
+// 		admUser, err = form.ToUser(repo)
 
 
-		if err != nil {
-			return err
-		}
+// 		if err != nil {
+// 			return err
+// 		}
 
 
-		admUser, err = repo.CreateUser(admUser)
+// 		admUser, err = repo.CreateUser(admUser)
 
 
-		if err != nil {
-			return err
-		}
-	}
+// 		if err != nil {
+// 			return err
+// 		}
+// 	}
 
 
-	filename := "/porter/porter.kubeconfig"
+// 	filename := "/porter/porter.kubeconfig"
 
 
-	// read if kubeconfig file exists, if it does update the user
-	if _, err := os.Stat(filename); !os.IsNotExist(err) {
-		fileBytes, err := ioutil.ReadFile(filename)
+// 	// read if kubeconfig file exists, if it does update the user
+// 	if _, err := os.Stat(filename); !os.IsNotExist(err) {
+// 		fileBytes, err := ioutil.ReadFile(filename)
 
 
-		contexts := make([]string, 0)
-		allContexts, err := kubernetes.GetContextsFromBytes(fileBytes, []string{})
+// 		contexts := make([]string, 0)
+// 		allContexts, err := kubernetes.GetContextsFromBytes(fileBytes, []string{})
 
 
-		if err != nil {
-			return err
-		}
+// 		if err != nil {
+// 			return err
+// 		}
 
 
-		for _, context := range allContexts {
-			contexts = append(contexts, context.Name)
-		}
+// 		for _, context := range allContexts {
+// 			contexts = append(contexts, context.Name)
+// 		}
 
 
-		form := forms.UpdateUserForm{
-			ID:              admUser.ID,
-			RawKubeConfig:   string(fileBytes),
-			AllowedContexts: contexts,
-		}
+// 		form := forms.UpdateUserForm{
+// 			ID:              admUser.ID,
+// 			RawKubeConfig:   string(fileBytes),
+// 			AllowedContexts: contexts,
+// 		}
 
 
-		admUser, err = form.ToUser(repo)
+// 		admUser, err = form.ToUser(repo)
 
 
-		if err != nil {
-			return err
-		}
+// 		if err != nil {
+// 			return err
+// 		}
 
 
-		admUser, err = repo.UpdateUser(admUser)
+// 		admUser, err = repo.UpdateUser(admUser)
 
 
-		if err != nil {
-			return err
-		}
-	}
+// 		if err != nil {
+// 			return err
+// 		}
+// 	}
 
 
-	return nil
-}
+// 	return nil
+// }

+ 7 - 0
cmd/migrate/main.go

@@ -23,8 +23,15 @@ func main() {
 	}
 	}
 
 
 	err = db.AutoMigrate(
 	err = db.AutoMigrate(
+		&models.Project{},
+		&models.Role{},
+		&models.ServiceAccount{},
+		&models.ServiceAccountAction{},
+		&models.ServiceAccountCandidate{},
+		&models.Cluster{},
 		&models.User{},
 		&models.User{},
 		&models.Session{},
 		&models.Session{},
+		&models.RepoClient{},
 	)
 	)
 
 
 	if err != nil {
 	if err != nil {

+ 25 - 0
cmd/ready/main.go

@@ -0,0 +1,25 @@
+package main
+
+import (
+	"fmt"
+	"net/http"
+	"os"
+
+	"github.com/porter-dev/porter/internal/config"
+)
+
+func main() {
+	appConf := config.FromEnv()
+
+	resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/livez", appConf.Server.Port))
+
+	if err != nil || resp.StatusCode >= http.StatusBadRequest {
+		os.Exit(1)
+	}
+
+	resp, err = http.Get(fmt.Sprintf("http://localhost:%d/api/readyz", appConf.Server.Port))
+
+	if err != nil || resp.StatusCode >= http.StatusBadRequest {
+		os.Exit(1)
+	}
+}

+ 0 - 17
docker/.env

@@ -1,17 +0,0 @@
-DEBUG=true
-
-STATIC_FILE_PATH=/porter/static
-
-SERVER_PORT=8080
-SERVER_TIMEOUT_READ=5s
-SERVER_TIMEOUT_WRITE=10s
-SERVER_TIMEOUT_IDLE=15s
-
-DB_HOST=postgres
-DB_PORT=5432
-DB_USER=porter
-DB_PASS=porter
-DB_NAME=porter
-COOKIE_SECRETS=secret
-
-SQL_LITE=false

+ 3 - 1
docker/Dockerfile

@@ -22,7 +22,8 @@ FROM base AS build-go
 RUN --mount=type=cache,target=/root/.cache/go-build \
 RUN --mount=type=cache,target=/root/.cache/go-build \
     --mount=type=cache,target=$GOPATH/pkg/mod \
     --mount=type=cache,target=$GOPATH/pkg/mod \
     go build -ldflags '-w -s' -a -o ./bin/app ./cmd/app && \
     go build -ldflags '-w -s' -a -o ./bin/app ./cmd/app && \
-    go build -ldflags '-w -s' -a -o ./bin/migrate ./cmd/migrate
+    go build -ldflags '-w -s' -a -o ./bin/migrate ./cmd/migrate && \
+    go build -ldflags '-w -s' -a -o ./bin/ready ./cmd/ready
 
 
 # Go test environment
 # Go test environment
 # -------------------
 # -------------------
@@ -52,6 +53,7 @@ RUN apk update
 
 
 COPY --from=build-go /porter/bin/app /porter/
 COPY --from=build-go /porter/bin/app /porter/
 COPY --from=build-go /porter/bin/migrate /porter/
 COPY --from=build-go /porter/bin/migrate /porter/
+COPY --from=build-go /porter/bin/ready /porter/
 COPY --from=build-webpack /webpack/build /porter/static
 COPY --from=build-webpack /webpack/build /porter/static
 
 
 ENV DEBUG=false
 ENV DEBUG=false

+ 0 - 849
docs/API.md

@@ -1,849 +0,0 @@
-**Table of Contents:**
-
-- [Overview](#overview)
-- [Global Errors](#global-errors)
-  - [`ErrorDataWrite`](#errordatawrite)
-  - [`ErrorDataRead`](#errordataread)
-  - [`ErrorInternal`](#errorinternal)
-- [`/api/users`](#apiusers)
-  - [`GET /api/users/{id}`](#get-apiusersid)
-  - [`GET /api/users/{id}/contexts`](#get-apiusersidcontexts)
-  - [`POST /api/users`](#post-apiusers)
-  - [`POST /api/login`](#post-apilogin)
-  - [`POST /api/logout`](#post-apilogout)
-  - [`PUT /api/users/{id}`](#put-apiusersid)
-  - [`DELETE /api/users/{id}`](#delete-apiusersid)
-- [`/api/releases`](#apireleases)
-  - [`GET /api/releases`](#get-apireleases)
-  - [`GET /api/releases/{name}/history`](#get-apireleasesnamehistory)
-  - [`GET /api/releases/{name}/{revision}`](#get-apireleasesnamerevision)
-  - [`POST /api/releases/{name}/rollback`](#post-apireleasesnamerollback)
-  - [`POST /api/releases/{name}/upgrade`](#post-apireleasesnameupgrade)
-- [`/api/k8s`](#apik8s)
-  - [`GET /api/k8s/namespaces`](#get-apik8snamespaces)
-
-
-### Overview
-
-This is the API specification that the Go server is implementing. 
-
-**Error handling:**
-
-Errors are passed via both a non-`2xx` status code and an HTTPError response body:
-
-```js
-HTTPError{
-    // The Porter custom error code
-    Code: Number,
-    // A descriptive error message
-    Errors: []String,
-}
-```
-
-Internal server errors are shared across all endpoints and are listed in the [Global Errors](#global-errors) section. 
-
-**Authentication:** The current authentication method is cookie-based sessions--most endpoints require a cookie-based session. 
-
-### Global Errors
-
-#### `ErrorDataWrite`
-
-**Description:** occurs when a write is attempted against the database and fails. 
-
-**Status Code:** `500`
-
-**Response Body:**
-
-```json
-{
-	"Code": 500,
-	"Errors": [{
-		"could not write to database"
-	}],
-}
-```
-
-#### `ErrorDataRead`
-
-**Description:** occurs when a read is attempted against the database and fails. 
-
-**Status Code:** `500`
-
-**Response Body:**
-
-```json
-{
-	"Code": 500,
-	"Errors": [{
-		"could not read from database"
-	}],
-}
-```
-
-#### `ErrorInternal`
-
-**Description:** occurs with a generic internal server error
-
-**Status Code:** `500`
-
-**Response Body:**
-
-```json
-{
-	"Code": 500,
-	"Errors": [{
-		"internal server error"
-	}],
-}
-```
-
-### `/api/users`
-
-#### `GET /api/users/{id}`
-
-**Description:** Gets a user object matching a specific `id`. 
-
-**URL parameters:** 
-
-- `id` The user's ID. 
-
-**Query parameters:** N/A
-
-**Request Body**: N/A
-
-**Successful Response Body**: 
-
-```js
-User{
-    "id": Number,
-    "email": String,
-    "contexts": []String,
-    "rawKubeConfig": String,
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:**
-
-- User not found
-  - Status Code: `404`
-  - Request Body:
-    ```json
-    {
-        "code":602,
-        "errors":["could not find requested object"]
-    }
-    ```
-- Invalid `id` URL parameter
-  - Status Code: `400`
-  - Request Body:
-    ```json
-    {
-        "code":600,
-        "errors":["could not process request"]
-    }
-    ```
-
-#### `GET /api/users/{id}/contexts`
-
-**Description:** Retrieves a list of contexts parsed from the provided kubeconfig. 
-
-**URL parameters:** 
-
-- `id` The user's ID. 
-
-**Query parameters:** N/A
-
-**Request Body**: N/A
-
-**Successful Response Body**: 
-
-```js
-[]Context{
-  "name": String,
-  "server": String,
-  "cluster": String,
-  "user": String,
-  "selected": Boolean,
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:** 
-- User not found
-  - Status Code: `404`
-  - Request Body:
-    ```json
-    {
-        "code":602,
-        "errors":["could not find requested object"]
-    }
-    ```
-- Invalid `id` URL parameter
-  - Status Code: `400`
-  - Request Body:
-    ```json
-    {
-        "code":600,
-        "errors":["could not process request"]
-    }
-    ```
-
-#### `POST /api/users`
-
-**Description:** Creates a new user with a given email and password.
-
-**URL parameters:** 
-
-- `id` The user's ID. 
-
-**Query parameters:** N/A
-
-**Request Body**: 
-
-```js
-{
-    "email": String,
-    "password": String,
-}
-```
-
-**Successful Response Body**:
-User object with only the id field. Other fields are empty - with values in parantheses.
-```js
-{
-  "id": Int,
-  "email": String ("")
-  "contexts": []String (NULL)
-  "rawKubeConfig": String ("")
-}
-```
-
-**Successful Status Code**: `201`
-
-**Errors:**
-
-- Invalid email (example: `{"email": "notanemail"}`)
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-        "code":601,
-        "errors":["email validation failed"]
-    }
-    ```
-
-- Missing field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-        "code":601,
-        "errors":["required validation failed"]
-    }`
-    ```
-
-- Email already taken 
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-        "code":601,
-        "errors":["email already taken"]
-    }
-    ```
-
-#### `POST /api/login`
-
-**Description:** Logs a user in via email and password.
-
-**URL parameters:** N/A
-
-**Query parameters:** N/A
-
-**Request Body**: 
-
-```js
-{
-    "email": String,
-    "password": String,
-}
-```
-
-**Successful Response Body**:
-User object with only the id field. Other fields are empty - with values in parantheses.
-```js
-{
-  "id": Int,
-  "email": String ("")
-  "contexts": []String (NULL)
-  "rawKubeConfig": String ("")
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:**
-
-- Email not registered
-  - Status Code: `401`
-  - Request Body:
-    ```json
-    {
-        "code": 401,
-        "errors": ["email not registered"]
-    }
-    ```
-
-- Incorrect password
-  - Status Code: `401`
-  - Request Body:
-    ```json
-    {
-        "code":401,
-        "errors":["incorrect password"]
-    }
-    ```
-
-#### `POST /api/logout`
-
-**Description:** Logs a user out by detaching a user from the cookie-based session. 
-
-**URL parameters:** N/A
-
-**Query parameters:** N/A
-
-**Request Body**: N/A
-
-**Successful Response Body**: N/A
-
-**Successful Status Code**: `200`
-
-**Errors:**
-
-- Not logged in
-  - Status Code: `403`
-  - Request Body:
-    ```sh
-    "Forbidden"
-    ```
-
-#### `PUT /api/users/{id}`
-
-**Description:** Updates an existing user.
-
-**URL parameters:** 
-
-- `id` The user's ID. 
-
-**Query parameters:** N/A
-
-**Request body:**
-
-```js
-{
-  "rawKubeConfig": String,
-  "allowedContexts": []String,
-}
-```
-
-**Successful Response Body**: N/A
-
-**Successful Status Code**: `204`
-
-**Errors:** 
-
-- Invalid `id` URL parameter
-  - Status Code: `400`
-  - Request Body:
-    ```json
-    {
-        "code":600,
-        "errors":["could not process request"]
-    }
-    ```
-
-#### `DELETE /api/users/{id}`
-
-**Description:** Deletes an existing user, requires the password to be sent before deletion. 
-
-**URL parameters:** 
-
-- `id` The user's ID. 
-
-**Query parameters:** N/A
-
-**Request body:**
-
-```js
-{
-    "password": String,
-}
-```
-
-**Successful Response Body**: N/A
-
-**Successful Status Code**: `204`
-
-**Errors:** 
-
-- Invalid `password`
-  - Status Code: `400`
-  - Request Body:
-    ```json
-    {
-        "code":601,
-        "errors":["invalid password"]
-    }
-    ```
-    
-- Missing field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-        "code":601,
-        "errors":["required validation failed"]
-    }`
-    ```
-
-- Invalid `id` URL parameter
-  - Status Code: `400`
-  - Request Body:
-    ```json
-    {
-        "code":600,
-        "errors":["could not process request"]
-    }
-    ```
-
-### `/api/releases`
-
-#### `GET /api/releases`
-
-**Description:** Gets a list of releases for a current context and a kubeconfig retrieved from the user's ID. 
-
-**URL parameters:** N/A
-
-**Query parameters:** 
-
-```js
-{
-  "namespace": String,
-  "context": String,
-  "storage": String("secret"|"configmap"|"memory"),
-  "limit": Number,
-  "skip": Number,
-  "byDate": Boolean,
-  "statusFilter": []String("unknown"|"deployed"|"uninstalled"|"superseded"|"failed"|"uninstalling"|"pending-install"|"pending-upgrade"|"pending-rollback")
-}
-```
-
-**Request Body**: N/A
-
-**Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
-
-```js
-[]Release{
-  // Name is the name of the release
-  "name": String,
-  "info": Info{
-    // LastDeployed is when the release was last deployed.
-    "last_deployed": String,
-    // Deleted tracks when this object was deleted.
-    "deleted": String,
-    // Description is human-friendly "log entry" about this release.
-    "description": String,
-    // Status is the current state of the release
-    "status": String("unknown"|"deployed"|"uninstalled"|"superseded"|"failed"|"uninstalling"|"pending-install"|"pending-upgrade"|"pending-rollback")
-  },
-  "chart": Chart{
-    "metadata": Metadata{
-      // The name of the chart
-      "name": String,
-      // The URL to a relevant project page, git repo, or contact person
-      "home": String,
-      // Sources is a list of URLs to the source code of this chart
-      "sources": []String,
-      // A SemVer 2 conformant version string of the chart
-      "version": String,
-      // A one-sentence description of the chart
-      "description": String,
-      // The URL to an icon file.
-      "icon": String,
-      // The API Version of this chart.
-      "apiVersion": String,
-    },
-    "templates": []File{
-      // Name is the path-like name of the template.
-      "name": String,
-      // Data is the template as byte data.
-      "data": String
-    },
-    // Values are default config for this chart.
-    "values": Map[String]{}
-  },
-  // The set of extra Values added to the release, which override the 
-  // default values inside of the chart
-  "config": Map[String]{},
-  // Manifest is the string representation of the rendered template
-  "manifest": String,
-  // Version is an int which represents the revision of the release.
-  "version": Number,
-  // Namespace is the kubernetes namespace of the release.
-  "namespace": String
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:** 
-
-- Missing required field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-      "code":601,
-      "errors":["required validation failed"]
-    }
-    ```
-
-#### `GET /api/releases/{name}/history`
-
-**Description:** Gets a history of revisions for a given deployed release based on the release `name`.
-
-**URL parameters:** 
-
-- `name` The name of the release.
-
-**Query parameters:** 
-
-```js
-{
-  // The namespace of the cluster to be used
-  "namespace": String,
-  // The name of the context in the kubeconfig being used
-  "context": String,
-  // The Helm storage option to use
-  "storage": String("secret"|"configmap"|"memory")
-}
-```
-
-**Request Body**: N/A
-
-**Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
-
-```js
-[]Release{
-  // Name is the name of the release
-  "name": String,
-  "info": Info{
-    // LastDeployed is when the release was last deployed.
-    "last_deployed": String,
-    // Deleted tracks when this object was deleted.
-    "deleted": String,
-    // Description is human-friendly "log entry" about this release.
-    "description": String,
-    // Status is the current state of the release
-    "status": String("unknown"|"deployed"|"uninstalled"|"superseded"|"failed"|"uninstalling"|"pending-install"|"pending-upgrade"|"pending-rollback")
-  },
-  "chart": Chart{
-    "metadata": Metadata{
-      // The name of the chart
-      "name": String,
-      // The URL to a relevant project page, git repo, or contact person
-      "home": String,
-      // Sources is a list of URLs to the source code of this chart
-      "sources": []String,
-      // A SemVer 2 conformant version string of the chart
-      "version": String,
-      // A one-sentence description of the chart
-      "description": String,
-      // The URL to an icon file.
-      "icon": String,
-      // The API Version of this chart.
-      "apiVersion": String,
-    },
-    "templates": []File{
-      // Name is the path-like name of the template.
-      "name": String,
-      // Data is the template as byte data.
-      "data": String
-    },
-    // Values are default config for this chart.
-    "values": Map[String]{}
-  },
-  // The set of extra Values added to the release, which override the 
-  // default values inside of the chart
-  "config": Map[String]{},
-  // Manifest is the string representation of the rendered template
-  "manifest": String,
-  // Version is an int which represents the revision of the release.
-  "version": Number,
-  // Namespace is the kubernetes namespace of the release.
-  "namespace": String
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:** 
-
-- Release not found
-  - Status Code: `404`
-  - Request Body:
-    ```json
-    {
-      "code":602,
-      "errors":["release not found"]
-    }
-    ```
-- Missing required field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-      "code":601,
-      "errors":["required validation failed"]
-    }
-    ```
-
-
-#### `GET /api/releases/{name}/{revision}`
-
-**Description:** Gets a single release for a current context and a kubeconfig retrieved from the user's ID based on a **name** and **revision**. To retrieve the latest deployed release, set **revision** to 0. 
-
-**URL parameters:** 
-
-- `name` The name of the release.
-- `revision` The number of the release (set to `0` for the latest deployed release).
-
-**Query parameters:** 
-
-```js
-{
-  // The namespace of the cluster to be used
-  "namespace": String,
-  // The name of the context in the kubeconfig being used
-  "context": String,
-  // The Helm storage option to use
-  "storage": String("secret"|"configmap"|"memory")
-}
-```
-
-**Request Body**: N/A
-
-**Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
-
-```js
-[]Release{
-  // Name is the name of the release
-  "name": String,
-  "info": Info{
-    // LastDeployed is when the release was last deployed.
-    "last_deployed": String,
-    // Deleted tracks when this object was deleted.
-    "deleted": String,
-    // Description is human-friendly "log entry" about this release.
-    "description": String,
-    // Status is the current state of the release
-    "status": String("unknown"|"deployed"|"uninstalled"|"superseded"|"failed"|"uninstalling"|"pending-install"|"pending-upgrade"|"pending-rollback")
-  },
-  "chart": Chart{
-    "metadata": Metadata{
-      // The name of the chart
-      "name": String,
-      // The URL to a relevant project page, git repo, or contact person
-      "home": String,
-      // Sources is a list of URLs to the source code of this chart
-      "sources": []String,
-      // A SemVer 2 conformant version string of the chart
-      "version": String,
-      // A one-sentence description of the chart
-      "description": String,
-      // The URL to an icon file.
-      "icon": String,
-      // The API Version of this chart.
-      "apiVersion": String,
-    },
-    "templates": []File{
-      // Name is the path-like name of the template.
-      "name": String,
-      // Data is the template as byte data.
-      "data": String
-    },
-    // Values are default config for this chart.
-    "values": Map[String]{}
-  },
-  // The set of extra Values added to the release, which override the 
-  // default values inside of the chart
-  "config": Map[String]{},
-  // Manifest is the string representation of the rendered template
-  "manifest": String,
-  // Version is an int which represents the revision of the release.
-  "version": Number,
-  // Namespace is the kubernetes namespace of the release.
-  "namespace": String
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:** 
-
-- Release not found
-  - Status Code: `404`
-  - Request Body:
-    ```json
-    {
-      "code":602,
-      "errors":["release not found"]
-    }
-    ```
-- Missing required field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-      "code":601,
-      "errors":["required validation failed"]
-    }
-    ```
-
-#### `POST /api/releases/{name}/rollback`
-
-**Description:** Rolls a release back to a specified revision. 
-
-**URL parameters:** 
-
-- `name` The name of the release.
-
-**Query parameters:** N/A
-
-**Request Body**:
-
-```js
-{
-  // The namespace of the cluster to be used
-  "namespace": String,
-  // The name of the context in the kubeconfig being used
-  "context": String,
-  // The Helm storage option to use
-  "storage": String("secret"|"configmap"|"memory"),
-  // The revision number of the desired rollback target
-  "revision": Number
-}
-```
-
-
-**Successful Response Body**: N/A
-
-**Successful Status Code**: `200`
-
-**Errors:**
-
-- Rollback failed
-  - Status Code: `500`
-  - Request Body:
-    ```json
-    {
-      "code":603,
-      "errors":["rollback failed: <error>"]
-    }
-    ```
-- Missing required field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-      "code":601,
-      "errors":["required validation failed"]
-    }
-    ```
-
-#### `POST /api/releases/{name}/upgrade`
-
-**Description:** Upgrades a release with new `values.yaml`. 
-
-**URL parameters:** 
-
-- `name` The name of the release.
-
-**Query parameters:** N/A
-
-**Request Body**:
-
-```js
-{
-  // The namespace of the cluster to be used
-  "namespace": String,
-  // The name of the context in the kubeconfig being used
-  "context": String,
-  // The Helm storage option to use
-  "storage": String("secret"|"configmap"|"memory"),
-  // The string of values to use
-  "values": String
-}
-```
-
-**Successful Response Body**: N/A
-
-**Successful Status Code**: `200`
-
-**Errors:** 
-
-- Upgrade failed
-  - Status Code: `500`
-  - Request Body:
-    ```json
-    {
-      "code":603,
-      "errors":["upgrade failed: <error>"]
-    }
-    ```
-- Missing required field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-      "code":601,
-      "errors":["required validation failed"]
-    }
-    ```
-
-### `/api/k8s`
-
-#### `GET /api/k8s/namespaces`
-
-**Description:** 
-
-**URL parameters:** N/A
-
-**Query parameters:** N/A
-
-```js
-// The name of the context in the kubeconfig being used
-"context": String,
-```
-
-**Request Body**: N/A
-
-**Successful Response Body**: the full body is determined by the [namespace specification](https://pkg.go.dev/k8s.io/api/core/v1#NamespaceList), but we're primarily only interested in namespace `name`:
-
-```js
-{
-  "metadata": {},
-  "items": []Namespace{
-    "metadata": {
-      "name": String
-    }
-  }
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:** TBD

+ 7 - 3
go.mod

@@ -3,6 +3,7 @@ module github.com/porter-dev/porter
 go 1.14
 go 1.14
 
 
 require (
 require (
+	cloud.google.com/go v0.65.0
 	github.com/Azure/go-autorest/autorest v0.11.1 // indirect
 	github.com/Azure/go-autorest/autorest v0.11.1 // indirect
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/Masterminds/semver v1.5.0 // indirect
 	github.com/Masterminds/semver v1.5.0 // indirect
@@ -15,7 +16,7 @@ require (
 	github.com/docker/go-connections v0.4.0
 	github.com/docker/go-connections v0.4.0
 	github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 // indirect
 	github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 // indirect
 	github.com/evanphx/json-patch v4.9.0+incompatible // indirect
 	github.com/evanphx/json-patch v4.9.0+incompatible // indirect
-	github.com/fatih/color v1.9.0 // indirect
+	github.com/fatih/color v1.9.0
 	github.com/go-chi/chi v4.1.2+incompatible
 	github.com/go-chi/chi v4.1.2+incompatible
 	github.com/go-chi/cors v1.1.1
 	github.com/go-chi/cors v1.1.1
 	github.com/go-playground/locales v0.13.0
 	github.com/go-playground/locales v0.13.0
@@ -23,6 +24,7 @@ require (
 	github.com/go-playground/validator/v10 v10.3.0
 	github.com/go-playground/validator/v10 v10.3.0
 	github.com/go-test/deep v1.0.7
 	github.com/go-test/deep v1.0.7
 	github.com/google/go-cmp v0.5.1
 	github.com/google/go-cmp v0.5.1
+	github.com/google/go-github v17.0.0+incompatible
 	github.com/google/go-github/v32 v32.1.0
 	github.com/google/go-github/v32 v32.1.0
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/sessions v1.2.1
 	github.com/gorilla/sessions v1.2.1
@@ -37,12 +39,14 @@ require (
 	github.com/rs/zerolog v1.20.0
 	github.com/rs/zerolog v1.20.0
 	github.com/sirupsen/logrus v1.7.0
 	github.com/sirupsen/logrus v1.7.0
 	github.com/spf13/cobra v1.0.0
 	github.com/spf13/cobra v1.0.0
+	github.com/spf13/viper v1.4.0
 	github.com/stretchr/testify v1.6.1
 	github.com/stretchr/testify v1.6.1
 	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
 	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
-	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 // indirect
+	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
 	golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6
 	golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
-	google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9 // indirect
+	google.golang.org/api v0.30.0
+	google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9
 	google.golang.org/grpc v1.33.0 // indirect
 	google.golang.org/grpc v1.33.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 	gopkg.in/go-playground/validator.v9 v9.31.0
 	gopkg.in/go-playground/validator.v9 v9.31.0

+ 13 - 0
go.sum

@@ -14,6 +14,7 @@ cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bP
 cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
 cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
 cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
 cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
 cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
 cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
+cloud.google.com/go v0.65.0 h1:Dg9iHVQfrhq82rUNu9ZxUDrJLaxFUe/HlCVaLyRruq8=
 cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
 cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
@@ -361,6 +362,7 @@ github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4er
 github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
 github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
 github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
@@ -400,6 +402,7 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.1 h1:JFrFEBb2xKufg6XkJsJr+WbKb4FQlURi5RUcBveYu9k=
 github.com/google/go-cmp v0.5.1 h1:JFrFEBb2xKufg6XkJsJr+WbKb4FQlURi5RUcBveYu9k=
 github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
 github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
+github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
 github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II=
 github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II=
 github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI=
 github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI=
 github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
 github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
@@ -423,6 +426,7 @@ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
 github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
 github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
 github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
 github.com/googleapis/gnostic v0.1.0 h1:rVsPeBmXbYv4If/cumu1AzZPwV58q433hvONV1UEZoI=
 github.com/googleapis/gnostic v0.1.0 h1:rVsPeBmXbYv4If/cumu1AzZPwV58q433hvONV1UEZoI=
@@ -471,6 +475,7 @@ github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
 github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
 github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
 github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
 github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
 github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
@@ -607,6 +612,7 @@ github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-b
 github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
 github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
 github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
 github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
 github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
 github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
+github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
 github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
 github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
 github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
 github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -658,6 +664,7 @@ github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUb
 github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
 github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
 github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
 github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
 github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A=
 github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A=
 github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
 github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
@@ -723,6 +730,7 @@ github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FI
 github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
 github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys=
 github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys=
+github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM=
 github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
 github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
 github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
 github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
 github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
 github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
@@ -810,6 +818,7 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k
 github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
 github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
 github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
 github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
 github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
@@ -819,6 +828,7 @@ github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3
 github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
 github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
 github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
 github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
 github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
 github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
+github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
@@ -827,6 +837,7 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
 github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
+github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
 github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
 github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
 github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
 github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
 github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
 github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
@@ -880,6 +891,7 @@ go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.4 h1:LYy1Hy3MJdrCdMwwzxA/dRok4ejH+RwNGbuoD9fCjto=
 go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
@@ -1162,6 +1174,7 @@ google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/
 google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
 google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
 google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
 google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
 google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
 google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
+google.golang.org/api v0.30.0 h1:yfrXXP61wVuLb0vBcG6qaOoIoqYEzOQS8jum51jkv2w=
 google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
 google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=

+ 6 - 2
internal/adapter/gorm.go

@@ -1,4 +1,4 @@
-package gorm
+package adapter
 
 
 import (
 import (
 	"fmt"
 	"fmt"
@@ -13,7 +13,11 @@ import (
 // New returns a new gorm database instance
 // New returns a new gorm database instance
 func New(conf *config.DBConf) (*gorm.DB, error) {
 func New(conf *config.DBConf) (*gorm.DB, error) {
 	if conf.SQLLite {
 	if conf.SQLLite {
-		return gorm.Open(sqlite.Open(conf.SQLLitePath), &gorm.Config{})
+		// we add DisableForeignKeyConstraintWhenMigrating since our sqlite does
+		// not support foreign key constraints
+		return gorm.Open(sqlite.Open(conf.SQLLitePath), &gorm.Config{
+			DisableForeignKeyConstraintWhenMigrating: true,
+		})
 	}
 	}
 
 
 	dsn := fmt.Sprintf(
 	dsn := fmt.Sprintf(

+ 7 - 0
internal/config/config.go

@@ -17,6 +17,7 @@ type Conf struct {
 
 
 // ServerConf is the server configuration
 // ServerConf is the server configuration
 type ServerConf struct {
 type ServerConf struct {
+	ServerURL      string        `env:"SERVER_URL,default=http://localhost:8080"`
 	Port           int           `env:"SERVER_PORT,default=8080"`
 	Port           int           `env:"SERVER_PORT,default=8080"`
 	StaticFilePath string        `env:"STATIC_FILE_PATH,default=/porter/static"`
 	StaticFilePath string        `env:"STATIC_FILE_PATH,default=/porter/static"`
 	CookieName     string        `env:"COOKIE_NAME,default=porter"`
 	CookieName     string        `env:"COOKIE_NAME,default=porter"`
@@ -24,11 +25,17 @@ type ServerConf struct {
 	TimeoutRead    time.Duration `env:"SERVER_TIMEOUT_READ,default=5s"`
 	TimeoutRead    time.Duration `env:"SERVER_TIMEOUT_READ,default=5s"`
 	TimeoutWrite   time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`
 	TimeoutWrite   time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`
 	TimeoutIdle    time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
 	TimeoutIdle    time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
+
+	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
+	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 }
 }
 
 
 // DBConf is the database configuration: if generated from environment variables,
 // DBConf is the database configuration: if generated from environment variables,
 // it assumes the default docker-compose configuration is used
 // it assumes the default docker-compose configuration is used
 type DBConf struct {
 type DBConf struct {
+	// EncryptionKey is the key to use for sensitive values that are encrypted at rest
+	EncryptionKey string `env:"ENCRYPTION_KEY,default=__random_strong_encryption_key__"`
+
 	Host     string `env:"DB_HOST,default=postgres"`
 	Host     string `env:"DB_HOST,default=postgres"`
 	Port     int    `env:"DB_PORT,default=5432"`
 	Port     int    `env:"DB_PORT,default=5432"`
 	Username string `env:"DB_USER,default=porter"`
 	Username string `env:"DB_USER,default=porter"`

+ 323 - 0
internal/forms/action.go

@@ -0,0 +1,323 @@
+package forms
+
+import (
+	"encoding/base64"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// ActionResolver exposes an interface for resolving an action as a ServiceAccount.
+// So that actions can be chained together, a pointer to a serviceAccount can be
+// used -- if this points to nil, a new service account is created
+type ActionResolver interface {
+	PopulateServiceAccount(repo repository.ServiceAccountRepository) error
+}
+
+// ServiceAccountActionResolver is the base type for resolving a ServiceAccountAction
+// that belongs to a given ServiceAccountCandidate
+type ServiceAccountActionResolver struct {
+	ServiceAccountCandidateID uint `json:"sa_candidate_id" form:"required"`
+	SA                        *models.ServiceAccount
+	SACandidate               *models.ServiceAccountCandidate
+}
+
+// PopulateServiceAccount will create a service account if it does not exist,
+// or will append a new cluster given by a ServiceAccountCandidate to the
+// ServiceAccount
+func (sar *ServiceAccountActionResolver) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	var err error
+	id := sar.ServiceAccountCandidateID
+
+	if sar.SACandidate == nil {
+		sar.SACandidate, err = repo.ReadServiceAccountCandidate(id)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	rawConf, err := kubernetes.GetRawConfigFromBytes(sar.SACandidate.Kubeconfig)
+
+	if err != nil {
+		return err
+	}
+
+	context := rawConf.Contexts[rawConf.CurrentContext]
+
+	authInfoName := context.AuthInfo
+	authInfo := rawConf.AuthInfos[authInfoName]
+
+	clusterName := context.Cluster
+	cluster := rawConf.Clusters[clusterName]
+
+	modelCluster := models.Cluster{
+		Name:                  clusterName,
+		LocationOfOrigin:      cluster.LocationOfOrigin,
+		Server:                cluster.Server,
+		TLSServerName:         cluster.TLSServerName,
+		InsecureSkipTLSVerify: cluster.InsecureSkipTLSVerify,
+	}
+
+	if len(cluster.CertificateAuthorityData) > 0 {
+		modelCluster.CertificateAuthorityData = cluster.CertificateAuthorityData
+	}
+
+	if sar.SA == nil {
+		sar.SA = &models.ServiceAccount{
+			ProjectID:         sar.SACandidate.ProjectID,
+			Kind:              sar.SACandidate.Kind,
+			Clusters:          []models.Cluster{modelCluster},
+			AuthMechanism:     sar.SACandidate.AuthMechanism,
+			LocationOfOrigin:  authInfo.LocationOfOrigin,
+			Impersonate:       authInfo.Impersonate,
+			ImpersonateGroups: strings.Join(authInfo.ImpersonateGroups, ","),
+		}
+	} else {
+		doesClusterExist := false
+
+		for _, cluster := range sar.SA.Clusters {
+			if cluster.Name == sar.SACandidate.ClusterName && cluster.Server == sar.SACandidate.ClusterEndpoint {
+				doesClusterExist = true
+			}
+		}
+
+		if !doesClusterExist {
+			sar.SA.Clusters = append(sar.SA.Clusters, modelCluster)
+		}
+	}
+
+	if len(authInfo.ClientCertificateData) > 0 {
+		sar.SA.ClientCertificateData = authInfo.ClientCertificateData
+	}
+
+	if len(authInfo.ClientKeyData) > 0 {
+		sar.SA.ClientKeyData = authInfo.ClientKeyData
+	}
+
+	if authInfo.Token != "" {
+		sar.SA.Token = authInfo.Token
+	}
+
+	if authInfo.Username != "" {
+		sar.SA.Username = authInfo.Username
+	}
+
+	if authInfo.Password != "" {
+		sar.SA.Password = authInfo.Password
+	}
+
+	if authInfo.AuthProvider != nil && authInfo.AuthProvider.Name == "oidc" {
+		if url, ok := authInfo.AuthProvider.Config["idp-issuer-url"]; ok {
+			sar.SA.OIDCIssuerURL = url
+		}
+
+		if clientID, ok := authInfo.AuthProvider.Config["client-id"]; ok {
+			sar.SA.OIDCClientID = clientID
+		}
+
+		if clientSecret, ok := authInfo.AuthProvider.Config["client-secret"]; ok {
+			sar.SA.OIDCClientSecret = clientSecret
+		}
+
+		if caData, ok := authInfo.AuthProvider.Config["idp-certificate-authority-data"]; ok {
+			// based on the implementation, the oidc plugin expects the data to be base64 encoded,
+			// which means we will not decode it here
+			// reference: https://github.com/kubernetes/kubernetes/blob/9dfb4c876bfca7a5ae84259fae2bc337ed90c2d7/staging/src/k8s.io/client-go/plugin/pkg/client/auth/oidc/oidc.go#L135
+			sar.SA.OIDCCertificateAuthorityData = caData
+		}
+
+		if idToken, ok := authInfo.AuthProvider.Config["id-token"]; ok {
+			sar.SA.OIDCIDToken = idToken
+		}
+
+		if refreshToken, ok := authInfo.AuthProvider.Config["refresh-token"]; ok {
+			sar.SA.OIDCRefreshToken = refreshToken
+		}
+	}
+
+	return nil
+}
+
+// ClusterCADataAction contains the base64 encoded cluster CA data
+type ClusterCADataAction struct {
+	*ServiceAccountActionResolver
+	ClusterCAData string `json:"cluster_ca_data" form:"required"`
+}
+
+// PopulateServiceAccount will add cluster ca data to a cluster in the ServiceAccount's
+// list of clusters
+func (cda *ClusterCADataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := cda.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	saCandidate := cda.ServiceAccountActionResolver.SACandidate
+
+	for i, cluster := range cda.ServiceAccountActionResolver.SA.Clusters {
+		if cluster.Name == saCandidate.ClusterName && cluster.Server == saCandidate.ClusterEndpoint {
+			decoded, err := base64.StdEncoding.DecodeString(cda.ClusterCAData)
+
+			// skip if decoding error
+			if err != nil {
+				return err
+			}
+
+			(&cluster).CertificateAuthorityData = decoded
+			cda.ServiceAccountActionResolver.SA.Clusters[i] = cluster
+		}
+	}
+
+	return nil
+}
+
+// ClientCertDataAction contains the base64 encoded cluster cert data
+type ClientCertDataAction struct {
+	*ServiceAccountActionResolver
+	ClientCertData string `json:"client_cert_data" form:"required"`
+}
+
+// PopulateServiceAccount will add client CA data to a ServiceAccount
+func (ccda *ClientCertDataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := ccda.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(ccda.ClientCertData)
+
+	// skip if decoding error
+	if err != nil {
+		return err
+	}
+
+	ccda.ServiceAccountActionResolver.SA.ClientCertificateData = decoded
+
+	return nil
+}
+
+// ClientKeyDataAction contains the base64 encoded cluster key data
+type ClientKeyDataAction struct {
+	*ServiceAccountActionResolver
+	ClientKeyData string `json:"client_key_data" form:"required"`
+}
+
+// PopulateServiceAccount will add client CA data to a ServiceAccount
+func (ckda *ClientKeyDataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := ckda.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(ckda.ClientKeyData)
+
+	// skip if decoding error
+	if err != nil {
+		return err
+	}
+
+	ckda.ServiceAccountActionResolver.SA.ClientKeyData = decoded
+
+	return nil
+}
+
+// OIDCIssuerDataAction contains the base64 encoded IDP issuer CA data
+type OIDCIssuerDataAction struct {
+	*ServiceAccountActionResolver
+	OIDCIssuerCAData string `json:"oidc_idp_issuer_ca_data" form:"required"`
+}
+
+// PopulateServiceAccount will add OIDC issuer CA data to a ServiceAccount
+func (oida *OIDCIssuerDataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := oida.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	// based on the implementation, the oidc plugin expects the data to be base64 encoded,
+	// which means we will not decode it here
+	// reference: https://github.com/kubernetes/kubernetes/blob/9dfb4c876bfca7a5ae84259fae2bc337ed90c2d7/staging/src/k8s.io/client-go/plugin/pkg/client/auth/oidc/oidc.go#L135
+	oida.ServiceAccountActionResolver.SA.OIDCCertificateAuthorityData = oida.OIDCIssuerCAData
+
+	return nil
+}
+
+// TokenDataAction contains the token data to use
+type TokenDataAction struct {
+	*ServiceAccountActionResolver
+	TokenData string `json:"token_data" form:"required"`
+}
+
+// PopulateServiceAccount will add bearer token data to a ServiceAccount
+func (tda *TokenDataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := tda.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	tda.ServiceAccountActionResolver.SA.Token = tda.TokenData
+
+	return nil
+}
+
+// GCPKeyDataAction contains the GCP key data
+type GCPKeyDataAction struct {
+	*ServiceAccountActionResolver
+	GCPKeyData string `json:"gcp_key_data" form:"required"`
+}
+
+// PopulateServiceAccount will add GCP key data to a ServiceAccount
+func (gkda *GCPKeyDataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := gkda.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	gkda.ServiceAccountActionResolver.SA.KeyData = []byte(gkda.GCPKeyData)
+
+	return nil
+}
+
+// AWSKeyDataAction contains the AWS key data
+type AWSKeyDataAction struct {
+	*ServiceAccountActionResolver
+	AWSKeyData string `json:"aws_key_data" form:"required"`
+}
+
+// PopulateServiceAccount will add GCP key data to a ServiceAccount
+func (akda *AWSKeyDataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := akda.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	akda.ServiceAccountActionResolver.SA.KeyData = []byte(akda.AWSKeyData)
+
+	return nil
+}

+ 677 - 0
internal/forms/action_test.go

@@ -0,0 +1,677 @@
+package forms_test
+
+import (
+	"encoding/base64"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository/test"
+)
+
+func TestPopulateServiceAccountBasic(t *testing.T) {
+	// create the in-memory repository
+	repo := test.NewRepository(true)
+
+	// create a new project
+	repo.Project.CreateProject(&models.Project{
+		Name: "test-project",
+	})
+
+	// create a ServiceAccountCandidate from a kubeconfig
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClusterCAWithData))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.ServiceAccountActionResolver{
+		ServiceAccountCandidateID: 1,
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(form.SA)
+	decodedStr, _ := base64.StdEncoding.DecodeString("LS0tLS1CRUdJTiBDRVJ=")
+
+	if len(sa.Clusters) != 1 {
+		t.Fatalf("cluster not written\n")
+	}
+
+	if sa.Clusters[0].ServiceAccountID != 1 {
+		t.Errorf("service account ID of joined cluster is not 1")
+	}
+
+	if string(sa.Clusters[0].CertificateAuthorityData) != string(decodedStr) {
+		t.Errorf("cluster ca data and input do not match: expected %s, got %s\n",
+			string(sa.Clusters[0].CertificateAuthorityData), string(decodedStr))
+	}
+
+	if sa.AuthMechanism != "x509" {
+		t.Errorf("service account auth mechanism is not x509")
+	}
+
+	if string(sa.ClientCertificateData) != string(decodedStr) {
+		t.Errorf("service account cert data and input do not match: expected %s, got %s\n",
+			string(sa.ClientCertificateData), string(decodedStr))
+	}
+
+	if string(sa.ClientKeyData) != string(decodedStr) {
+		t.Errorf("service account key data and input do not match: expected %s, got %s\n",
+			string(sa.ClientKeyData), string(decodedStr))
+	}
+}
+
+func TestPopulateServiceAccountClusterDataAction(t *testing.T) {
+	// create the in-memory repository
+	repo := test.NewRepository(true)
+
+	// create a new project
+	repo.Project.CreateProject(&models.Project{
+		Name: "test-project",
+	})
+
+	// create a ServiceAccountCandidate from a kubeconfig
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClusterCAWithoutData))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.ClusterCADataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		ClusterCAData: "LS0tLS1CRUdJTiBDRVJ=",
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
+	decodedStr, _ := base64.StdEncoding.DecodeString("LS0tLS1CRUdJTiBDRVJ=")
+
+	if len(sa.Clusters) != 1 {
+		t.Fatalf("cluster not written\n")
+	}
+
+	if sa.Clusters[0].ServiceAccountID != 1 {
+		t.Errorf("service account ID of joined cluster is not 1")
+	}
+
+	if string(sa.Clusters[0].CertificateAuthorityData) != string(decodedStr) {
+		t.Errorf("cluster ca data and input do not match: expected %s, got %s\n",
+			string(sa.Clusters[0].CertificateAuthorityData), string(decodedStr))
+	}
+
+	if sa.AuthMechanism != "x509" {
+		t.Errorf("service account auth mechanism is not x509")
+	}
+
+	if string(sa.ClientCertificateData) != string(decodedStr) {
+		t.Errorf("service account cert data and input do not match: expected %s, got %s\n",
+			string(sa.ClientCertificateData), string(decodedStr))
+	}
+
+	if string(sa.ClientKeyData) != string(decodedStr) {
+		t.Errorf("service account key data and input do not match: expected %s, got %s\n",
+			string(sa.ClientKeyData), string(decodedStr))
+	}
+}
+
+func TestPopulateServiceAccountClientCertAction(t *testing.T) {
+	// create the in-memory repository
+	repo := test.NewRepository(true)
+
+	// create a new project
+	repo.Project.CreateProject(&models.Project{
+		Name: "test-project",
+	})
+
+	// create a ServiceAccountCandidate from a kubeconfig
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClientWithoutCertData))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.ClientCertDataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		ClientCertData: "LS0tLS1CRUdJTiBDRVJ=",
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
+	decodedStr, _ := base64.StdEncoding.DecodeString("LS0tLS1CRUdJTiBDRVJ=")
+
+	if len(sa.Clusters) != 1 {
+		t.Fatalf("cluster not written\n")
+	}
+
+	if sa.Clusters[0].ServiceAccountID != 1 {
+		t.Errorf("service account ID of joined cluster is not 1")
+	}
+
+	if string(sa.Clusters[0].CertificateAuthorityData) != string(decodedStr) {
+		t.Errorf("cluster ca data and input do not match: expected %s, got %s\n",
+			string(sa.Clusters[0].CertificateAuthorityData), string(decodedStr))
+	}
+
+	if sa.AuthMechanism != "x509" {
+		t.Errorf("service account auth mechanism is not x509")
+	}
+
+	if string(sa.ClientCertificateData) != string(decodedStr) {
+		t.Errorf("service account cert data and input do not match: expected %s, got %s\n",
+			string(sa.ClientCertificateData), string(decodedStr))
+	}
+
+	if string(sa.ClientKeyData) != string(decodedStr) {
+		t.Errorf("service account key data and input do not match: expected %s, got %s\n",
+			string(sa.ClientKeyData), string(decodedStr))
+	}
+}
+
+func TestPopulateServiceAccountClientCertAndKeyActions(t *testing.T) {
+	// create the in-memory repository
+	repo := test.NewRepository(true)
+
+	// create a new project
+	repo.Project.CreateProject(&models.Project{
+		Name: "test-project",
+	})
+
+	// create a ServiceAccountCandidate from a kubeconfig
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClientWithoutCertAndKeyData))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.ClientCertDataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		ClientCertData: "LS0tLS1CRUdJTiBDRVJ=",
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	keyForm := forms.ClientKeyDataAction{
+		ServiceAccountActionResolver: form.ServiceAccountActionResolver,
+		ClientKeyData:                "LS0tLS1CRUdJTiBDRVJ=",
+	}
+
+	err = keyForm.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(keyForm.ServiceAccountActionResolver.SA)
+	decodedStr, _ := base64.StdEncoding.DecodeString("LS0tLS1CRUdJTiBDRVJ=")
+
+	if len(sa.Clusters) != 1 {
+		t.Fatalf("cluster not written\n")
+	}
+
+	if sa.Clusters[0].ServiceAccountID != 1 {
+		t.Errorf("service account ID of joined cluster is not 1")
+	}
+
+	if string(sa.Clusters[0].CertificateAuthorityData) != string(decodedStr) {
+		t.Errorf("cluster ca data and input do not match: expected %s, got %s\n",
+			string(sa.Clusters[0].CertificateAuthorityData), string(decodedStr))
+	}
+
+	if sa.AuthMechanism != "x509" {
+		t.Errorf("service account auth mechanism is not x509")
+	}
+
+	if string(sa.ClientCertificateData) != string(decodedStr) {
+		t.Errorf("service account cert data and input do not match: expected %s, got %s\n",
+			string(sa.ClientCertificateData), string(decodedStr))
+	}
+
+	if string(sa.ClientKeyData) != string(decodedStr) {
+		t.Errorf("service account cert data and input do not match: expected %s, got %s\n",
+			string(sa.ClientKeyData), string(decodedStr))
+	}
+}
+
+func TestPopulateServiceAccountTokenDataAction(t *testing.T) {
+	// create the in-memory repository
+	repo := test.NewRepository(true)
+	tokenData := "abcdefghijklmnop"
+
+	// create a new project
+	repo.Project.CreateProject(&models.Project{
+		Name: "test-project",
+	})
+
+	// create a ServiceAccountCandidate from a kubeconfig
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(BearerTokenWithoutData))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.TokenDataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		TokenData: tokenData,
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
+
+	if len(sa.Clusters) != 1 {
+		t.Fatalf("cluster not written\n")
+	}
+
+	if sa.Clusters[0].ServiceAccountID != 1 {
+		t.Errorf("service account ID of joined cluster is not 1")
+	}
+
+	if sa.AuthMechanism != models.Bearer {
+		t.Errorf("service account auth mechanism is not %s\n", models.Bearer)
+	}
+
+	if sa.Token != tokenData {
+		t.Errorf("service account token data is wrong: expected %s, got %s\n",
+			tokenData, sa.Token)
+	}
+}
+
+func TestPopulateServiceAccountGCPKeyDataAction(t *testing.T) {
+	// create the in-memory repository
+	repo := test.NewRepository(true)
+	gcpKeyData := []byte(`{"key": "data"}`)
+
+	// create a new project
+	repo.Project.CreateProject(&models.Project{
+		Name: "test-project",
+	})
+
+	// create a ServiceAccountCandidate from a kubeconfig
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(GCPPlugin))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.GCPKeyDataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		GCPKeyData: string(gcpKeyData),
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
+
+	if len(sa.Clusters) != 1 {
+		t.Fatalf("cluster not written\n")
+	}
+
+	if sa.Clusters[0].ServiceAccountID != 1 {
+		t.Errorf("service account ID of joined cluster is not 1")
+	}
+
+	if sa.AuthMechanism != models.GCP {
+		t.Errorf("service account auth mechanism is not %s\n", models.GCP)
+	}
+
+	if string(sa.KeyData) != string(gcpKeyData) {
+		t.Errorf("service account token data is wrong: expected %s, got %s\n",
+			string(sa.KeyData), string(gcpKeyData))
+	}
+}
+
+func TestPopulateServiceAccountAWSKeyDataAction(t *testing.T) {
+	// create the in-memory repository
+	repo := test.NewRepository(true)
+	awsKeyData := []byte(`{"key": "data"}`)
+
+	// create a new project
+	repo.Project.CreateProject(&models.Project{
+		Name: "test-project",
+	})
+
+	// create a ServiceAccountCandidate from a kubeconfig
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(AWSEKSGetTokenExec))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.AWSKeyDataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		AWSKeyData: string(awsKeyData),
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
+
+	if len(sa.Clusters) != 1 {
+		t.Fatalf("cluster not written\n")
+	}
+
+	if sa.Clusters[0].ServiceAccountID != 1 {
+		t.Errorf("service account ID of joined cluster is not 1")
+	}
+
+	if sa.AuthMechanism != models.AWS {
+		t.Errorf("service account auth mechanism is not %s\n", models.AWS)
+	}
+
+	if string(sa.KeyData) != string(awsKeyData) {
+		t.Errorf("service account token data is wrong: expected %s, got %s\n",
+			string(sa.KeyData), string(awsKeyData))
+	}
+}
+
+func TestPopulateServiceAccountOIDCAction(t *testing.T) {
+	// create the in-memory repository
+	repo := test.NewRepository(true)
+
+	// create a new project
+	repo.Project.CreateProject(&models.Project{
+		Name: "test-project",
+	})
+
+	// create a ServiceAccountCandidate from a kubeconfig
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(OIDCAuthWithoutData))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.OIDCIssuerDataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
+
+	if len(sa.Clusters) != 1 {
+		t.Fatalf("cluster not written\n")
+	}
+
+	if sa.Clusters[0].ServiceAccountID != 1 {
+		t.Errorf("service account ID of joined cluster is not 1")
+	}
+
+	if sa.AuthMechanism != models.OIDC {
+		t.Errorf("service account auth mechanism is not %s\n", models.OIDC)
+	}
+
+	if string(sa.OIDCCertificateAuthorityData) != "LS0tLS1CRUdJTiBDRVJ=" {
+		t.Errorf("service account key data and input do not match: expected %s, got %s\n",
+			string(sa.OIDCCertificateAuthorityData), "LS0tLS1CRUdJTiBDRVJ=")
+	}
+}
+
+const ClusterCAWithData string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate-data: LS0tLS1CRUdJTiBDRVJ=
+    client-key-data: LS0tLS1CRUdJTiBDRVJ=
+current-context: context-test
+`
+
+const ClusterCAWithoutData string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority: /fake/path/to/ca.pem
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate-data: LS0tLS1CRUdJTiBDRVJ=
+    client-key-data: LS0tLS1CRUdJTiBDRVJ=
+current-context: context-test
+`
+
+const ClientWithoutCertData string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate: /fake/path/to/ca.pem
+    client-key-data: LS0tLS1CRUdJTiBDRVJ=
+current-context: context-test
+`
+
+const ClientWithoutCertAndKeyData string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate: /fake/path/to/ca.pem
+    client-key: /fake/path/to/ca.pem
+current-context: context-test
+`
+
+const BearerTokenWithoutData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    tokenFile: /path/to/token/file.txt
+`
+const GCPPlugin string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+users:
+- name: test-admin
+  user:
+    auth-provider:
+      name: gcp
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+`
+
+const AWSEKSGetTokenExec string = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    exec:
+      apiVersion: client.authentication.k8s.io/v1alpha1
+      command: aws
+      args:
+        - "eks"
+        - "get-token"
+        - "--cluster-name"
+        - "cluster-test"
+`
+
+const OIDCAuthWithoutData string = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    auth-provider:
+      config:
+        client-id: porter-api
+        id-token: token
+        idp-issuer-url: https://localhost
+        idp-certificate-authority: /fake/path/to/ca.pem
+      name: oidc
+`

+ 29 - 0
internal/forms/candidate.go

@@ -0,0 +1,29 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateServiceAccountCandidatesForm represents the accepted values for
+// creating a list of ServiceAccountCandidates from a kubeconfig
+type CreateServiceAccountCandidatesForm struct {
+	ProjectID  uint   `json:"project_id"`
+	Kubeconfig string `json:"kubeconfig"`
+}
+
+// ToServiceAccountCandidates creates a ServiceAccountCandidate from the kubeconfig and
+// project id
+func (csa *CreateServiceAccountCandidatesForm) ToServiceAccountCandidates() ([]*models.ServiceAccountCandidate, error) {
+	candidates, err := kubernetes.GetServiceAccountCandidates([]byte(csa.Kubeconfig))
+
+	if err != nil {
+		return nil, err
+	}
+
+	for _, saCandidate := range candidates {
+		saCandidate.ProjectID = csa.ProjectID
+	}
+
+	return candidates, nil
+}

+ 26 - 12
internal/forms/k8s.go

@@ -2,6 +2,7 @@ package forms
 
 
 import (
 import (
 	"net/url"
 	"net/url"
+	"strconv"
 
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
@@ -14,22 +15,35 @@ type K8sForm struct {
 
 
 // PopulateK8sOptionsFromQueryParams populates fields in the ReleaseForm using the passed
 // PopulateK8sOptionsFromQueryParams populates fields in the ReleaseForm using the passed
 // url.Values (the parsed query params)
 // url.Values (the parsed query params)
-func (kf *K8sForm) PopulateK8sOptionsFromQueryParams(vals url.Values) {
-	if context, ok := vals["context"]; ok && len(context) == 1 {
-		kf.Context = context[0]
+func (kf *K8sForm) PopulateK8sOptionsFromQueryParams(
+	vals url.Values,
+	repo repository.ServiceAccountRepository,
+) error {
+	if clusterID, ok := vals["cluster_id"]; ok && len(clusterID) == 1 {
+		id, err := strconv.ParseUint(clusterID[0], 10, 64)
+
+		if err != nil {
+			return err
+		}
+
+		kf.ClusterID = uint(id)
 	}
 	}
-}
 
 
-// PopulateK8sOptionsFromUserID uses the passed userID to populate the HelmOptions object
-func (kf *K8sForm) PopulateK8sOptionsFromUserID(userID uint, repo repository.UserRepository) error {
-	user, err := repo.ReadUser(userID)
+	if serviceAccountID, ok := vals["service_account_id"]; ok && len(serviceAccountID) == 1 {
+		id, err := strconv.ParseUint(serviceAccountID[0], 10, 64)
 
 
-	if err != nil {
-		return err
-	}
+		if err != nil {
+			return err
+		}
+
+		sa, err := repo.ReadServiceAccount(uint(id))
 
 
-	kf.AllowedContexts = user.ContextToSlice()
-	kf.KubeConfig = user.RawKubeConfig
+		if err != nil {
+			return err
+		}
+
+		kf.ServiceAccount = sa
+	}
 
 
 	return nil
 	return nil
 }
 }

+ 43 - 0
internal/forms/project.go

@@ -0,0 +1,43 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// WriteProjectForm is a generic form for write operations to the Project model
+type WriteProjectForm interface {
+	ToProject(repo repository.ProjectRepository) (*models.Project, error)
+}
+
+// CreateProjectForm represents the accepted values for creating a project
+type CreateProjectForm struct {
+	WriteProjectForm
+	Name string `json:"name" form:"required"`
+}
+
+// ToProject converts the project to a gorm project model
+func (cpf *CreateProjectForm) ToProject(_ repository.ProjectRepository) (*models.Project, error) {
+	return &models.Project{
+		Name: cpf.Name,
+	}, nil
+}
+
+// CreateProjectRoleForm represents the accepted values for creating a project
+// role
+type CreateProjectRoleForm struct {
+	WriteProjectForm
+	ID    uint          `json:"project_id" form:"required"`
+	Roles []models.Role `json:"roles"`
+}
+
+// ToProject converts the form to a gorm project model
+func (cprf *CreateProjectRoleForm) ToProject(_ repository.ProjectRepository) (*models.Project, error) {
+	return &models.Project{
+		Model: gorm.Model{
+			ID: cprf.ID,
+		},
+		Roles: cprf.Roles,
+	}, nil
+}

+ 34 - 16
internal/forms/release.go

@@ -15,9 +15,34 @@ type ReleaseForm struct {
 
 
 // PopulateHelmOptionsFromQueryParams populates fields in the ReleaseForm using the passed
 // PopulateHelmOptionsFromQueryParams populates fields in the ReleaseForm using the passed
 // url.Values (the parsed query params)
 // url.Values (the parsed query params)
-func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(vals url.Values) {
-	if context, ok := vals["context"]; ok && len(context) == 1 {
-		rf.Context = context[0]
+func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(
+	vals url.Values,
+	repo repository.ServiceAccountRepository,
+) error {
+	if clusterID, ok := vals["cluster_id"]; ok && len(clusterID) == 1 {
+		id, err := strconv.ParseUint(clusterID[0], 10, 64)
+
+		if err != nil {
+			return err
+		}
+
+		rf.ClusterID = uint(id)
+	}
+
+	if serviceAccountID, ok := vals["service_account_id"]; ok && len(serviceAccountID) == 1 {
+		id, err := strconv.ParseUint(serviceAccountID[0], 10, 64)
+
+		if err != nil {
+			return err
+		}
+
+		sa, err := repo.ReadServiceAccount(uint(id))
+
+		if err != nil {
+			return err
+		}
+
+		rf.ServiceAccount = sa
 	}
 	}
 
 
 	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
 	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
@@ -27,18 +52,6 @@ func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(vals url.Values) {
 	if storage, ok := vals["storage"]; ok && len(storage) == 1 {
 	if storage, ok := vals["storage"]; ok && len(storage) == 1 {
 		rf.Storage = storage[0]
 		rf.Storage = storage[0]
 	}
 	}
-}
-
-// PopulateHelmOptionsFromUserID uses the passed user ID to populate the HelmOptions object
-func (rf *ReleaseForm) PopulateHelmOptionsFromUserID(userID uint, repo repository.UserRepository) error {
-	user, err := repo.ReadUser(userID)
-
-	if err != nil {
-		return err
-	}
-
-	rf.AllowedContexts = user.ContextToSlice()
-	rf.KubeConfig = user.RawKubeConfig
 
 
 	return nil
 	return nil
 }
 }
@@ -51,7 +64,10 @@ type ListReleaseForm struct {
 
 
 // PopulateListFromQueryParams populates fields in the ListReleaseForm using the passed
 // PopulateListFromQueryParams populates fields in the ListReleaseForm using the passed
 // url.Values (the parsed query params)
 // url.Values (the parsed query params)
-func (lrf *ListReleaseForm) PopulateListFromQueryParams(vals url.Values) {
+func (lrf *ListReleaseForm) PopulateListFromQueryParams(
+	vals url.Values,
+	_ repository.ServiceAccountRepository,
+) error {
 	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
 	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
 		lrf.ListFilter.Namespace = namespace[0]
 		lrf.ListFilter.Namespace = namespace[0]
 	}
 	}
@@ -77,6 +93,8 @@ func (lrf *ListReleaseForm) PopulateListFromQueryParams(vals url.Values) {
 	if statusFilter, ok := vals["statusFilter"]; ok {
 	if statusFilter, ok := vals["statusFilter"]; ok {
 		lrf.ListFilter.StatusFilter = statusFilter
 		lrf.ListFilter.StatusFilter = statusFilter
 	}
 	}
+
+	return nil
 }
 }
 
 
 // GetReleaseForm represents the accepted values for getting a single Helm release
 // GetReleaseForm represents the accepted values for getting a single Helm release

+ 0 - 61
internal/forms/user.go

@@ -1,9 +1,6 @@
 package forms
 package forms
 
 
 import (
 import (
-	"strings"
-
-	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/crypto/bcrypt"
@@ -58,64 +55,6 @@ func (luf *LoginUserForm) ToUser(_ repository.UserRepository) (*models.User, err
 	}, nil
 	}, nil
 }
 }
 
 
-// UpdateUserForm represents the accepted values for updating a user
-//
-// ID is a query parameter, the other two are sent in JSON body
-type UpdateUserForm struct {
-	WriteUserForm
-	ID              uint     `form:"required"`
-	RawKubeConfig   string   `json:"rawKubeConfig,omitempty"`
-	AllowedContexts []string `json:"allowedContexts"`
-}
-
-// ToUser converts an UpdateUserForm to models.User by parsing the kubeconfig
-// and the allowed clusters to generate a list of ClusterConfigs.
-func (uuf *UpdateUserForm) ToUser(repo repository.UserRepository) (*models.User, error) {
-	rawBytes := []byte(uuf.RawKubeConfig)
-	contexts := uuf.AllowedContexts
-
-	savedUser, err := repo.ReadUser(uuf.ID)
-
-	if err != nil {
-		return nil, err
-	}
-
-	// if the rawKubeConfig is empty, query the DB for a non-empty one
-	if uuf.RawKubeConfig == "" {
-		rawBytes = savedUser.RawKubeConfig
-	}
-
-	// if the allowedContexts is nil, query the DB for a non-nil one
-	if uuf.AllowedContexts == nil {
-		contexts = savedUser.ContextToSlice()
-	}
-
-	if len(rawBytes) > 0 {
-		// validate the kubeconfig
-		_contexts, err := kubernetes.GetContextsFromBytes(rawBytes, contexts)
-
-		if err != nil {
-			return nil, err
-		}
-
-		contexts = make([]string, 0)
-
-		// ensure only joined contexts get written
-		for _, context := range _contexts {
-			if context.Selected {
-				contexts = append(contexts, context.Name)
-			}
-		}
-	}
-
-	contextsJoin := strings.Join(contexts, ",")
-
-	savedUser.Contexts = contextsJoin
-	savedUser.RawKubeConfig = rawBytes
-
-	return savedUser, nil
-}
-
 // DeleteUserForm represents the accepted values for deleting a user
 // DeleteUserForm represents the accepted values for deleting a user
 type DeleteUserForm struct {
 type DeleteUserForm struct {
 	WriteUserForm
 	WriteUserForm

+ 7 - 8
internal/helm/config.go

@@ -6,6 +6,7 @@ import (
 
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/internal/models"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chartutil"
 	"helm.sh/helm/v3/pkg/chartutil"
 	"helm.sh/helm/v3/pkg/kube"
 	"helm.sh/helm/v3/pkg/kube"
@@ -17,11 +18,10 @@ import (
 // Form represents the options for connecting to a cluster and
 // Form represents the options for connecting to a cluster and
 // creating a Helm agent
 // creating a Helm agent
 type Form struct {
 type Form struct {
-	KubeConfig      []byte
-	AllowedContexts []string
-	Context         string `json:"context" form:"required"`
-	Storage         string `json:"storage" form:"oneof=secret configmap memory"`
-	Namespace       string `json:"namespace"`
+	ServiceAccount *models.ServiceAccount `form:"required"`
+	ClusterID      uint                   `json:"cluster_id" form:"required"`
+	Storage        string                 `json:"storage" form:"oneof=secret configmap memory"`
+	Namespace      string                 `json:"namespace"`
 }
 }
 
 
 // GetAgentOutOfClusterConfig creates a new Agent from outside the cluster using
 // GetAgentOutOfClusterConfig creates a new Agent from outside the cluster using
@@ -29,9 +29,8 @@ type Form struct {
 func GetAgentOutOfClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 func GetAgentOutOfClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	// create a kubernetes agent
 	// create a kubernetes agent
 	conf := &kubernetes.OutOfClusterConfig{
 	conf := &kubernetes.OutOfClusterConfig{
-		KubeConfig:      form.KubeConfig,
-		AllowedContexts: form.AllowedContexts,
-		Context:         form.Context,
+		ServiceAccount: form.ServiceAccount,
+		ClusterID:      form.ClusterID,
 	}
 	}
 
 
 	k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(conf)
 	k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(conf)

+ 25 - 9
internal/kubernetes/config.go

@@ -6,6 +6,7 @@ import (
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
+	"github.com/porter-dev/porter/internal/models"
 	"k8s.io/apimachinery/pkg/api/meta"
 	"k8s.io/apimachinery/pkg/api/meta"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
@@ -59,13 +60,13 @@ func GetAgentTesting(objects ...runtime.Object) *Agent {
 // OutOfClusterConfig is the set of parameters required for an out-of-cluster connection.
 // OutOfClusterConfig is the set of parameters required for an out-of-cluster connection.
 // This implements RESTClientGetter
 // This implements RESTClientGetter
 type OutOfClusterConfig struct {
 type OutOfClusterConfig struct {
-	KubeConfig      []byte
-	AllowedContexts []string
-	Context         string `json:"context" form:"required"`
+	ServiceAccount *models.ServiceAccount `form:"required"`
+	ClusterID      uint                   `json:"cluster_id" form:"required"`
 }
 }
 
 
-// ToRESTConfig creates a kubernetes REST client factory -- it simply calls ClientConfig on
-// the result of ToRawKubeConfigLoader
+// ToRESTConfig creates a kubernetes REST client factory -- it calls ClientConfig on
+// the result of ToRawKubeConfigLoader, and also adds a custom http transport layer
+// if necessary (required for GCP auth)
 func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
 func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
 	restConf, err := conf.ToRawKubeConfigLoader().ClientConfig()
 	restConf, err := conf.ToRawKubeConfigLoader().ClientConfig()
 
 
@@ -73,6 +74,22 @@ func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	// if conf.ServiceAccount.AuthMechanism == models.GCP {
+	// 	creds, err := google.CredentialsFromJSON(
+	// 		context.Background(),
+	// 		conf.ServiceAccount.KeyData,
+	// 		"https://www.googleapis.com/auth/cloud-platform",
+	// 	)
+
+	// 	if err != nil {
+	// 		return nil, err
+	// 	}
+
+	// 	restConf.Transport = &oauth2.Transport{
+	// 		Source: creds.TokenSource,
+	// 	}
+	// }
+
 	rest.SetKubernetesDefaults(restConf)
 	rest.SetKubernetesDefaults(restConf)
 	return restConf, nil
 	return restConf, nil
 }
 }
@@ -80,10 +97,9 @@ func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
 // ToRawKubeConfigLoader creates a clientcmd.ClientConfig from the raw kubeconfig found in
 // ToRawKubeConfigLoader creates a clientcmd.ClientConfig from the raw kubeconfig found in
 // the OutOfClusterConfig. It does not implement loading rules or overrides.
 // the OutOfClusterConfig. It does not implement loading rules or overrides.
 func (conf *OutOfClusterConfig) ToRawKubeConfigLoader() clientcmd.ClientConfig {
 func (conf *OutOfClusterConfig) ToRawKubeConfigLoader() clientcmd.ClientConfig {
-	cmdConf, _ := GetRestrictedClientConfigFromBytes(
-		conf.KubeConfig,
-		conf.Context,
-		conf.AllowedContexts,
+	cmdConf, _ := GetClientConfigFromServiceAccount(
+		conf.ServiceAccount,
+		conf.ClusterID,
 	)
 	)
 
 
 	return cmdConf
 	return cmdConf

+ 344 - 0
internal/kubernetes/kubeconfig.go

@@ -1,11 +1,355 @@
 package kubernetes
 package kubernetes
 
 
 import (
 import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
+	"golang.org/x/oauth2/google"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/clientcmd/api"
 	"k8s.io/client-go/tools/clientcmd/api"
 )
 )
 
 
+// GetServiceAccountCandidates parses a kubeconfig for a list of service account
+// candidates.
+func GetServiceAccountCandidates(kubeconfig []byte) ([]*models.ServiceAccountCandidate, error) {
+	config, err := clientcmd.NewClientConfigFromBytes(kubeconfig)
+
+	if err != nil {
+		return nil, err
+	}
+
+	rawConf, err := config.RawConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	res := make([]*models.ServiceAccountCandidate, 0)
+
+	for contextName, context := range rawConf.Contexts {
+		clusterName := context.Cluster
+		authInfoName := context.AuthInfo
+
+		// get the auth mechanism and actions
+		authMechanism, authInfoActions := parseAuthInfoForActions(rawConf.AuthInfos[authInfoName])
+		clusterActions := parseClusterForActions(rawConf.Clusters[clusterName])
+
+		actions := append(authInfoActions, clusterActions...)
+
+		// if auth mechanism is unsupported, we'll skip it
+		if authMechanism == models.NotAvailable {
+			continue
+		}
+
+		// construct the raw kubeconfig that's relevant for that context
+		contextConf, err := getConfigForContext(&rawConf, contextName)
+
+		if err != nil {
+			continue
+		}
+
+		rawBytes, err := clientcmd.Write(*contextConf)
+
+		if err == nil {
+			// create the candidate service account
+			res = append(res, &models.ServiceAccountCandidate{
+				Actions:         actions,
+				Kind:            "connector",
+				ClusterName:     clusterName,
+				ClusterEndpoint: rawConf.Clusters[clusterName].Server,
+				AuthMechanism:   authMechanism,
+				Kubeconfig:      rawBytes,
+			})
+		}
+	}
+
+	return res, nil
+}
+
+// GetRawConfigFromBytes returns the clientcmdapi.Config from kubeconfig
+// bytes
+func GetRawConfigFromBytes(kubeconfig []byte) (*api.Config, error) {
+	config, err := clientcmd.NewClientConfigFromBytes(kubeconfig)
+
+	if err != nil {
+		fmt.Println("ERROR IS HERE")
+		return nil, err
+	}
+
+	rawConf, err := config.RawConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &rawConf, nil
+}
+
+// Parsing rules are:
+//
+// (1) If a client certificate + client key exist, uses x509 auth mechanism
+// (2) If an oidc/gcp/aws plugin exists, uses that auth mechanism
+// (3) If a bearer token exists, uses bearer token auth mechanism
+// (4) If a username/password exist, uses basic auth mechanism
+// (5) Otherwise, the config gets skipped
+//
+func parseAuthInfoForActions(authInfo *api.AuthInfo) (authMechanism string, actions []models.ServiceAccountAction) {
+	actions = make([]models.ServiceAccountAction, 0)
+
+	if (authInfo.ClientCertificate != "" || len(authInfo.ClientCertificateData) != 0) &&
+		(authInfo.ClientKey != "" || len(authInfo.ClientKeyData) != 0) {
+		if len(authInfo.ClientCertificateData) == 0 {
+			actions = append(actions, models.ServiceAccountAction{
+				Name:     models.ClientCertDataAction,
+				Resolved: false,
+				Filename: authInfo.ClientCertificate,
+			})
+		}
+
+		if len(authInfo.ClientKeyData) == 0 {
+			actions = append(actions, models.ServiceAccountAction{
+				Name:     models.ClientKeyDataAction,
+				Resolved: false,
+				Filename: authInfo.ClientKey,
+			})
+		}
+
+		return models.X509, actions
+	}
+
+	if authInfo.AuthProvider != nil {
+		switch authInfo.AuthProvider.Name {
+		case "oidc":
+			filename, isFile := authInfo.AuthProvider.Config["idp-certificate-authority"]
+			data, isData := authInfo.AuthProvider.Config["idp-certificate-authority-data"]
+
+			if isFile && (!isData || data == "") {
+				return models.OIDC, []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     models.OIDCIssuerDataAction,
+						Resolved: false,
+						Filename: filename,
+					},
+				}
+			}
+
+			return models.OIDC, actions
+		case "gcp":
+			return models.GCP, []models.ServiceAccountAction{
+				models.ServiceAccountAction{
+					Name:     models.GCPKeyDataAction,
+					Resolved: false,
+				},
+			}
+		}
+	}
+
+	if authInfo.Exec != nil {
+		if authInfo.Exec.Command == "aws" || authInfo.Exec.Command == "aws-iam-authenticator" {
+			return models.AWS, []models.ServiceAccountAction{
+				models.ServiceAccountAction{
+					Name:     models.AWSKeyDataAction,
+					Resolved: false,
+				},
+			}
+		}
+	}
+
+	if authInfo.Token != "" || authInfo.TokenFile != "" {
+		if authInfo.Token == "" {
+			return models.Bearer, []models.ServiceAccountAction{
+				models.ServiceAccountAction{
+					Name:     models.TokenDataAction,
+					Resolved: false,
+					Filename: authInfo.TokenFile,
+				},
+			}
+		}
+
+		return models.Bearer, actions
+	}
+
+	if authInfo.Username != "" && authInfo.Password != "" {
+		return models.Basic, actions
+	}
+
+	return models.NotAvailable, actions
+}
+
+// Parses the cluster object to determine actions -- only currently supported action is
+// population of the cluster certificate authority data
+func parseClusterForActions(cluster *api.Cluster) (actions []models.ServiceAccountAction) {
+	actions = make([]models.ServiceAccountAction, 0)
+
+	if cluster.CertificateAuthority != "" && len(cluster.CertificateAuthorityData) == 0 {
+		return []models.ServiceAccountAction{
+			models.ServiceAccountAction{
+				Name:     models.ClusterCADataAction,
+				Resolved: false,
+				Filename: cluster.CertificateAuthority,
+			},
+		}
+	}
+
+	return actions
+}
+
+// getKubeconfigForContext returns the raw kubeconfig associated with only a
+// single context of the raw config
+func getConfigForContext(
+	rawConf *api.Config,
+	contextName string,
+) (*api.Config, error) {
+	copyConf := rawConf.DeepCopy()
+
+	copyConf.Clusters = make(map[string]*api.Cluster)
+	copyConf.AuthInfos = make(map[string]*api.AuthInfo)
+	copyConf.Contexts = make(map[string]*api.Context)
+	copyConf.CurrentContext = contextName
+
+	context, ok := rawConf.Contexts[contextName]
+
+	if ok {
+		userName := context.AuthInfo
+		clusterName := context.Cluster
+		authInfo, userFound := rawConf.AuthInfos[userName]
+		cluster, clusterFound := rawConf.Clusters[clusterName]
+
+		if userFound && clusterFound {
+			copyConf.Clusters[clusterName] = cluster
+			copyConf.AuthInfos[userName] = authInfo
+			copyConf.Contexts[contextName] = context
+		} else {
+			return nil, errors.New("linked user and cluster not found")
+		}
+	} else {
+		return nil, errors.New("context not found")
+	}
+
+	return copyConf, nil
+}
+
+// GetClientConfigFromServiceAccount will construct new clientcmd.ClientConfig using
+// the configuration saved within a ServiceAccount model
+func GetClientConfigFromServiceAccount(
+	sa *models.ServiceAccount,
+	clusterID uint,
+) (clientcmd.ClientConfig, error) {
+	apiConfig, err := createRawConfigFromServiceAccount(sa, clusterID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	config := clientcmd.NewDefaultClientConfig(*apiConfig, &clientcmd.ConfigOverrides{})
+
+	return config, nil
+}
+
+func createRawConfigFromServiceAccount(
+	sa *models.ServiceAccount,
+	clusterID uint,
+) (*api.Config, error) {
+	apiConfig := &api.Config{}
+
+	var cluster *models.Cluster = nil
+
+	// find the cluster within the ServiceAccount configuration
+	for _, _cluster := range sa.Clusters {
+		if _cluster.ID == clusterID {
+			cluster = &_cluster
+		}
+	}
+
+	if cluster == nil {
+		return nil, errors.New("cluster not found")
+	}
+
+	clusterMap := make(map[string]*api.Cluster)
+
+	clusterMap[cluster.Name] = &api.Cluster{
+		LocationOfOrigin:         cluster.LocationOfOrigin,
+		Server:                   cluster.Server,
+		TLSServerName:            cluster.TLSServerName,
+		InsecureSkipTLSVerify:    cluster.InsecureSkipTLSVerify,
+		CertificateAuthorityData: cluster.CertificateAuthorityData,
+	}
+
+	// construct the auth infos
+	authInfoName := cluster.Name + "-" + sa.AuthMechanism
+
+	authInfoMap := make(map[string]*api.AuthInfo)
+
+	authInfoMap[authInfoName] = &api.AuthInfo{
+		LocationOfOrigin: sa.LocationOfOrigin,
+		Impersonate:      sa.Impersonate,
+	}
+
+	if groups := strings.Split(sa.ImpersonateGroups, ","); len(groups) > 0 && groups[0] != "" {
+		authInfoMap[authInfoName].ImpersonateGroups = groups
+	}
+
+	switch sa.AuthMechanism {
+	case models.X509:
+		authInfoMap[authInfoName].ClientCertificateData = sa.ClientCertificateData
+		authInfoMap[authInfoName].ClientKeyData = sa.ClientKeyData
+	case models.Basic:
+		authInfoMap[authInfoName].Username = sa.Username
+		authInfoMap[authInfoName].Password = sa.Password
+	case models.Bearer:
+		authInfoMap[authInfoName].Token = sa.Token
+	case models.OIDC:
+		authInfoMap[authInfoName].AuthProvider = &api.AuthProviderConfig{
+			Name: "oidc",
+			Config: map[string]string{
+				"idp-issuer-url":                 sa.OIDCIssuerURL,
+				"client-id":                      sa.OIDCClientID,
+				"client-secret":                  sa.OIDCClientSecret,
+				"idp-certificate-authority-data": sa.OIDCCertificateAuthorityData,
+				"id-token":                       sa.OIDCIDToken,
+				"refresh-token":                  sa.OIDCRefreshToken,
+			},
+		}
+	// we'll add a bearer token here for now
+	case models.GCP:
+		creds, err := google.CredentialsFromJSON(
+			context.Background(),
+			sa.KeyData,
+			"https://www.googleapis.com/auth/cloud-platform",
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		tok, err := creds.TokenSource.Token()
+
+		authInfoMap[authInfoName].Token = tok.AccessToken
+	case models.AWS:
+	default:
+		return nil, errors.New("not a supported auth mechanism")
+	}
+
+	// create a context of the cluster name
+	contextMap := make(map[string]*api.Context)
+
+	contextMap[cluster.Name] = &api.Context{
+		LocationOfOrigin: cluster.LocationOfOrigin,
+		Cluster:          cluster.Name,
+		AuthInfo:         authInfoName,
+	}
+
+	apiConfig.Clusters = clusterMap
+	apiConfig.AuthInfos = authInfoMap
+	apiConfig.Contexts = contextMap
+	apiConfig.CurrentContext = cluster.Name
+
+	return apiConfig, nil
+}
+
 // GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
 // GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
 // a context name, and the set of allowed contexts.
 // a context name, and the set of allowed contexts.
 func GetRestrictedClientConfigFromBytes(
 func GetRestrictedClientConfigFromBytes(

+ 602 - 9
internal/kubernetes/kubeconfig_test.go

@@ -7,6 +7,7 @@ import (
 
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
+	"k8s.io/client-go/tools/clientcmd"
 )
 )
 
 
 type kubeConfigTest struct {
 type kubeConfigTest struct {
@@ -165,6 +166,331 @@ func TestGetRestrictedClientConfig(t *testing.T) {
 	}
 	}
 }
 }
 
 
+type saCandidatesTest struct {
+	name     string
+	raw      []byte
+	expected []*models.ServiceAccountCandidate
+}
+
+var SACandidatesTests = []saCandidatesTest{
+	saCandidatesTest{
+		name: "test without cluster ca data",
+		raw:  []byte(ClusterCAWithoutData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-cluster-ca-data",
+						Resolved: false,
+						Filename: "/fake/path/to/ca.pem",
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.X509,
+				Kubeconfig:      []byte(ClusterCAWithoutData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "x509 test with cert and key data",
+		raw:  []byte(x509WithData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions:         []models.ServiceAccountAction{},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.X509,
+				Kubeconfig:      []byte(x509WithData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "x509 test without cert data",
+		raw:  []byte(x509WithoutCertData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-client-cert-data",
+						Resolved: false,
+						Filename: "/fake/path/to/cert.pem",
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.X509,
+				Kubeconfig:      []byte(x509WithoutCertData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "x509 test without key data",
+		raw:  []byte(x509WithoutKeyData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-client-key-data",
+						Resolved: false,
+						Filename: "/fake/path/to/key.pem",
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.X509,
+				Kubeconfig:      []byte(x509WithoutKeyData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "x509 test without cert and key data",
+		raw:  []byte(x509WithoutCertAndKeyData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-client-cert-data",
+						Resolved: false,
+						Filename: "/fake/path/to/cert.pem",
+					},
+					models.ServiceAccountAction{
+						Name:     "upload-client-key-data",
+						Resolved: false,
+						Filename: "/fake/path/to/key.pem",
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.X509,
+				Kubeconfig:      []byte(x509WithoutCertAndKeyData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "bearer token test with data",
+		raw:  []byte(BearerTokenWithData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions:         []models.ServiceAccountAction{},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.Bearer,
+				Kubeconfig:      []byte(BearerTokenWithData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "bearer token test without data",
+		raw:  []byte(BearerTokenWithoutData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-token-data",
+						Resolved: false,
+						Filename: "/path/to/token/file.txt",
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.Bearer,
+				Kubeconfig:      []byte(BearerTokenWithoutData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "gcp test",
+		raw:  []byte(GCPPlugin),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-gcp-key-data",
+						Resolved: false,
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.GCP,
+				Kubeconfig:      []byte(GCPPlugin),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "aws iam authenticator test",
+		raw:  []byte(AWSIamAuthenticatorExec),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-aws-key-data",
+						Resolved: false,
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.AWS,
+				Kubeconfig:      []byte(AWSIamAuthenticatorExec),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "aws eks get-token test",
+		raw:  []byte(AWSEKSGetTokenExec),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-aws-key-data",
+						Resolved: false,
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.AWS,
+				Kubeconfig:      []byte(AWSEKSGetTokenExec),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "oidc without ca data",
+		raw:  []byte(OIDCAuthWithoutData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-oidc-idp-issuer-ca-data",
+						Resolved: false,
+						Filename: "/fake/path/to/ca.pem",
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.OIDC,
+				Kubeconfig:      []byte(OIDCAuthWithoutData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "oidc with ca data",
+		raw:  []byte(OIDCAuthWithData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions:         []models.ServiceAccountAction{},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.OIDC,
+				Kubeconfig:      []byte(OIDCAuthWithData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "basic auth test",
+		raw:  []byte(BasicAuth),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions:         []models.ServiceAccountAction{},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.Basic,
+				Kubeconfig:      []byte(BasicAuth),
+			},
+		},
+	},
+}
+
+func TestGetServiceAccountCandidates(t *testing.T) {
+	for _, c := range SACandidatesTests {
+		result, err := kubernetes.GetServiceAccountCandidates(c.raw)
+
+		if err != nil {
+			t.Fatalf("error occurred %v\n", err)
+		}
+
+		// make result into a map so it's easier to compare
+		resMap := make(map[string]*models.ServiceAccountCandidate)
+
+		for _, res := range result {
+			resMap[res.Kind+"-"+res.ClusterEndpoint+"-"+res.AuthMechanism] = res
+		}
+
+		for _, exp := range c.expected {
+			res, ok := resMap[exp.Kind+"-"+exp.ClusterEndpoint+"-"+exp.AuthMechanism]
+
+			if !ok {
+				t.Fatalf("%s failed: no matching result for %s\n", c.name,
+					exp.Kind+"-"+exp.ClusterEndpoint+"-"+exp.AuthMechanism)
+			}
+
+			// compare basic string fields
+			if exp.AuthMechanism != res.AuthMechanism {
+				t.Errorf("%s failed on auth mechanism: expected %s, got %s\n",
+					c.name, exp.AuthMechanism, res.AuthMechanism)
+			}
+
+			if exp.ClusterName != res.ClusterName {
+				t.Errorf("%s failed on cluster name: expected %s, got %s\n",
+					c.name, exp.ClusterName, res.ClusterName)
+			}
+
+			if exp.ClusterEndpoint != res.ClusterEndpoint {
+				t.Errorf("%s failed on cluster endpoint: expected %s, got %s\n",
+					c.name, exp.ClusterEndpoint, res.ClusterEndpoint)
+			}
+
+			if len(res.Actions) != len(exp.Actions) {
+				t.Errorf("%s failed on action names: expected length %d, got length %d\n",
+					c.name, len(res.Actions), len(exp.Actions))
+			} else {
+				for i, action := range exp.Actions {
+					if res.Actions[i].Name != action.Name {
+						t.Errorf("%s failed on action names: expected res to contain %s, got %s\n",
+							c.name, action.Name, res.Actions[i].Name)
+					}
+
+					if res.Actions[i].Filename != action.Filename {
+						t.Errorf("%s failed on action file names: expected res to contain %s, got %s\n",
+							c.name, action.Filename, res.Actions[i].Filename)
+					}
+				}
+			}
+
+			// compare kubeconfig by transforming into a client config
+			resConfig, _ := clientcmd.NewClientConfigFromBytes(res.Kubeconfig)
+			expConfig, err := clientcmd.NewClientConfigFromBytes(exp.Kubeconfig)
+
+			if err != nil {
+				t.Fatalf("config from bytes, error occurred %v\n", err)
+			}
+
+			resRawConf, _ := resConfig.RawConfig()
+			expRawConf, err := expConfig.RawConfig()
+
+			if err != nil {
+				t.Fatalf("raw config conversion, error occurred %v\n", err)
+			}
+
+			if !reflect.DeepEqual(resRawConf, expRawConf) {
+				t.Errorf("%s failed: expected %v, got %v\n", c.name, expRawConf, resRawConf)
+			}
+		}
+	}
+}
+
 const noContexts string = `
 const noContexts string = `
 apiVersion: v1
 apiVersion: v1
 kind: Config
 kind: Config
@@ -266,7 +592,28 @@ users:
   - name: test-admin
   - name: test-admin
 `
 `
 
 
-const oidcPlugin string = `
+const ClusterCAWithoutData string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority: /fake/path/to/ca.pem
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate-data: LS0tLS1CRUdJTiBDRVJ=
+    client-key-data: LS0tLS1CRUdJTiBDRVJ=
+current-context: context-test
+`
+
+const x509WithData string = `
 apiVersion: v1
 apiVersion: v1
 kind: Config
 kind: Config
 preferences: {}
 preferences: {}
@@ -281,16 +628,262 @@ contexts:
     user: test-admin
     user: test-admin
   name: context-test
   name: context-test
 users:
 users:
-  - name: test-admin
-  - name: test-admin
+- name: test-admin
+  user:
+    client-certificate-data: LS0tLS1CRUdJTiBDRVJ=
+    client-key-data: LS0tLS1CRUdJTiBDRVJ=
+`
+
+const x509WithoutCertData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate: /fake/path/to/cert.pem
+    client-key-data: LS0tLS1CRUdJTiBDRVJ=
+`
+
+const x509WithoutKeyData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate-data: LS0tLS1CRUdJTiBDRVJ=
+    client-key: /fake/path/to/key.pem
+`
+
+const x509WithoutCertAndKeyData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate: /fake/path/to/cert.pem
+    client-key: /fake/path/to/key.pem
+`
+
+const BearerTokenWithData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    token: LS0tLS1CRUdJTiBDRVJ=
+`
+
+const BearerTokenWithoutData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    tokenFile: /path/to/token/file.txt
+`
+const GCPPlugin string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+users:
+- name: test-admin
+  user:
+    auth-provider:
+      name: gcp
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+`
+
+const AWSIamAuthenticatorExec = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    exec:
+      apiVersion: client.authentication.k8s.io/v1alpha1
+      command: aws-iam-authenticator
+      args:
+        - "token"
+        - "-i"
+        - "cluster-test"
+`
+
+const AWSEKSGetTokenExec = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    exec:
+      apiVersion: client.authentication.k8s.io/v1alpha1
+      command: aws
+      args:
+        - "eks"
+        - "get-token"
+        - "--cluster-name"
+        - "cluster-test"
+`
+
+const OIDCAuthWithoutData = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
   user:
   user:
     auth-provider:
     auth-provider:
       config:
       config:
-        client-id: sampleclientid
-        client-secret: sampleclientsecret
-        id-token: IDTOKEN
-        idp-issuer-url: https://login.example.com/
-		refresh-token: REFRESHTOKEN
-		idp-certificate-authority: /example/file/on/system.pem
+        client-id: porter-api
+        id-token: token
+        idp-issuer-url: https://localhost
+        idp-certificate-authority: /fake/path/to/ca.pem
       name: oidc
       name: oidc
 `
 `
+
+const OIDCAuthWithData = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    auth-provider:
+      config:
+        client-id: porter-api
+        id-token: token
+        idp-issuer-url: https://localhost
+        idp-certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+      name: oidc
+`
+
+const BasicAuth = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    username: admin
+    password: changeme
+`

+ 52 - 4
internal/kubernetes/local/kubeconfig.go

@@ -1,5 +1,3 @@
-// +build cli
-
 package local
 package local
 
 
 import (
 import (
@@ -17,6 +15,58 @@ import (
 	"k8s.io/client-go/util/homedir"
 	"k8s.io/client-go/util/homedir"
 )
 )
 
 
+// GetKubeconfigFromHost returns the kubeconfig for a list of contexts using default
+// options set on the host, or an explicit kubeconfig path. It then strips the kubeconfig
+// of contexts not specified in the contexts array, and returns generate kubeconfig.
+func GetKubeconfigFromHost(kubeconfigPath string, contexts []string) ([]byte, error) {
+	envVarName := clientcmd.RecommendedConfigPathEnvVar
+
+	if kubeconfigPath != "" {
+		if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) {
+			// the specified kubeconfig does not exist so fallback to other options
+			kubeconfigPath = ""
+		}
+	}
+
+	if kubeconfigPath == "" && os.Getenv(envVarName) == "" {
+		if home := homedir.HomeDir(); home != "" {
+			kubeconfigPath = filepath.Join(home, ".kube", "config")
+		}
+	}
+
+	loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
+	loadingRules.ExplicitPath = kubeconfigPath
+
+	clientConf := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{})
+	rawConf, err := clientConf.RawConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	if len(contexts) == 0 {
+		contexts = []string{rawConf.CurrentContext}
+
+		if contexts[0] == "" {
+			return nil, fmt.Errorf("at least one context must be specified")
+		}
+	}
+
+	conf, err := stripAndValidateClientContexts(&rawConf, contexts[0], contexts)
+
+	if err != nil {
+		return nil, err
+	}
+
+	strippedRawConf, err := conf.RawConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	return clientcmd.Write(strippedRawConf)
+}
+
 // GetConfigFromHostWithCertData gets the kubeconfig using default options set on the host:
 // GetConfigFromHostWithCertData gets the kubeconfig using default options set on the host:
 // the kubeconfig can either be retrieved from a specified path or an environment variable.
 // the kubeconfig can either be retrieved from a specified path or an environment variable.
 // This function only outputs a clientcmd that uses the allowedContexts.
 // This function only outputs a clientcmd that uses the allowedContexts.
@@ -68,8 +118,6 @@ func GetConfigFromHostWithCertData(kubeconfigPath string, allowedContexts []stri
 	return res, nil
 	return res, nil
 }
 }
 
 
-// GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
-// a context name, and the set of allowed contexts.
 func stripAndValidateClientContexts(
 func stripAndValidateClientContexts(
 	rawConf *clientcmdapi.Config,
 	rawConf *clientcmdapi.Config,
 	currentContext string,
 	currentContext string,

+ 118 - 0
internal/models/action.go

@@ -0,0 +1,118 @@
+package models
+
+import "gorm.io/gorm"
+
+// Action names
+const (
+	ClusterCADataAction  string = "upload-cluster-ca-data"
+	ClientCertDataAction        = "upload-client-cert-data"
+	ClientKeyDataAction         = "upload-client-key-data"
+	OIDCIssuerDataAction        = "upload-oidc-idp-issuer-ca-data"
+	TokenDataAction             = "upload-token-data"
+	GCPKeyDataAction            = "upload-gcp-key-data"
+	AWSKeyDataAction            = "upload-aws-key-data"
+)
+
+// ServiceAccountAction is an action that must be resolved to set up
+// a ServiceAccount
+type ServiceAccountAction struct {
+	gorm.Model
+
+	ServiceAccountCandidateID uint
+
+	// One of the constant action names
+	Name     string `json:"name"`
+	Resolved bool   `json:"resolved"`
+
+	// Filename is an optional filename, if the action requires
+	// data populated from a local file
+	Filename string `json:"filename,omitempty"`
+}
+
+// Externalize generates an external ServiceAccount to be shared over REST
+func (u *ServiceAccountAction) Externalize() *ServiceAccountActionExternal {
+	info := ServiceAccountActionInfos[u.Name]
+
+	return &ServiceAccountActionExternal{
+		Name:     u.Name,
+		Resolved: u.Resolved,
+		Filename: u.Filename,
+		Docs:     info.Docs,
+		Fields:   info.Fields,
+	}
+}
+
+// ServiceAccountActionExternal is an external ServiceAccountAction to be
+// sent over REST
+type ServiceAccountActionExternal struct {
+	Name     string `json:"name"`
+	Docs     string `json:"docs"`
+	Resolved bool   `json:"resolved"`
+	Fields   string `json:"fields"`
+	Filename string `json:"filename,omitempty"`
+}
+
+// ServiceAccountAllActions is a helper type that contains the fields for
+// all possible actions, so that raw bytes can be unmarshaled in a single
+// read
+type ServiceAccountAllActions struct {
+	Name string `json:"name"`
+
+	ClusterCAData    string `json:"cluster_ca_data,omitempty"`
+	ClientCertData   string `json:"client_cert_data,omitempty"`
+	ClientKeyData    string `json:"client_key_data,omitempty"`
+	OIDCIssuerCAData string `json:"oidc_idp_issuer_ca_data,omitempty"`
+	TokenData        string `json:"token_data,omitempty"`
+	GCPKeyData       string `json:"gcp_key_data,omitempty"`
+	AWSKeyData       string `json:"aws_key_data,omitempty"`
+}
+
+// ServiceAccountActionInfo contains the information for actions to be
+// performed in order to initialize a ServiceAccount
+type ServiceAccountActionInfo struct {
+	Name string `json:"name"`
+	Docs string `json:"docs"`
+
+	// a comma-separated list of required fields to send in an action request
+	Fields string `json:"fields"`
+}
+
+// ServiceAccountActionInfos contain the information for actions to be
+// performed in order to initialize a ServiceAccount
+var ServiceAccountActionInfos = map[string]ServiceAccountActionInfo{
+	"upload-cluster-ca-data": ServiceAccountActionInfo{
+		Name:   ClusterCADataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "cluster_ca_data",
+	},
+	"upload-client-cert-data": ServiceAccountActionInfo{
+		Name:   ClientCertDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "client_cert_data",
+	},
+	"upload-client-key-data": ServiceAccountActionInfo{
+		Name:   ClientKeyDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "client_key_data",
+	},
+	"upload-oidc-idp-issuer-ca-data": ServiceAccountActionInfo{
+		Name:   OIDCIssuerDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "oidc_idp_issuer_ca_data",
+	},
+	"upload-token-data": ServiceAccountActionInfo{
+		Name:   TokenDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "token_data",
+	},
+	"upload-gcp-key-data": ServiceAccountActionInfo{
+		Name:   GCPKeyDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "gcp_key_data",
+	},
+	"upload-aws-key-data": ServiceAccountActionInfo{
+		Name:   AWSKeyDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "aws_key_data",
+	},
+}

+ 43 - 0
internal/models/cluster.go

@@ -0,0 +1,43 @@
+package models
+
+import "gorm.io/gorm"
+
+// Cluster type that extends gorm.Model
+type Cluster struct {
+	gorm.Model
+
+	Name                  string `json:"name"`
+	ServiceAccountID      uint   `json:"service_account_id"`
+	LocationOfOrigin      string `json:"location_of_origin"`
+	Server                string `json:"server"`
+	TLSServerName         string `json:"tls-server-name,omitempty"`
+	InsecureSkipTLSVerify bool   `json:"insecure-skip-tls-verify,omitempty"`
+	ProxyURL              string `json:"proxy-url,omitempty"`
+
+	// CertificateAuthorityData is encrypted at rest
+	CertificateAuthorityData []byte `json:"certificate-authority-data,omitempty"`
+}
+
+// ClusterExternal is the external cluster type to be sent over REST
+type ClusterExternal struct {
+	ID                    uint   `json:"id"`
+	ServiceAccountID      uint   `json:"service_account_id"`
+	Name                  string `json:"name"`
+	Server                string `json:"server"`
+	TLSServerName         string `json:"tls-server-name,omitempty"`
+	InsecureSkipTLSVerify bool   `json:"insecure-skip-tls-verify,omitempty"`
+	ProxyURL              string `json:"proxy-url,omitempty"`
+}
+
+// Externalize generates an external Cluster to be shared over REST
+func (c *Cluster) Externalize() *ClusterExternal {
+	return &ClusterExternal{
+		ID:                    c.Model.ID,
+		ServiceAccountID:      c.ServiceAccountID,
+		Name:                  c.Name,
+		Server:                c.Server,
+		TLSServerName:         c.TLSServerName,
+		InsecureSkipTLSVerify: c.InsecureSkipTLSVerify,
+		ProxyURL:              c.ProxyURL,
+	}
+}

+ 47 - 0
internal/models/project.go

@@ -0,0 +1,47 @@
+package models
+
+import (
+	"gorm.io/gorm"
+)
+
+// Project type that extends gorm.Model
+type Project struct {
+	gorm.Model
+
+	Name        string       `json:"name"`
+	Roles       []Role       `json:"roles"`
+	RepoClients []RepoClient `json:"repo_clients,omitempty"`
+
+	ServiceAccountCandidates []ServiceAccountCandidate `json:"sa_candidates"`
+	ServiceAccounts          []ServiceAccount          `json:"serviceaccounts"`
+}
+
+// ProjectExternal represents the Project type that is sent over REST
+type ProjectExternal struct {
+	ID          uint                 `json:"id"`
+	Name        string               `json:"name"`
+	Roles       []RoleExternal       `json:"roles"`
+	RepoClients []RepoClientExternal `json:"repo_clients,omitempty"`
+}
+
+// Externalize generates an external Project to be shared over REST
+func (p *Project) Externalize() *ProjectExternal {
+	roles := make([]RoleExternal, 0)
+
+	for _, role := range p.Roles {
+		roles = append(roles, *role.Externalize())
+	}
+
+	repoClients := make([]RepoClientExternal, 0)
+
+	for _, repoClient := range p.RepoClients {
+		repoClients = append(repoClients, *repoClient.Externalize())
+	}
+
+	return &ProjectExternal{
+		ID:          p.ID,
+		Name:        p.Name,
+		Roles:       roles,
+		RepoClients: repoClients,
+	}
+}

+ 51 - 0
internal/models/repoclient.go

@@ -0,0 +1,51 @@
+package models
+
+import (
+	"gorm.io/gorm"
+)
+
+// The allowed repository clients
+const (
+	RepoClientGithub = "github"
+)
+
+// RepoClient is a client for a set of repositories that has been added
+// via a project OAuth flow
+type RepoClient struct {
+	gorm.Model
+
+	ProjectID  uint `json:"project_id"`
+	UserID     uint `json:"user_id"`
+	RepoUserID uint `json:"repo_id"`
+
+	// the kind can be one of the predefined repo kinds
+	Kind string `json:"kind"`
+
+	// ------------------------------------------------------------------
+	// All fields below this line are encrypted before storage
+	// ------------------------------------------------------------------
+
+	AccessToken  string `json:"access_token"`
+	RefreshToken string `json:"refresh_token"`
+}
+
+// RepoClientExternal is a RepoClient scrubbed of sensitive information to be
+// shared over REST
+type RepoClientExternal struct {
+	ID         uint   `json:"id"`
+	ProjectID  uint   `json:"project_id"`
+	UserID     uint   `json:"user_id"`
+	RepoUserID uint   `json:"repo_id"`
+	Kind       string `json:"kind"`
+}
+
+// Externalize generates an external RepoClient to be shared over REST
+func (r *RepoClient) Externalize() *RepoClientExternal {
+	return &RepoClientExternal{
+		ID:         r.Model.ID,
+		ProjectID:  r.ProjectID,
+		UserID:     r.UserID,
+		RepoUserID: r.RepoUserID,
+		Kind:       r.Kind,
+	}
+}

+ 38 - 0
internal/models/role.go

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

+ 145 - 0
internal/models/serviceaccount.go

@@ -0,0 +1,145 @@
+package models
+
+import (
+	"gorm.io/gorm"
+)
+
+// Supported auth mechanisms
+const (
+	X509         string = "x509"
+	Basic               = "basic"
+	Bearer              = "bearerToken"
+	OIDC                = "oidc"
+	GCP                 = "gcp-sa"
+	AWS                 = "aws-sa"
+	NotAvailable        = "n/a"
+)
+
+// ServiceAccountCandidate is a service account that requires an action
+// from the user to set up.
+type ServiceAccountCandidate struct {
+	gorm.Model
+
+	ProjectID uint   `json:"project_id"`
+	Kind      string `json:"kind"`
+
+	Actions []ServiceAccountAction `json:"actions"`
+
+	ClusterName     string `json:"cluster_name"`
+	ClusterEndpoint string `json:"cluster_endpoint"`
+	AuthMechanism   string `json:"auth_mechanism"`
+
+	// ------------------------------------------------------------------
+	// All fields below this line are encrypted before storage
+	// ------------------------------------------------------------------
+
+	Kubeconfig []byte `json:"kubeconfig"`
+}
+
+// ServiceAccountCandidateExternal represents the ServiceAccountCandidate type that is
+// sent over REST
+type ServiceAccountCandidateExternal struct {
+	ID              uint                           `json:"id"`
+	Actions         []ServiceAccountActionExternal `json:"actions"`
+	ProjectID       uint                           `json:"project_id"`
+	Kind            string                         `json:"kind"`
+	ClusterName     string                         `json:"cluster_name"`
+	ClusterEndpoint string                         `json:"cluster_endpoint"`
+	AuthMechanism   string                         `json:"auth_mechanism"`
+}
+
+// Externalize generates an external ServiceAccountCandidate to be shared over REST
+func (s *ServiceAccountCandidate) Externalize() *ServiceAccountCandidateExternal {
+	actions := make([]ServiceAccountActionExternal, 0)
+
+	for _, action := range s.Actions {
+		actions = append(actions, *action.Externalize())
+	}
+
+	return &ServiceAccountCandidateExternal{
+		ID:              s.ID,
+		Actions:         actions,
+		ProjectID:       s.ProjectID,
+		Kind:            s.Kind,
+		ClusterName:     s.ClusterName,
+		ClusterEndpoint: s.ClusterEndpoint,
+		AuthMechanism:   s.AuthMechanism,
+	}
+}
+
+// ServiceAccount type that extends gorm.Model
+type ServiceAccount struct {
+	gorm.Model
+
+	ProjectID uint `json:"project_id"`
+
+	// Kind can either be "connector" or "provisioner"
+	Kind string `json:"kind"`
+
+	// Clusters is a list of clusters that this ServiceAccount can connect
+	// to or has provisioned
+	Clusters []Cluster `json:"clusters"`
+
+	// AuthMechanism is the strategy used for either connecting to or provisioning
+	// the cluster. Supported mechanisms are: basic,x509,bearerToken,oidc,gcp-sa,aws-sa
+	AuthMechanism string `json:"auth_mechanism"`
+
+	// These fields are used by all auth mechanisms
+	LocationOfOrigin  string
+	Impersonate       string `json:"act-as,omitempty"`
+	ImpersonateGroups string `json:"act-as-groups,omitempty"`
+
+	// ------------------------------------------------------------------
+	// All fields below this line are encrypted before storage
+	// ------------------------------------------------------------------
+
+	// Certificate data is used by x509 auth mechanisms over TLS
+	ClientCertificateData []byte `json:"client-certificate-data,omitempty"`
+	ClientKeyData         []byte `json:"client-key-data,omitempty"`
+
+	// Token is used for bearer-token auth mechanisms
+	Token string `json:"token,omitempty"`
+
+	// Username/Password for basic authentication to a cluster
+	Username string `json:"username,omitempty"`
+	Password string `json:"password,omitempty"`
+
+	// KeyData for a service account for GCP and AWS connectors, along with
+	// a previous token so a new token isn't generated for each request
+	KeyData   []byte `json:"key_data"`
+	PrevToken string `json:"prev_token"`
+
+	// OIDC-related fields
+	OIDCIssuerURL                string `json:"idp-issuer-url"`
+	OIDCClientID                 string `json:"client-id"`
+	OIDCClientSecret             string `json:"client-secret"`
+	OIDCCertificateAuthorityData string `json:"idp-certificate-authority-data"`
+	OIDCIDToken                  string `json:"id-token"`
+	OIDCRefreshToken             string `json:"refresh-token"`
+}
+
+// ServiceAccountExternal is an external ServiceAccount to be shared over REST
+type ServiceAccountExternal struct {
+	ID            uint              `json:"id"`
+	ProjectID     uint              `json:"project_id"`
+	Kind          string            `json:"kind"`
+	Clusters      []ClusterExternal `json:"clusters"`
+	AuthMechanism string            `json:"auth_mechanism"`
+}
+
+// Externalize generates an external ServiceAccount to be shared over REST
+func (s *ServiceAccount) Externalize() *ServiceAccountExternal {
+	clusters := make([]ClusterExternal, 0)
+
+	for _, cluster := range s.Clusters {
+		clusters = append(clusters, *cluster.Externalize())
+	}
+
+	return &ServiceAccountExternal{
+		ID:            s.ID,
+		ProjectID:     s.ProjectID,
+		Kind:          s.Kind,
+		Clusters:      clusters,
+		AuthMechanism: s.AuthMechanism,
+	}
+}

+ 6 - 25
internal/models/user.go

@@ -1,8 +1,6 @@
 package models
 package models
 
 
 import (
 import (
-	"strings"
-
 	"gorm.io/gorm"
 	"gorm.io/gorm"
 )
 )
 
 
@@ -10,37 +8,20 @@ import (
 type User struct {
 type User struct {
 	gorm.Model
 	gorm.Model
 
 
-	Email         string `json:"email" gorm:"unique"`
-	Password      string `json:"password"`
-	Contexts      string `json:"contexts"`
-	RawKubeConfig []byte `json:"rawKubeConfig"`
+	Email    string `json:"email" gorm:"unique"`
+	Password string `json:"password"`
 }
 }
 
 
 // UserExternal represents the User type that is sent over REST
 // UserExternal represents the User type that is sent over REST
 type UserExternal struct {
 type UserExternal struct {
-	ID            uint     `json:"id"`
-	Email         string   `json:"email"`
-	Contexts      []string `json:"contexts"`
-	RawKubeConfig string   `json:"rawKubeConfig"`
+	ID    uint   `json:"id"`
+	Email string `json:"email"`
 }
 }
 
 
 // Externalize generates an external User to be shared over REST
 // Externalize generates an external User to be shared over REST
 func (u *User) Externalize() *UserExternal {
 func (u *User) Externalize() *UserExternal {
 	return &UserExternal{
 	return &UserExternal{
-		ID:            u.ID,
-		Email:         u.Email,
-		Contexts:      u.ContextToSlice(),
-		RawKubeConfig: string(u.RawKubeConfig),
-	}
-}
-
-// ContextToSlice converts the serialized context string to an array of strings
-func (u *User) ContextToSlice() []string {
-	contexts := strings.Split(u.Contexts, ",")
-
-	if u.Contexts == "" {
-		contexts = make([]string, 0)
+		ID:    u.ID,
+		Email: u.Email,
 	}
 	}
-
-	return contexts
 }
 }

+ 2 - 12
internal/models/user_test.go

@@ -13,10 +13,8 @@ func TestUserExternalize(t *testing.T) {
 		Model: gorm.Model{
 		Model: gorm.Model{
 			ID: 1,
 			ID: 1,
 		},
 		},
-		Email:         "testing@testing.com",
-		Password:      "testing123",
-		Contexts:      "test",
-		RawKubeConfig: []byte{},
+		Email:    "testing@testing.com",
+		Password: "testing123",
 	}
 	}
 
 
 	extUser := *user.Externalize()
 	extUser := *user.Externalize()
@@ -28,12 +26,4 @@ func TestUserExternalize(t *testing.T) {
 	if extUser.Email != user.Email {
 	if extUser.Email != user.Email {
 		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Email", user.Email, extUser.Email)
 		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Email", user.Email, extUser.Email)
 	}
 	}
-
-	if len(extUser.Contexts) != 1 {
-		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Length Contexts", len(extUser.Contexts), 1)
-	}
-
-	if len(extUser.RawKubeConfig) != 0 {
-		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Length RawKubeConfig", len(extUser.RawKubeConfig), 0)
-	}
 }
 }

+ 37 - 0
internal/oauth/config.go

@@ -0,0 +1,37 @@
+package oauth
+
+import (
+	"crypto/rand"
+	"encoding/base64"
+
+	"golang.org/x/oauth2"
+)
+
+type Config struct {
+	ClientID     string
+	ClientSecret string
+	Scopes       []string
+	BaseURL      string
+}
+
+func NewGithubClient(cfg *Config) *oauth2.Config {
+	return &oauth2.Config{
+		ClientID:     cfg.ClientID,
+		ClientSecret: cfg.ClientSecret,
+		Endpoint: oauth2.Endpoint{
+			AuthURL:  "https://github.com/login/oauth/authorize",
+			TokenURL: "https://github.com/login/oauth/access_token",
+		},
+		RedirectURL: cfg.BaseURL + "/api/oauth/github/callback",
+		Scopes:      cfg.Scopes,
+	}
+}
+
+func CreateRandomState() string {
+	b := make([]byte, 16)
+	rand.Read(b)
+
+	state := base64.URLEncoding.EncodeToString(b)
+
+	return state
+}

+ 158 - 0
internal/providers/gcp/agent.go

@@ -0,0 +1,158 @@
+package gcp
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+
+	admin "cloud.google.com/go/iam/admin/apiv1"
+	adminpb "google.golang.org/genproto/googleapis/iam/admin/v1"
+
+	crm "google.golang.org/api/cloudresourcemanager/v1"
+	gke "google.golang.org/api/container/v1"
+)
+
+type Agent struct {
+	Ctx       context.Context
+	ProjectID string
+
+	IAMClient                   *admin.IamClient
+	CloudResourceManagerService *crm.Service
+	GKEService                  *gke.Service
+}
+
+func (a *Agent) CreateServiceAccount(name string) (*adminpb.ServiceAccount, error) {
+	req := &adminpb.CreateServiceAccountRequest{
+		Name:      "projects/" + a.ProjectID,
+		AccountId: name,
+		ServiceAccount: &adminpb.ServiceAccount{
+			DisplayName: name,
+		},
+	}
+
+	return a.IAMClient.CreateServiceAccount(a.Ctx, req)
+}
+
+func (a *Agent) SetServiceAccountIAMPolicy(sa *adminpb.ServiceAccount) error {
+	projectSvc := a.CloudResourceManagerService.Projects
+
+	policy, err := projectSvc.GetIamPolicy(
+		a.ProjectID,
+		&crm.GetIamPolicyRequest{},
+	).Do()
+
+	if err != nil {
+		return err
+	}
+
+	doesExist := false
+
+	// find a container.developer binding if it exists
+	for _, binding := range policy.Bindings {
+		if binding.Role == "roles/container.developer" {
+			doesExist = true
+			binding.Members = append(binding.Members, "serviceAccount:"+sa.Email)
+			break
+		}
+	}
+
+	if !doesExist {
+		policy.Bindings = append(policy.Bindings, &crm.Binding{
+			Members: []string{"serviceAccount:" + sa.Email},
+			Role:    "roles/container.developer",
+		})
+	}
+
+	policy, err = projectSvc.SetIamPolicy(
+		a.ProjectID,
+		&crm.SetIamPolicyRequest{
+			Policy: policy,
+		},
+	).Do()
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+type ServiceAccountKey struct {
+	// set to service_account
+	Type         string `json:"type"`
+	ProjectID    string `json:"project_id"`
+	PrivateKeyID string `json:"private_key_id"`
+	// the private key, not base64 encoded
+	PrivateKey              string `json:"private_key"`
+	ClientEmail             string `json:"client_email"`
+	ClientID                string `json:"client_id"`
+	AuthURI                 string `json:"auth_uri"`
+	TokenURI                string `json:"token_uri"`
+	AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url"`
+	ClientX509CertURL       string `json:"client_x509_cert_url"`
+}
+
+// CreateServiceAccountKey will create a new key for the specified service account
+func (a *Agent) CreateServiceAccountKey(sa *adminpb.ServiceAccount) ([]byte, error) {
+	req := &adminpb.CreateServiceAccountKeyRequest{
+		Name: "projects/" + a.ProjectID + "/serviceAccounts/" + sa.Email,
+	}
+
+	resp, err := a.IAMClient.CreateServiceAccountKey(a.Ctx, req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.GetPrivateKeyData(), nil
+}
+
+// GetProjectIDForGKECluster automatically determines the project ID for a cluster
+// that a user has access to
+func (a *Agent) GetProjectIDForGKECluster(endpoint string) (string, error) {
+	// get a list of project IDs
+	projectSvc := a.CloudResourceManagerService.Projects
+
+	resp, err := projectSvc.List().Do()
+
+	if err != nil {
+		return "", err
+	}
+
+	projectIDs := make([]string, 0)
+
+	for _, project := range resp.Projects {
+		projectIDs = append(projectIDs, project.ProjectId)
+	}
+
+	// parse endpoint for ip address
+	u, err := url.Parse(endpoint)
+
+	if err != nil {
+		return "", err
+	}
+
+	ipAddr := u.Hostname()
+
+	// iterate through the projects, and get the GKE endpoints for each project
+	// if there's a match, return that project id
+	for _, projectID := range projectIDs {
+		projectsLocsService := a.GKEService.Projects.Locations
+
+		// this should be all zones
+		resp, err := projectsLocsService.Clusters.List("projects/" + projectID + "/locations/-").Do()
+
+		// we'll just continue -- if nothing is found, we'll return an error
+		if err != nil {
+			continue
+		}
+
+		for _, cluster := range resp.Clusters {
+			if cluster.Endpoint == ipAddr {
+				return projectID, nil
+			}
+		}
+	}
+
+	return "", fmt.Errorf("cluster not found")
+}

+ 95 - 0
internal/providers/gcp/local/config.go

@@ -0,0 +1,95 @@
+package local
+
+import (
+	"context"
+	"fmt"
+	"os/exec"
+	"time"
+
+	"github.com/porter-dev/porter/internal/providers/gcp"
+	"google.golang.org/api/cloudresourcemanager/v1"
+	gke "google.golang.org/api/container/v1"
+
+	admin "cloud.google.com/go/iam/admin/apiv1"
+
+	oauth2 "golang.org/x/oauth2/google"
+)
+
+// NewDefaultAgent returns an agent using Application Default Credentials. If these are not
+// set and the gcloud utility is installed on the machine, this will spawn a setup process
+// to link these credentials.
+func NewDefaultAgent() (*gcp.Agent, error) {
+	ctx := context.Background()
+	creds, err := setupDefaultCredentials(ctx)
+
+	if err != nil {
+		return nil, err
+	}
+
+	c, err := getDefaultIAMClient(ctx)
+
+	if err != nil {
+		return nil, err
+	}
+
+	cloudresourcemanagerService, err := cloudresourcemanager.NewService(ctx)
+
+	if err != nil {
+		return nil, err
+	}
+
+	gkeService, err := gke.NewService(ctx)
+
+	return &gcp.Agent{
+		Ctx:                         ctx,
+		ProjectID:                   creds.ProjectID,
+		IAMClient:                   c,
+		CloudResourceManagerService: cloudresourcemanagerService,
+		GKEService:                  gkeService,
+	}, nil
+}
+
+func setupDefaultCredentials(ctx context.Context) (*oauth2.Credentials, error) {
+	// determine if local Application Default Credentials Exist
+	creds, _ := oauth2.FindDefaultCredentials(ctx)
+
+	// if they don't exist, attempt gcloud login
+	if creds == nil {
+		if !commandExists("gcloud") {
+			return nil, fmt.Errorf("gcloud cli command does not exist")
+		}
+
+		// create Application Default Credentials that use the local user creds
+		cmd := exec.Command("gcloud", "auth", "application-default", "login")
+		err := cmd.Run()
+
+		if err != nil {
+			return nil, err
+		}
+
+		for i := 0; i < 5; i++ {
+			creds, err := oauth2.FindDefaultCredentials(ctx)
+
+			if creds != nil {
+				return creds, err
+			}
+
+			if i == 4 {
+				return nil, err
+			}
+
+			time.Sleep(time.Second)
+		}
+	}
+
+	return creds, nil
+}
+
+func getDefaultIAMClient(ctx context.Context) (*admin.IamClient, error) {
+	return admin.NewIamClient(ctx)
+}
+
+func commandExists(cmd string) bool {
+	_, err := exec.LookPath(cmd)
+	return err == nil
+}

+ 70 - 0
internal/repository/encrypt.go

@@ -0,0 +1,70 @@
+package repository
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"errors"
+	"io"
+)
+
+// This file is copied from: https://github.com/gtank/cryptopasta
+
+// NewEncryptionKey generates a random 256-bit key for Encrypt() and
+// Decrypt(). It panics if the source of randomness fails.
+func NewEncryptionKey() *[32]byte {
+	key := [32]byte{}
+	_, err := io.ReadFull(rand.Reader, key[:])
+	if err != nil {
+		panic(err)
+	}
+	return &key
+}
+
+// Encrypt encrypts data using 256-bit AES-GCM.  This both hides the content of
+// the data and provides a check that it hasn't been altered. Output takes the
+// form nonce|ciphertext|tag where '|' indicates concatenation.
+func Encrypt(plaintext []byte, key *[32]byte) (ciphertext []byte, err error) {
+	block, err := aes.NewCipher(key[:])
+	if err != nil {
+		return nil, err
+	}
+
+	gcm, err := cipher.NewGCM(block)
+	if err != nil {
+		return nil, err
+	}
+
+	nonce := make([]byte, gcm.NonceSize())
+	_, err = io.ReadFull(rand.Reader, nonce)
+	if err != nil {
+		return nil, err
+	}
+
+	return gcm.Seal(nonce, nonce, plaintext, nil), nil
+}
+
+// Decrypt decrypts data using 256-bit AES-GCM.  This both hides the content of
+// the data and provides a check that it hasn't been altered. Expects input
+// form nonce|ciphertext|tag where '|' indicates concatenation.
+func Decrypt(ciphertext []byte, key *[32]byte) (plaintext []byte, err error) {
+	block, err := aes.NewCipher(key[:])
+	if err != nil {
+		return nil, err
+	}
+
+	gcm, err := cipher.NewGCM(block)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(ciphertext) < gcm.NonceSize() {
+		return nil, errors.New("malformed ciphertext")
+	}
+
+	return gcm.Open(nil,
+		ciphertext[:gcm.NonceSize()],
+		ciphertext[gcm.NonceSize():],
+		nil,
+	)
+}

+ 194 - 0
internal/repository/gorm/helpers_test.go

@@ -0,0 +1,194 @@
+package gorm_test
+
+import (
+	"os"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/gorm"
+)
+
+type tester struct {
+	repo             *repository.Repository
+	key              *[32]byte
+	dbFileName       string
+	initUsers        []*models.User
+	initProjects     []*models.Project
+	initSACandidates []*models.ServiceAccountCandidate
+	initSAs          []*models.ServiceAccount
+	initRCs          []*models.RepoClient
+}
+
+func setupTestEnv(tester *tester, t *testing.T) {
+	t.Helper()
+
+	db, err := adapter.New(&config.DBConf{
+		EncryptionKey: "__random_strong_encryption_key__",
+		SQLLite:       true,
+		SQLLitePath:   tester.dbFileName,
+	})
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	err = db.AutoMigrate(
+		&models.Project{},
+		&models.Role{},
+		&models.ServiceAccount{},
+		&models.ServiceAccountAction{},
+		&models.ServiceAccountCandidate{},
+		&models.Cluster{},
+		&models.User{},
+		&models.Session{},
+		&models.RepoClient{},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte("__random_strong_encryption_key__") {
+		key[i] = b
+	}
+
+	tester.key = &key
+
+	tester.repo = gorm.NewRepository(db, &key)
+}
+
+func cleanup(tester *tester, t *testing.T) {
+	t.Helper()
+
+	// remove the created file file
+	os.Remove(tester.dbFileName)
+}
+
+func initUser(tester *tester, t *testing.T) {
+	t.Helper()
+
+	user := &models.User{
+		Email:    "example@example.com",
+		Password: "hello1234",
+	}
+
+	user, err := tester.repo.User.CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+}
+
+func initProject(tester *tester, t *testing.T) {
+	t.Helper()
+
+	proj := &models.Project{
+		Name: "project-test",
+	}
+
+	proj, err := tester.repo.Project.CreateProject(proj)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initProjects = append(tester.initProjects, proj)
+}
+
+func initProjectRole(tester *tester, t *testing.T) {
+	t.Helper()
+
+	role := &models.Role{
+		Kind:      models.RoleAdmin,
+		UserID:    tester.initUsers[0].Model.ID,
+		ProjectID: tester.initProjects[0].Model.ID,
+	}
+
+	role, err := tester.repo.Project.CreateProjectRole(tester.initProjects[0], role)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+}
+
+func initServiceAccountCandidate(tester *tester, t *testing.T) {
+	t.Helper()
+
+	saCandidate := &models.ServiceAccountCandidate{
+		ProjectID:       1,
+		Kind:            "connector",
+		ClusterName:     "cluster-test",
+		ClusterEndpoint: "https://localhost",
+		AuthMechanism:   models.X509,
+		Kubeconfig:      []byte("current-context: testing\n"),
+		Actions: []models.ServiceAccountAction{
+			models.ServiceAccountAction{
+				Name:     models.TokenDataAction,
+				Resolved: false,
+			},
+		},
+	}
+
+	saCandidate, err := tester.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initSACandidates = append(tester.initSACandidates, saCandidate)
+}
+
+func initServiceAccount(tester *tester, t *testing.T) {
+	t.Helper()
+
+	sa := &models.ServiceAccount{
+		ProjectID:             1,
+		Kind:                  "connector",
+		AuthMechanism:         models.X509,
+		ClientCertificateData: []byte("-----BEGIN"),
+		ClientKeyData:         []byte("-----BEGIN"),
+		Clusters: []models.Cluster{
+			models.Cluster{
+				Name:                     "cluster-test",
+				Server:                   "https://localhost",
+				CertificateAuthorityData: []byte("-----BEGIN"),
+			},
+		},
+	}
+
+	sa, err := tester.repo.ServiceAccount.CreateServiceAccount(sa)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initSAs = append(tester.initSAs, sa)
+}
+
+func initRepoClient(tester *tester, t *testing.T) {
+	t.Helper()
+
+	rc := &models.RepoClient{
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		RepoUserID:   1,
+		Kind:         models.RepoClientGithub,
+		AccessToken:  "accesstoken1234",
+		RefreshToken: "refreshtoken1234",
+	}
+
+	rc, err := tester.repo.RepoClient.CreateRepoClient(rc)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initRCs = append(tester.initRCs, rc)
+}

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

@@ -0,0 +1,66 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// ProjectRepository uses gorm.DB for querying the database
+type ProjectRepository struct {
+	db *gorm.DB
+}
+
+// NewProjectRepository returns a ProjectRepository which uses
+// gorm.DB for querying the database
+func NewProjectRepository(db *gorm.DB) repository.ProjectRepository {
+	return &ProjectRepository{db}
+}
+
+// CreateProject creates a new project
+func (repo *ProjectRepository) CreateProject(project *models.Project) (*models.Project, error) {
+	if err := repo.db.Create(project).Error; err != nil {
+		return nil, err
+	}
+
+	return project, nil
+}
+
+// CreateProjectRole appends a role to the existing array of roles
+func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *models.Role) (*models.Role, error) {
+	assoc := repo.db.Model(&project).Association("Roles")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(role); err != nil {
+		return nil, err
+	}
+
+	return role, nil
+}
+
+// ReadProject gets a projects specified by a unique id
+func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
+	project := &models.Project{}
+
+	if err := repo.db.Preload("Roles").Where("id = ?", id).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	return project, nil
+}
+
+// ListProjectsByUserID lists projects where a user has an associated role
+func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Project, error) {
+	projects := make([]*models.Project, 0)
+
+	subQuery := repo.db.Model(&models.Role{}).Where("user_id = ?", userID).Select("project_id")
+
+	if err := repo.db.Preload("Roles").Model(&models.Project{}).Where("id IN (?)", subQuery).Find(&projects).Error; err != nil {
+		return nil, err
+	}
+
+	return projects, nil
+}

+ 170 - 0
internal/repository/gorm/project_test.go

@@ -0,0 +1,170 @@
+package gorm_test
+
+import (
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+
+	orm "gorm.io/gorm"
+)
+
+func TestCreateProject(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_proj.db",
+	}
+
+	setupTestEnv(tester, t)
+	defer cleanup(tester, t)
+
+	proj := &models.Project{
+		Name: "project-test",
+	}
+
+	proj, err := tester.repo.Project.CreateProject(proj)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	proj, err = tester.repo.Project.ReadProject(proj.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1 and name is "project-test"
+	if proj.Model.ID != 1 {
+		t.Errorf("incorrect project ID: expected %d, got %d\n", 1, proj.Model.ID)
+	}
+
+	if proj.Name != "project-test" {
+		t.Errorf("incorrect project name: expected %s, got %s\n", "project-test", proj.Name)
+	}
+}
+
+func TestCreateProjectRole(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_proj_role.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	role := &models.Role{
+		Kind:      models.RoleAdmin,
+		UserID:    0,
+		ProjectID: tester.initProjects[0].Model.ID,
+	}
+
+	role, err := tester.repo.Project.CreateProjectRole(tester.initProjects[0], role)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	proj, err := tester.repo.Project.ReadProject(tester.initProjects[0].Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure IDs are correct
+	if proj.Model.ID != 1 {
+		t.Errorf("incorrect project ID: expected %d, got %d\n", 1, proj.Model.ID)
+	}
+
+	if len(proj.Roles) != 1 {
+		t.Fatalf("project roles incorrect length: expected %d, got %d\n", 1, len(proj.Roles))
+	}
+
+	if proj.Roles[0].Model.ID != 1 {
+		t.Fatalf("incorrect role ID: expected %d, got %d\n", 1, proj.Roles[0].Model.ID)
+	}
+
+	// make sure data is correct
+	expProj := &models.Project{
+		Name: "project-test",
+		Roles: []models.Role{
+			models.Role{
+				Kind:      models.RoleAdmin,
+				UserID:    0,
+				ProjectID: 1,
+			},
+		},
+	}
+
+	copyProj := proj
+
+	// reset fields for reflect.DeepEqual
+	copyProj.Model = orm.Model{}
+	copyProj.Roles[0].Model = orm.Model{}
+
+	if diff := deep.Equal(copyProj, expProj); diff != nil {
+		t.Errorf("incorrect project")
+		t.Error(diff)
+	}
+}
+
+func TestListProjectsByUserID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./list_projects_user_id.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	// create two projects, same name
+	initProject(tester, t)
+	initProjectRole(tester, t)
+	initProject(tester, t)
+
+	role := &models.Role{
+		Kind:   models.RoleAdmin,
+		UserID: 1,
+	}
+
+	role, err := tester.repo.Project.CreateProjectRole(tester.initProjects[1], role)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	defer cleanup(tester, t)
+
+	projects, err := tester.repo.Project.ListProjectsByUserID(tester.initUsers[0].Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(projects) != 2 {
+		t.Fatalf("projects length was not 2\n")
+	}
+
+	for i, project := range projects {
+		// make sure data is correct
+		expProj := &models.Project{
+			Name: "project-test",
+			Roles: []models.Role{
+				models.Role{
+					Kind:      models.RoleAdmin,
+					UserID:    tester.initUsers[0].Model.ID,
+					ProjectID: uint(i + 1),
+				},
+			},
+		}
+
+		copyProj := project
+
+		// reset fields for reflect.DeepEqual
+		copyProj.Model = orm.Model{}
+		copyProj.Roles[0].Model = orm.Model{}
+
+		if diff := deep.Equal(copyProj, expProj); diff != nil {
+			t.Errorf("incorrect project")
+			t.Error(diff)
+		}
+	}
+
+}

+ 134 - 0
internal/repository/gorm/repoclient.go

@@ -0,0 +1,134 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// RepoClientRepository uses gorm.DB for querying the database
+type RepoClientRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewRepoClientRepository returns a RepoClientRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewRepoClientRepository(db *gorm.DB, key *[32]byte) repository.RepoClientRepository {
+	return &RepoClientRepository{db, key}
+}
+
+// CreateRepoClient creates a new repo client and appends it to the in-memory list
+func (repo *RepoClientRepository) CreateRepoClient(rc *models.RepoClient) (*models.RepoClient, error) {
+	err := repo.EncryptRepoClientData(rc, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", rc.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("RepoClients")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(rc); err != nil {
+		return nil, err
+	}
+
+	return rc, nil
+}
+
+// ReadRepoClient returns a repo client by id
+func (repo *RepoClientRepository) ReadRepoClient(id uint) (*models.RepoClient, error) {
+	rc := &models.RepoClient{}
+
+	// preload Clusters association
+	if err := repo.db.Where("id = ?", id).First(&rc).Error; err != nil {
+		return nil, err
+	}
+
+	repo.DecryptRepoClientData(rc, repo.key)
+
+	return rc, nil
+}
+
+// ListRepoClientsByProjectID returns a list of repo clients that match a project id
+func (repo *RepoClientRepository) ListRepoClientsByProjectID(projectID uint) ([]*models.RepoClient, error) {
+	rcs := []*models.RepoClient{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&rcs).Error; err != nil {
+		return nil, err
+	}
+
+	for _, rc := range rcs {
+		repo.DecryptRepoClientData(rc, repo.key)
+	}
+
+	return rcs, nil
+}
+
+// EncryptRepoClientData will encrypt the repo client tokens before writing
+// to the DB
+func (repo *RepoClientRepository) EncryptRepoClientData(
+	rc *models.RepoClient,
+	key *[32]byte,
+) error {
+	if rc.AccessToken != "" {
+		cipherData, err := repository.Encrypt([]byte(rc.AccessToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		rc.AccessToken = string(cipherData)
+	}
+
+	if rc.RefreshToken != "" {
+		cipherData, err := repository.Encrypt([]byte(rc.RefreshToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		rc.RefreshToken = string(cipherData)
+	}
+
+	return nil
+}
+
+// DecryptRepoClientData will decrypt the repo client tokens before
+// returning it from the DB
+func (repo *RepoClientRepository) DecryptRepoClientData(
+	rc *models.RepoClient,
+	key *[32]byte,
+) error {
+	if rc.AccessToken != "" {
+		plaintext, err := repository.Decrypt([]byte(rc.AccessToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		rc.AccessToken = string(plaintext)
+	}
+
+	if rc.RefreshToken != "" {
+		plaintext, err := repository.Decrypt([]byte(rc.RefreshToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		rc.RefreshToken = string(plaintext)
+	}
+
+	return nil
+}

+ 112 - 0
internal/repository/gorm/repoclient_test.go

@@ -0,0 +1,112 @@
+package gorm_test
+
+import (
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	orm "gorm.io/gorm"
+)
+
+func TestCreateRepoClient(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_rc.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	repoClient := &models.RepoClient{
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		RepoUserID:   1,
+		Kind:         models.RepoClientGithub,
+		AccessToken:  "accesstoken1234",
+		RefreshToken: "refreshtoken1234",
+	}
+
+	repoClient, err := tester.repo.RepoClient.CreateRepoClient(repoClient)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	repoClient, err = tester.repo.RepoClient.ReadRepoClient(repoClient.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if repoClient.Model.ID != 1 {
+		t.Errorf("incorrect repo client ID: expected %d, got %d\n", 1, repoClient.Model.ID)
+	}
+
+	// make sure data is correct
+	expRepoClient := &models.RepoClient{
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		RepoUserID:   1,
+		Kind:         models.RepoClientGithub,
+		AccessToken:  "accesstoken1234",
+		RefreshToken: "refreshtoken1234",
+	}
+
+	copyRepoClient := repoClient
+
+	// reset fields for reflect.DeepEqual
+	copyRepoClient.Model = orm.Model{}
+
+	if diff := deep.Equal(copyRepoClient, expRepoClient); diff != nil {
+		t.Errorf("incorrect repo client")
+		t.Error(diff)
+	}
+}
+
+func TestListRepoClientsByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_rcs.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	initServiceAccount(tester, t)
+	initRepoClient(tester, t)
+	defer cleanup(tester, t)
+
+	rcs, err := tester.repo.RepoClient.ListRepoClientsByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(rcs) != 1 {
+		t.Fatalf("length of rcs incorrect: expected %d, got %d\n", 1, len(rcs))
+	}
+
+	// make sure data is correct
+	// make sure data is correct
+	expRepoClient := &models.RepoClient{
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		RepoUserID:   1,
+		Kind:         models.RepoClientGithub,
+		AccessToken:  "accesstoken1234",
+		RefreshToken: "refreshtoken1234",
+	}
+
+	copyRepoClient := rcs[0]
+
+	// reset fields for reflect.DeepEqual
+	copyRepoClient.Model = orm.Model{}
+
+	if diff := deep.Equal(copyRepoClient, expRepoClient); diff != nil {
+		t.Errorf("incorrect repo client")
+		t.Error(diff)
+	}
+}

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

@@ -7,9 +7,12 @@ import (
 
 
 // NewRepository returns a Repository which uses
 // NewRepository returns a Repository which uses
 // gorm.DB for querying the database
 // gorm.DB for querying the database
-func NewRepository(db *gorm.DB) *repository.Repository {
+func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 	return &repository.Repository{
 	return &repository.Repository{
-		User:    NewUserRepository(db),
-		Session: NewSessionRepository(db),
+		User:           NewUserRepository(db),
+		Session:        NewSessionRepository(db),
+		Project:        NewProjectRepository(db),
+		ServiceAccount: NewServiceAccountRepository(db, key),
+		RepoClient:     NewRepoClientRepository(db, key),
 	}
 	}
 }
 }

+ 487 - 0
internal/repository/gorm/serviceaccount.go

@@ -0,0 +1,487 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// ServiceAccountRepository uses gorm.DB for querying the database
+type ServiceAccountRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewServiceAccountRepository returns a ServiceAccountRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewServiceAccountRepository(db *gorm.DB, key *[32]byte) repository.ServiceAccountRepository {
+	return &ServiceAccountRepository{db, key}
+}
+
+// CreateServiceAccountCandidate creates a new service account candidate
+func (repo *ServiceAccountRepository) CreateServiceAccountCandidate(
+	saCandidate *models.ServiceAccountCandidate,
+) (*models.ServiceAccountCandidate, error) {
+	err := repo.EncryptServiceAccountCandidateData(saCandidate, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", saCandidate.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("ServiceAccountCandidates")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(saCandidate); err != nil {
+		return nil, err
+	}
+
+	return saCandidate, nil
+}
+
+// ReadServiceAccountCandidate finds a service account candidate by id
+func (repo *ServiceAccountRepository) ReadServiceAccountCandidate(
+	id uint,
+) (*models.ServiceAccountCandidate, error) {
+	saCandidate := &models.ServiceAccountCandidate{}
+
+	if err := repo.db.Preload("Actions").Where("id = ?", id).First(&saCandidate).Error; err != nil {
+		return nil, err
+	}
+
+	repo.DecryptServiceAccountCandidateData(saCandidate, repo.key)
+
+	return saCandidate, nil
+}
+
+// ListServiceAccountCandidatesByProjectID finds all service account candidates
+// for a given project id
+func (repo *ServiceAccountRepository) ListServiceAccountCandidatesByProjectID(
+	projectID uint,
+) ([]*models.ServiceAccountCandidate, error) {
+	saCandidates := []*models.ServiceAccountCandidate{}
+
+	if err := repo.db.Preload("Actions").Where("project_id = ?", projectID).Find(&saCandidates).Error; err != nil {
+		return nil, err
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.DecryptServiceAccountCandidateData(saCandidate, repo.key)
+	}
+
+	return saCandidates, nil
+}
+
+// CreateServiceAccount creates a new servicea account
+func (repo *ServiceAccountRepository) CreateServiceAccount(
+	sa *models.ServiceAccount,
+) (*models.ServiceAccount, error) {
+	err := repo.EncryptServiceAccountData(sa, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", sa.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("ServiceAccounts")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(sa); err != nil {
+		return nil, err
+	}
+
+	return sa, nil
+}
+
+// ReadServiceAccount finds a service account by id
+func (repo *ServiceAccountRepository) ReadServiceAccount(
+	id uint,
+) (*models.ServiceAccount, error) {
+	sa := &models.ServiceAccount{}
+
+	// preload Clusters association
+	if err := repo.db.Preload("Clusters").Where("id = ?", id).First(&sa).Error; err != nil {
+		return nil, err
+	}
+
+	repo.DecryptServiceAccountData(sa, repo.key)
+
+	return sa, nil
+}
+
+// ListServiceAccountsByProjectID finds all service accounts
+// for a given project id
+func (repo *ServiceAccountRepository) ListServiceAccountsByProjectID(
+	projectID uint,
+) ([]*models.ServiceAccount, error) {
+	sas := []*models.ServiceAccount{}
+
+	if err := repo.db.Preload("Clusters").Where("project_id = ?", projectID).Find(&sas).Error; err != nil {
+		return nil, err
+	}
+
+	for _, sa := range sas {
+		repo.DecryptServiceAccountData(sa, repo.key)
+	}
+
+	return sas, nil
+}
+
+// EncryptServiceAccountData will encrypt the user's service account data before writing
+// to the DB
+func (repo *ServiceAccountRepository) EncryptServiceAccountData(
+	sa *models.ServiceAccount,
+	key *[32]byte,
+) error {
+	if len(sa.ClientCertificateData) > 0 {
+		cipherData, err := repository.Encrypt(sa.ClientCertificateData, key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.ClientCertificateData = cipherData
+	}
+
+	if len(sa.ClientKeyData) > 0 {
+		cipherData, err := repository.Encrypt(sa.ClientKeyData, key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.ClientKeyData = cipherData
+	}
+
+	if sa.Token != "" {
+		cipherData, err := repository.Encrypt([]byte(sa.Token), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.Token = string(cipherData)
+	}
+
+	if sa.Username != "" {
+		cipherData, err := repository.Encrypt([]byte(sa.Username), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.Username = string(cipherData)
+	}
+
+	if sa.Password != "" {
+		cipherData, err := repository.Encrypt([]byte(sa.Password), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.Password = string(cipherData)
+	}
+
+	if len(sa.KeyData) > 0 {
+		cipherData, err := repository.Encrypt(sa.KeyData, key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.KeyData = cipherData
+	}
+
+	if sa.PrevToken != "" {
+		cipherData, err := repository.Encrypt([]byte(sa.PrevToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.PrevToken = string(cipherData)
+	}
+
+	if sa.OIDCIssuerURL != "" {
+		cipherData, err := repository.Encrypt([]byte(sa.OIDCIssuerURL), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCIssuerURL = string(cipherData)
+	}
+
+	if sa.OIDCClientID != "" {
+		cipherData, err := repository.Encrypt([]byte(sa.OIDCClientID), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCClientID = string(cipherData)
+	}
+
+	if sa.OIDCClientSecret != "" {
+		cipherData, err := repository.Encrypt([]byte(sa.OIDCClientSecret), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCClientSecret = string(cipherData)
+	}
+
+	if sa.OIDCCertificateAuthorityData != "" {
+		cipherData, err := repository.Encrypt([]byte(sa.OIDCCertificateAuthorityData), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCCertificateAuthorityData = string(cipherData)
+	}
+
+	if sa.OIDCIDToken != "" {
+		cipherData, err := repository.Encrypt([]byte(sa.OIDCIDToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCIDToken = string(cipherData)
+	}
+
+	if sa.OIDCRefreshToken != "" {
+		cipherData, err := repository.Encrypt([]byte(sa.OIDCRefreshToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCRefreshToken = string(cipherData)
+	}
+
+	for i, cluster := range sa.Clusters {
+		if len(cluster.CertificateAuthorityData) > 0 {
+			cipherData, err := repository.Encrypt(cluster.CertificateAuthorityData, key)
+
+			if err != nil {
+				return err
+			}
+
+			cluster.CertificateAuthorityData = cipherData
+			sa.Clusters[i] = cluster
+		}
+	}
+
+	return nil
+}
+
+// EncryptServiceAccountCandidateData will encrypt the service account candidate data before
+// writing to the DB
+func (repo *ServiceAccountRepository) EncryptServiceAccountCandidateData(
+	saCandidate *models.ServiceAccountCandidate,
+	key *[32]byte,
+) error {
+	if len(saCandidate.Kubeconfig) > 0 {
+		cipherData, err := repository.Encrypt(saCandidate.Kubeconfig, key)
+
+		if err != nil {
+			return err
+		}
+
+		saCandidate.Kubeconfig = cipherData
+	}
+
+	return nil
+}
+
+// DecryptServiceAccountData will decrypt the user's service account data before
+// returning it from the DB
+func (repo *ServiceAccountRepository) DecryptServiceAccountData(
+	sa *models.ServiceAccount,
+	key *[32]byte,
+) error {
+	if len(sa.ClientCertificateData) > 0 {
+		plaintext, err := repository.Decrypt(sa.ClientCertificateData, key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.ClientCertificateData = plaintext
+	}
+
+	if len(sa.ClientKeyData) > 0 {
+		plaintext, err := repository.Decrypt(sa.ClientKeyData, key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.ClientKeyData = plaintext
+	}
+
+	if sa.Token != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.Token), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.Token = string(plaintext)
+	}
+
+	if sa.Username != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.Username), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.Username = string(plaintext)
+	}
+
+	if sa.Password != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.Password), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.Password = string(plaintext)
+	}
+
+	if len(sa.KeyData) > 0 {
+		plaintext, err := repository.Decrypt(sa.KeyData, key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.KeyData = plaintext
+	}
+
+	if sa.PrevToken != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.PrevToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.PrevToken = string(plaintext)
+	}
+
+	if sa.OIDCIssuerURL != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.OIDCIssuerURL), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCIssuerURL = string(plaintext)
+	}
+
+	if sa.OIDCClientID != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.OIDCClientID), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCClientID = string(plaintext)
+	}
+
+	if sa.OIDCClientSecret != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.OIDCClientSecret), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCClientSecret = string(plaintext)
+	}
+
+	if sa.OIDCCertificateAuthorityData != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.OIDCCertificateAuthorityData), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCCertificateAuthorityData = string(plaintext)
+	}
+
+	if sa.OIDCIDToken != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.OIDCIDToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCIDToken = string(plaintext)
+	}
+
+	if sa.OIDCRefreshToken != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.OIDCRefreshToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCRefreshToken = string(plaintext)
+	}
+
+	for i, cluster := range sa.Clusters {
+		if len(cluster.CertificateAuthorityData) > 0 {
+			plaintext, err := repository.Decrypt(cluster.CertificateAuthorityData, key)
+
+			if err != nil {
+				return err
+			}
+
+			cluster.CertificateAuthorityData = plaintext
+			sa.Clusters[i] = cluster
+		}
+	}
+
+	return nil
+}
+
+// DecryptServiceAccountCandidateData will decrypt the service account candidate data before
+// returning it from the DB
+func (repo *ServiceAccountRepository) DecryptServiceAccountCandidateData(
+	saCandidate *models.ServiceAccountCandidate,
+	key *[32]byte,
+) error {
+	if len(saCandidate.Kubeconfig) > 0 {
+		plaintext, err := repository.Decrypt(saCandidate.Kubeconfig, key)
+
+		if err != nil {
+			return err
+		}
+
+		saCandidate.Kubeconfig = plaintext
+	}
+
+	return nil
+}

+ 344 - 0
internal/repository/gorm/serviceaccount_test.go

@@ -0,0 +1,344 @@
+package gorm_test
+
+import (
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	orm "gorm.io/gorm"
+)
+
+func TestCreateServiceAccountCandidate(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_sa_candidate.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	saCandidate := &models.ServiceAccountCandidate{
+		ProjectID:       1,
+		Kind:            "connector",
+		ClusterName:     "cluster-test",
+		ClusterEndpoint: "https://localhost",
+		AuthMechanism:   models.X509,
+		Kubeconfig:      []byte("current-context: testing\n"),
+	}
+
+	saCandidate, err := tester.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	saCandidate, err = tester.repo.ServiceAccount.ReadServiceAccountCandidate(saCandidate.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if saCandidate.Model.ID != 1 {
+		t.Errorf("incorrect service accound candidate ID: expected %d, got %d\n", 1, saCandidate.Model.ID)
+	}
+
+	// make sure data is correct
+	expSACandidate := &models.ServiceAccountCandidate{
+		ProjectID:       1,
+		Kind:            "connector",
+		ClusterName:     "cluster-test",
+		ClusterEndpoint: "https://localhost",
+		AuthMechanism:   models.X509,
+		Kubeconfig:      []byte("current-context: testing\n"),
+		Actions:         []models.ServiceAccountAction{},
+	}
+
+	copySACandidate := saCandidate
+
+	// reset fields for reflect.DeepEqual
+	copySACandidate.Model = orm.Model{}
+
+	if diff := deep.Equal(copySACandidate, expSACandidate); diff != nil {
+		t.Errorf("incorrect sa candidate")
+		t.Error(diff)
+	}
+}
+
+func TestCreateServiceAccountCandidateWithAction(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_sa_candidate_w_action.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initServiceAccountCandidate(tester, t)
+	defer cleanup(tester, t)
+
+	saCandidate := tester.initSACandidates[0]
+
+	saCandidate, err := tester.repo.ServiceAccount.ReadServiceAccountCandidate(saCandidate.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure IDs are correct
+	if saCandidate.Model.ID != 1 {
+		t.Errorf("incorrect service account candidate ID: expected %d, got %d\n", 1, saCandidate.Model.ID)
+	}
+
+	if len(saCandidate.Actions) != 1 {
+		t.Errorf("incorrect actions length: expected %d, got %d\n", 1, len(saCandidate.Actions))
+	}
+
+	if saCandidate.Actions[0].Model.ID != 1 {
+		t.Errorf("incorrect actions ID: expected %d, got %d\n", 1, saCandidate.Actions[0].Model.ID)
+	}
+
+	// make sure data is correct
+	expSACandidate := &models.ServiceAccountCandidate{
+		ProjectID:       1,
+		Kind:            "connector",
+		ClusterName:     "cluster-test",
+		ClusterEndpoint: "https://localhost",
+		AuthMechanism:   models.X509,
+		Kubeconfig:      []byte("current-context: testing\n"),
+		Actions: []models.ServiceAccountAction{
+			models.ServiceAccountAction{
+				ServiceAccountCandidateID: 1,
+				Name:                      models.TokenDataAction,
+				Resolved:                  false,
+			},
+		},
+	}
+
+	copySACandidate := saCandidate
+
+	// reset fields for reflect.DeepEqual
+	copySACandidate.Model = orm.Model{}
+
+	copySACandidate.Actions[0].Model = orm.Model{}
+
+	if diff := deep.Equal(copySACandidate, expSACandidate); diff != nil {
+		t.Errorf("incorrect sa candidate")
+		t.Error(diff)
+	}
+}
+
+func TestListServiceAccountCandidatesByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_sa_candidates.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initServiceAccountCandidate(tester, t)
+	defer cleanup(tester, t)
+
+	saCandidates, err := tester.repo.ServiceAccount.ListServiceAccountCandidatesByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(saCandidates) != 1 {
+		t.Fatalf("length of sa candidates incorrect: expected %d, got %d\n", 1, len(saCandidates))
+	}
+
+	// make sure data is correct
+	expSACandidate := &models.ServiceAccountCandidate{
+		ProjectID:       1,
+		Kind:            "connector",
+		ClusterName:     "cluster-test",
+		ClusterEndpoint: "https://localhost",
+		AuthMechanism:   models.X509,
+		Kubeconfig:      []byte("current-context: testing\n"),
+		Actions: []models.ServiceAccountAction{
+			models.ServiceAccountAction{
+				ServiceAccountCandidateID: 1,
+				Name:                      models.TokenDataAction,
+				Resolved:                  false,
+			},
+		},
+	}
+
+	copySACandidate := saCandidates[0]
+
+	// reset fields for reflect.DeepEqual
+	copySACandidate.Model = orm.Model{}
+	copySACandidate.Actions[0].Model = orm.Model{}
+
+	if diff := deep.Equal(copySACandidate, expSACandidate); diff != nil {
+		t.Errorf("incorrect sa candidate")
+		t.Error(diff)
+	}
+}
+
+func TestCreateServiceAccount(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_sa.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	sa := &models.ServiceAccount{
+		ProjectID:             1,
+		Kind:                  "connector",
+		AuthMechanism:         models.X509,
+		ClientCertificateData: []byte("-----BEGIN"),
+		ClientKeyData:         []byte("-----BEGIN"),
+	}
+
+	sa, err := tester.repo.ServiceAccount.CreateServiceAccount(sa)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err = tester.repo.ServiceAccount.ReadServiceAccount(sa.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if sa.Model.ID != 1 {
+		t.Errorf("incorrect service account ID: expected %d, got %d\n", 1, sa.Model.ID)
+	}
+
+	// make sure data is correct
+	expSA := &models.ServiceAccount{
+		ProjectID:             1,
+		Kind:                  "connector",
+		AuthMechanism:         models.X509,
+		ClientCertificateData: []byte("-----BEGIN"),
+		ClientKeyData:         []byte("-----BEGIN"),
+		Clusters:              []models.Cluster{},
+	}
+
+	copySA := sa
+
+	// reset fields for reflect.DeepEqual
+	copySA.Model = orm.Model{}
+
+	if diff := deep.Equal(copySA, expSA); diff != nil {
+		t.Errorf("incorrect service account")
+		t.Error(diff)
+	}
+}
+
+func TestCreateServiceAccountWithCluster(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_sa_w_cluster.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initServiceAccount(tester, t)
+	defer cleanup(tester, t)
+
+	sa := tester.initSAs[0]
+
+	sa, err := tester.repo.ServiceAccount.ReadServiceAccount(sa.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if sa.Model.ID != 1 {
+		t.Errorf("incorrect service account ID: expected %d, got %d\n", 1, sa.Model.ID)
+	}
+
+	if len(sa.Clusters) != 1 {
+		t.Errorf("incorrect clusters length: expected %d, got %d\n", 1, len(sa.Clusters))
+	}
+
+	if sa.Clusters[0].Model.ID != 1 {
+		t.Errorf("incorrect clusters ID: expected %d, got %d\n", 1, sa.Clusters[0].Model.ID)
+	}
+
+	// make sure data is correct
+	expSA := &models.ServiceAccount{
+		ProjectID:             1,
+		Kind:                  "connector",
+		AuthMechanism:         models.X509,
+		ClientCertificateData: []byte("-----BEGIN"),
+		ClientKeyData:         []byte("-----BEGIN"),
+		Clusters: []models.Cluster{
+			models.Cluster{
+				ServiceAccountID:         1,
+				Name:                     "cluster-test",
+				Server:                   "https://localhost",
+				CertificateAuthorityData: []byte("-----BEGIN"),
+			},
+		},
+	}
+
+	copySA := sa
+
+	// reset fields for reflect.DeepEqual
+	copySA.Model = orm.Model{}
+	copySA.Clusters[0].Model = orm.Model{}
+
+	if diff := deep.Equal(copySA, expSA); diff != nil {
+		t.Errorf("incorrect service account")
+		t.Error(diff)
+	}
+}
+
+func TestListServiceAccountsByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_sas.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initServiceAccount(tester, t)
+	defer cleanup(tester, t)
+
+	sas, err := tester.repo.ServiceAccount.ListServiceAccountsByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(sas) != 1 {
+		t.Fatalf("length of sas incorrect: expected %d, got %d\n", 1, len(sas))
+	}
+
+	// make sure data is correct
+	expSA := &models.ServiceAccount{
+		ProjectID:             1,
+		Kind:                  "connector",
+		AuthMechanism:         models.X509,
+		ClientCertificateData: []byte("-----BEGIN"),
+		ClientKeyData:         []byte("-----BEGIN"),
+		Clusters: []models.Cluster{
+			models.Cluster{
+				ServiceAccountID:         1,
+				Name:                     "cluster-test",
+				Server:                   "https://localhost",
+				CertificateAuthorityData: []byte("-----BEGIN"),
+			},
+		},
+	}
+
+	copySA := sas[0]
+
+	// reset fields for reflect.DeepEqual
+	copySA.Model = orm.Model{}
+	copySA.Clusters[0].Model = orm.Model{}
+
+	if diff := deep.Equal(copySA, expSA); diff != nil {
+		t.Errorf("incorrect service account")
+		t.Error(diff)
+	}
+}

+ 1 - 1
internal/repository/gorm/user.go

@@ -55,7 +55,7 @@ func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error)
 
 
 // DeleteUser deletes a single user using their unique id
 // DeleteUser deletes a single user using their unique id
 func (repo *UserRepository) DeleteUser(user *models.User) (*models.User, error) {
 func (repo *UserRepository) DeleteUser(user *models.User) (*models.User, error) {
-	if err := repo.db.First(&models.User{}, user.ID).Delete(&user).Error; err != nil {
+	if err := repo.db.Delete(&user).Error; err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 	return user, nil
 	return user, nil

+ 16 - 0
internal/repository/project.go

@@ -0,0 +1,16 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// WriteProject is the function type for all Project write operations
+type WriteProject func(project *models.Project) (*models.Project, error)
+
+// ProjectRepository represents the set of queries on the Project model
+type ProjectRepository interface {
+	CreateProject(project *models.Project) (*models.Project, error)
+	CreateProjectRole(project *models.Project, role *models.Role) (*models.Role, error)
+	ReadProject(id uint) (*models.Project, error)
+	ListProjectsByUserID(userID uint) ([]*models.Project, error)
+}

+ 11 - 0
internal/repository/repoclient.go

@@ -0,0 +1,11 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// RepoClientRepository represents the set of queries on the
+// RepoClient model
+type RepoClientRepository interface {
+	CreateRepoClient(rc *models.RepoClient) (*models.RepoClient, error)
+	ReadRepoClient(id uint) (*models.RepoClient, error)
+	ListRepoClientsByProjectID(projectID uint) ([]*models.RepoClient, error)
+}

+ 5 - 2
internal/repository/repository.go

@@ -2,6 +2,9 @@ package repository
 
 
 // Repository collects the repositories for each model
 // Repository collects the repositories for each model
 type Repository struct {
 type Repository struct {
-	User    UserRepository
-	Session SessionRepository
+	User           UserRepository
+	Project        ProjectRepository
+	Session        SessionRepository
+	ServiceAccount ServiceAccountRepository
+	RepoClient     RepoClientRepository
 }
 }

+ 16 - 0
internal/repository/serviceaccount.go

@@ -0,0 +1,16 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// ServiceAccountRepository represents the set of queries on the
+// ServiceAccount model
+type ServiceAccountRepository interface {
+	CreateServiceAccountCandidate(saCandidate *models.ServiceAccountCandidate) (*models.ServiceAccountCandidate, error)
+	ReadServiceAccountCandidate(id uint) (*models.ServiceAccountCandidate, error)
+	ListServiceAccountCandidatesByProjectID(projectID uint) ([]*models.ServiceAccountCandidate, error)
+	CreateServiceAccount(sa *models.ServiceAccount) (*models.ServiceAccount, error)
+	ReadServiceAccount(id uint) (*models.ServiceAccount, error)
+	ListServiceAccountsByProjectID(projectID uint) ([]*models.ServiceAccount, error)
+}

+ 86 - 0
internal/repository/test/project.go

@@ -0,0 +1,86 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// ProjectRepository will return errors on queries if canQuery is false
+// and only stores a small set of projects in-memory that are indexed by their
+// array index + 1
+type ProjectRepository struct {
+	canQuery bool
+	projects []*models.Project
+}
+
+// NewProjectRepository will return errors if canQuery is false
+func NewProjectRepository(canQuery bool) repository.ProjectRepository {
+	return &ProjectRepository{canQuery, []*models.Project{}}
+}
+
+// CreateProject appends a new project to the in-memory projects array
+func (repo *ProjectRepository) CreateProject(project *models.Project) (*models.Project, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.projects = append(repo.projects, project)
+	project.ID = uint(len(repo.projects))
+
+	return project, nil
+}
+
+// CreateProjectRole appends a role to the existing array of roles
+func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *models.Role) (*models.Role, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(project.ID-1) >= len(repo.projects) || repo.projects[project.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(project.ID - 1)
+	oldProject := *repo.projects[index]
+	repo.projects[index] = project
+	project.Roles = append(oldProject.Roles, *role)
+
+	return role, nil
+}
+
+// ReadProject gets a projects specified by a unique id
+func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.projects) || repo.projects[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.projects[index], nil
+}
+
+// ListProjectsByUserID lists projects where a user has an associated role
+func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Project, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	resp := make([]*models.Project, 0)
+
+	// find all roles matching
+	for _, project := range repo.projects {
+		for _, role := range project.Roles {
+			if role.UserID == userID {
+				resp = append(resp, project)
+			}
+		}
+	}
+
+	return resp, nil
+}

+ 66 - 0
internal/repository/test/repoclient.go

@@ -0,0 +1,66 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// RepoClientRepository implements repository.RepoClientRepository
+type RepoClientRepository struct {
+	canQuery    bool
+	repoClients []*models.RepoClient
+}
+
+// NewRepoClientRepository will return errors if canQuery is false
+func NewRepoClientRepository(canQuery bool) repository.RepoClientRepository {
+	return &RepoClientRepository{
+		canQuery,
+		[]*models.RepoClient{},
+	}
+}
+
+// CreateRepoClient creates a new repo client and appends it to the in-memory list
+func (repo *RepoClientRepository) CreateRepoClient(rc *models.RepoClient) (*models.RepoClient, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.repoClients = append(repo.repoClients, rc)
+	rc.ID = uint(len(repo.repoClients))
+
+	return rc, nil
+}
+
+// ReadRepoClient returns a repo client by id
+func (repo *RepoClientRepository) ReadRepoClient(id uint) (*models.RepoClient, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.repoClients) || repo.repoClients[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.repoClients[index], nil
+}
+
+// ListRepoClientsByProjectID returns a list of repo clients that match a project id
+func (repo *RepoClientRepository) ListRepoClientsByProjectID(projectID uint) ([]*models.RepoClient, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*models.RepoClient, 0)
+
+	for _, rc := range repo.repoClients {
+		if rc.ProjectID == projectID {
+			res = append(res, rc)
+		}
+	}
+
+	return res, nil
+}

+ 4 - 2
internal/repository/test/repository.go

@@ -8,7 +8,9 @@ import (
 // and accepts a parameter that can trigger read/write errors
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool) *repository.Repository {
 func NewRepository(canQuery bool) *repository.Repository {
 	return &repository.Repository{
 	return &repository.Repository{
-		User:    NewUserRepository(canQuery),
-		Session: NewSessionRepository(canQuery),
+		User:           NewUserRepository(canQuery),
+		Session:        NewSessionRepository(canQuery),
+		Project:        NewProjectRepository(canQuery),
+		ServiceAccount: NewServiceAccountRepository(canQuery),
 	}
 	}
 }
 }

+ 154 - 0
internal/repository/test/serviceaccount.go

@@ -0,0 +1,154 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// ServiceAccountRepository implements repository.ServiceAccountRepository
+type ServiceAccountRepository struct {
+	canQuery                 bool
+	serviceAccountCandidates []*models.ServiceAccountCandidate
+	serviceAccounts          []*models.ServiceAccount
+	clusters                 []*models.Cluster
+}
+
+// NewServiceAccountRepository will return errors if canQuery is false
+func NewServiceAccountRepository(canQuery bool) repository.ServiceAccountRepository {
+	return &ServiceAccountRepository{
+		canQuery,
+		[]*models.ServiceAccountCandidate{},
+		[]*models.ServiceAccount{},
+		[]*models.Cluster{},
+	}
+}
+
+// CreateServiceAccountCandidate creates a new service account candidate
+func (repo *ServiceAccountRepository) CreateServiceAccountCandidate(
+	saCandidate *models.ServiceAccountCandidate,
+) (*models.ServiceAccountCandidate, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.serviceAccountCandidates = append(repo.serviceAccountCandidates, saCandidate)
+	saCandidate.ID = uint(len(repo.serviceAccountCandidates))
+
+	return saCandidate, nil
+}
+
+// ReadServiceAccountCandidate finds a service account candidate by id
+func (repo *ServiceAccountRepository) ReadServiceAccountCandidate(
+	id uint,
+) (*models.ServiceAccountCandidate, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.serviceAccountCandidates) || repo.serviceAccountCandidates[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.serviceAccountCandidates[index], nil
+}
+
+// ListServiceAccountCandidatesByProjectID finds all service account candidates
+// for a given project id
+func (repo *ServiceAccountRepository) ListServiceAccountCandidatesByProjectID(
+	projectID uint,
+) ([]*models.ServiceAccountCandidate, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*models.ServiceAccountCandidate, 0)
+
+	for _, saCandidate := range repo.serviceAccountCandidates {
+		if saCandidate.ProjectID == projectID {
+			res = append(res, saCandidate)
+		}
+	}
+
+	return res, nil
+}
+
+// CreateServiceAccount creates a new servicea account
+func (repo *ServiceAccountRepository) CreateServiceAccount(
+	sa *models.ServiceAccount,
+) (*models.ServiceAccount, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if sa == nil {
+		return nil, nil
+	}
+
+	repo.serviceAccounts = append(repo.serviceAccounts, sa)
+	sa.ID = uint(len(repo.serviceAccounts))
+
+	for i, cluster := range sa.Clusters {
+		(&cluster).ServiceAccountID = sa.ID
+		clusterP, _ := repo.createCluster(&cluster)
+		sa.Clusters[i] = *clusterP
+	}
+
+	return sa, nil
+}
+
+// ReadServiceAccount finds a service account by id
+func (repo *ServiceAccountRepository) ReadServiceAccount(
+	id uint,
+) (*models.ServiceAccount, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.serviceAccounts) || repo.serviceAccounts[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.serviceAccounts[index], nil
+}
+
+// ListServiceAccountsByProjectID finds all service accounts
+// for a given project id
+func (repo *ServiceAccountRepository) ListServiceAccountsByProjectID(
+	projectID uint,
+) ([]*models.ServiceAccount, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*models.ServiceAccount, 0)
+
+	for _, sa := range repo.serviceAccounts {
+		if sa.ProjectID == projectID {
+			res = append(res, sa)
+		}
+	}
+
+	return res, nil
+}
+
+func (repo *ServiceAccountRepository) createCluster(
+	cluster *models.Cluster,
+) (*models.Cluster, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if cluster == nil {
+		return nil, nil
+	}
+
+	repo.clusters = append(repo.clusters, cluster)
+	cluster.ID = uint(len(repo.clusters))
+
+	return cluster, nil
+}

+ 1 - 1
internal/repository/test/user.go

@@ -17,7 +17,7 @@ type UserRepository struct {
 	users    []*models.User
 	users    []*models.User
 }
 }
 
 
-// NewUserRepository will return errors
+// NewUserRepository will return errors if canQuery is false
 func NewUserRepository(canQuery bool) repository.UserRepository {
 func NewUserRepository(canQuery bool) repository.UserRepository {
 	return &UserRepository{canQuery, []*models.User{}}
 	return &UserRepository{canQuery, []*models.User{}}
 }
 }

+ 31 - 16
server/api/api.go

@@ -4,6 +4,9 @@ import (
 	"github.com/go-playground/locales/en"
 	"github.com/go-playground/locales/en"
 	ut "github.com/go-playground/universal-translator"
 	ut "github.com/go-playground/universal-translator"
 	"github.com/go-playground/validator/v10"
 	"github.com/go-playground/validator/v10"
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
 
 
 	"github.com/gorilla/sessions"
 	"github.com/gorilla/sessions"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm"
@@ -23,24 +26,28 @@ type TestAgents struct {
 // App represents an API instance with handler methods attached, a DB connection
 // App represents an API instance with handler methods attached, a DB connection
 // and a logger instance
 // and a logger instance
 type App struct {
 type App struct {
-	logger     *lr.Logger
-	repo       *repository.Repository
-	validator  *validator.Validate
-	store      sessions.Store
-	translator *ut.Translator
-	cookieName string
-	testing    bool
-	TestAgents *TestAgents
+	db           *gorm.DB
+	logger       *lr.Logger
+	repo         *repository.Repository
+	validator    *validator.Validate
+	store        sessions.Store
+	translator   *ut.Translator
+	cookieName   string
+	testing      bool
+	TestAgents   *TestAgents
+	GithubConfig *oauth2.Config
 }
 }
 
 
 // New returns a new App instance
 // New returns a new App instance
 func New(
 func New(
 	logger *lr.Logger,
 	logger *lr.Logger,
+	db *gorm.DB,
 	repo *repository.Repository,
 	repo *repository.Repository,
 	validator *validator.Validate,
 	validator *validator.Validate,
 	store sessions.Store,
 	store sessions.Store,
 	cookieName string,
 	cookieName string,
 	testing bool,
 	testing bool,
+	githubConfig *oauth.Config,
 ) *App {
 ) *App {
 	// for now, will just support the english translator from the
 	// for now, will just support the english translator from the
 	// validator/translations package
 	// validator/translations package
@@ -60,15 +67,23 @@ func New(
 		}
 		}
 	}
 	}
 
 
+	var oauthGithubConf *oauth2.Config
+
+	if githubConfig != nil {
+		oauthGithubConf = oauth.NewGithubClient(githubConfig)
+	}
+
 	return &App{
 	return &App{
-		logger:     logger,
-		repo:       repo,
-		validator:  validator,
-		store:      store,
-		translator: &trans,
-		cookieName: cookieName,
-		testing:    testing,
-		TestAgents: testAgents,
+		db:           db,
+		logger:       logger,
+		repo:         repo,
+		validator:    validator,
+		store:        store,
+		translator:   &trans,
+		cookieName:   cookieName,
+		testing:      testing,
+		TestAgents:   testAgents,
+		GithubConfig: oauthGithubConf,
 	}
 	}
 }
 }
 
 

+ 44 - 0
server/api/health_handler.go

@@ -0,0 +1,44 @@
+package api
+
+import (
+	"net/http"
+)
+
+// HandleLive responds immediately with an HTTP 200 status.
+func (app *App) HandleLive(w http.ResponseWriter, r *http.Request) {
+	writeHealthy(w)
+}
+
+// HandleReady responds with HTTP 200 if healthy, 500 otherwise
+func (app *App) HandleReady(w http.ResponseWriter, r *http.Request) {
+	if app.db == nil {
+		writeHealthy(w)
+		return
+	}
+
+	db, err := app.db.DB()
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := db.Ping(); err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	writeHealthy(w)
+}
+
+func writeHealthy(w http.ResponseWriter) {
+	w.Header().Set("Content-Type", "text/plain")
+	w.WriteHeader(http.StatusOK)
+	w.Write([]byte("."))
+}
+
+func writeUnhealthy(w http.ResponseWriter) {
+	w.Header().Set("Content-Type", "text/plain")
+	w.WriteHeader(http.StatusInternalServerError)
+	w.Write([]byte("."))
+}

+ 2 - 2
server/api/helpers_test.go

@@ -79,8 +79,8 @@ func newTester(canQuery bool) *tester {
 	repo := test.NewRepository(canQuery)
 	repo := test.NewRepository(canQuery)
 
 
 	store, _ := sessionstore.NewStore(repo, appConf.Server)
 	store, _ := sessionstore.NewStore(repo, appConf.Server)
-	app := api.New(logger, repo, validator, store, appConf.Server.CookieName, true)
-	r := router.New(app, store, appConf.Server.CookieName, appConf.Server.StaticFilePath)
+	app := api.New(logger, nil, repo, validator, store, appConf.Server.CookieName, true, nil)
+	r := router.New(app, store, appConf.Server.CookieName, appConf.Server.StaticFilePath, repo)
 
 
 	return &tester{
 	return &tester{
 		app:    app,
 		app:    app,

+ 1 - 11
server/api/k8s_handler.go

@@ -18,13 +18,6 @@ const (
 
 
 // HandleListNamespaces retrieves a list of namespaces
 // HandleListNamespaces retrieves a list of namespaces
 func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
-	session, err := app.store.Get(r, app.cookieName)
-
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
-		return
-	}
-
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 
 
 	if err != nil {
 	if err != nil {
@@ -36,11 +29,8 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	form := &forms.K8sForm{
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{},
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{},
 	}
 	}
-	form.PopulateK8sOptionsFromQueryParams(vals)
 
 
-	if sessID, ok := session.Values["user_id"].(uint); ok {
-		form.PopulateK8sOptionsFromUserID(sessID, app.repo.User)
-	}
+	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.ServiceAccount)
 
 
 	// validate the form
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
 	if err := app.validator.Struct(form); err != nil {

+ 5 - 2
server/api/k8s_handler_test.go

@@ -79,8 +79,9 @@ var listNamespacesTests = []*k8sTest{
 		},
 		},
 		msg:    "List namespaces",
 		msg:    "List namespaces",
 		method: "GET",
 		method: "GET",
-		endpoint: "/api/k8s/namespaces?" + url.Values{
-			"context": []string{"context-test"},
+		endpoint: "/api/projects/1/k8s/namespaces?" + url.Values{
+			"service_account_id": []string{"1"},
+			"cluster_id":         []string{"1"},
 		}.Encode(),
 		}.Encode(),
 		body:      "",
 		body:      "",
 		expStatus: http.StatusOK,
 		expStatus: http.StatusOK,
@@ -113,6 +114,8 @@ var defaultObjects = []runtime.Object{
 
 
 func initDefaultK8s(tester *tester) {
 func initDefaultK8s(tester *tester) {
 	initUserDefault(tester)
 	initUserDefault(tester)
+	initProject(tester)
+	initProjectSADefault(tester)
 
 
 	agent := kubernetes.GetAgentTesting(defaultObjects...)
 	agent := kubernetes.GetAgentTesting(defaultObjects...)
 
 

+ 178 - 0
server/api/oauth_github_handler.go

@@ -0,0 +1,178 @@
+package api
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/porter-dev/porter/internal/models"
+
+	"github.com/go-chi/chi"
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+)
+
+// HandleGithubOAuthStartUser starts the oauth2 flow for a user login request.
+func (app *App) HandleGithubOAuthStartUser(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	err := app.populateOAuthSession(w, r, state, false)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := app.GithubConfig.AuthCodeURL(state, oauth2.AccessTypeOnline)
+
+	http.Redirect(w, r, url, 302)
+}
+
+// HandleGithubOAuthStartProject starts the oauth2 flow for a project repo request.
+// In this handler, the project id gets written to the session (along with the oauth
+// state param), so that the correct project id can be identified in the callback.
+func (app *App) HandleGithubOAuthStartProject(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	err := app.populateOAuthSession(w, r, state, true)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := app.GithubConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
+
+	http.Redirect(w, r, url, 302)
+}
+
+// HandleGithubOAuthCallback verifies the callback request by checking that the
+// state parameter has not been modified, and validates the token.
+// There is a difference between the oauth flow when logging a user in, and when
+// linking a repository.
+//
+// When logging a user in, the access token gets stored in the session, and no refresh
+// token is requested. We store the access token in the session because a user can be
+// logged in multiple times with a single access token.
+//
+// NOTE: this user flow will likely be augmented with Dex, or entirely replaced with Dex.
+//
+// However, when linking a repository, the access token and refresh token are requested when
+// the flow has started. A project also gets linked to the session. After callback, a new
+// github config gets stored for the project, and the user will then get redirected to
+// a URL that allows them to select their repositories they'd like to link. We require a refresh
+// token because we need permanent access to the linked repository.
+func (app *App) HandleGithubOAuthCallback(w http.ResponseWriter, r *http.Request) {
+	session, err := app.store.Get(r, app.cookieName)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Could not read cookie: are cookies enabled?",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	token, err := app.GithubConfig.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	if !token.Valid() {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+	projID, _ := session.Values["project_id"].(uint)
+
+	app.updateProjectFromToken(projID, userID, token)
+
+	// TODO -- custom redirect URI
+	http.Redirect(w, r, "/", 302)
+}
+
+func (app *App) populateOAuthSession(w http.ResponseWriter, r *http.Request, state string, isProject bool) error {
+	session, err := app.store.Get(r, app.cookieName)
+
+	if err != nil {
+		return err
+	}
+
+	// need state parameter to validate when redirected
+	session.Values["state"] = state
+
+	if isProject {
+		// read the project id and add it to the session
+		projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+		if err != nil || projID == 0 {
+			return fmt.Errorf("could not read project id")
+		}
+
+		session.Values["project_id"] = projID
+	}
+
+	if err := session.Save(r, w); err != nil {
+		app.logger.Warn().Err(err)
+	}
+
+	return nil
+}
+
+func (app *App) upsertUserFromToken() error {
+	return fmt.Errorf("UNIMPLEMENTED")
+}
+
+// updates a project's repository clients with the token information.
+func (app *App) updateProjectFromToken(projectID uint, userID uint, tok *oauth2.Token) error {
+	// get the list of repositories that this token has access to
+	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
+
+	user, _, err := client.Users.Get(context.Background(), "")
+
+	if err != nil {
+		return err
+	}
+
+	repoClient := &models.RepoClient{
+		ProjectID:    projectID,
+		UserID:       userID,
+		RepoUserID:   uint(user.GetID()),
+		Kind:         models.RepoClientGithub,
+		AccessToken:  tok.AccessToken,
+		RefreshToken: tok.RefreshToken,
+	}
+
+	repoClient, err = app.repo.RepoClient.CreateRepoClient(repoClient)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}

+ 389 - 0
server/api/project_handler.go

@@ -0,0 +1,389 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// Enumeration of user API error codes, represented as int64
+const (
+	ErrProjectDecode ErrorCode = iota + 600
+	ErrProjectValidateFields
+	ErrProjectDataRead
+)
+
+// HandleCreateProject validates a project form entry, converts the project to a gorm
+// model, and saves the user to the database
+func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
+	session, err := app.store.Get(r, app.cookieName)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	form := &forms.CreateProjectForm{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to a project model
+	projModel, err := form.ToProject(app.repo.Project)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	projModel, err = app.repo.Project.CreateProject(projModel)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	// create a new Role with the user as the admin
+	_, err = app.repo.Project.CreateProjectRole(projModel, &models.Role{
+		UserID:    userID,
+		ProjectID: projModel.ID,
+		Kind:      models.RoleAdmin,
+	})
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.logger.Info().Msgf("New project created: %d", projModel.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	projExt := projModel.Externalize()
+
+	if err := json.NewEncoder(w).Encode(projExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleReadProject returns an externalized Project (models.ProjectExternal)
+// based on an ID
+func (app *App) HandleReadProject(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	proj, err := app.repo.Project.ReadProject(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	projExt := proj.Externalize()
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(projExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleListProjectClusters returns a list of clusters that have linked ServiceAccounts.
+// If multiple service accounts exist for a cluster, the service account created later
+// will take precedence. This may be changed in a future release to return multiple
+// service accounts.
+func (app *App) HandleListProjectClusters(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	sas, err := app.repo.ServiceAccount.ListServiceAccountsByProjectID(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	clusters := make([]*models.ClusterExternal, 0)
+
+	// clusterMapIndex used for checking if cluster has already been added
+	// maps from the cluster's endpoint to the index in the cluster array
+	clusterMapIndex := make(map[string]int)
+
+	for _, sa := range sas {
+		for _, cluster := range sa.Clusters {
+			if currIndex, ok := clusterMapIndex[cluster.Server]; ok {
+				if clusters[currIndex].ServiceAccountID <= cluster.ServiceAccountID {
+					clusters[currIndex] = cluster.Externalize()
+					continue
+				}
+			}
+
+			clusterMapIndex[cluster.Server] = len(clusters)
+			clusters = append(clusters, cluster.Externalize())
+		}
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(clusters); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleCreateProjectSACandidates handles the creation of ServiceAccountCandidates
+// using a kubeconfig and a project id
+func (app *App) HandleCreateProjectSACandidates(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateServiceAccountCandidatesForm{
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to a ServiceAccountCandidate
+	saCandidates, err := form.ToServiceAccountCandidates()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	extSACandidates := make([]*models.ServiceAccountCandidateExternal, 0)
+
+	for _, saCandidate := range saCandidates {
+		// handle write to the database
+		saCandidate, err = app.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+
+		if err != nil {
+			app.handleErrorDataWrite(err, w)
+			return
+		}
+
+		app.logger.Info().Msgf("New service account candidate created: %d", saCandidate.ID)
+
+		// if the SA candidate does not have any actions to perform, create the ServiceAccount
+		// automatically
+		if len(saCandidate.Actions) == 0 {
+			saForm := &forms.ServiceAccountActionResolver{
+				ServiceAccountCandidateID: saCandidate.ID,
+				SACandidate:               saCandidate,
+			}
+
+			err := saForm.PopulateServiceAccount(app.repo.ServiceAccount)
+
+			if err != nil {
+				app.handleErrorDataWrite(err, w)
+				return
+			}
+
+			sa, err := app.repo.ServiceAccount.CreateServiceAccount(saForm.SA)
+
+			if err != nil {
+				app.handleErrorDataWrite(err, w)
+				return
+			}
+
+			app.logger.Info().Msgf("New service account created: %d", sa.ID)
+		}
+
+		extSACandidates = append(extSACandidates, saCandidate.Externalize())
+	}
+
+	w.WriteHeader(http.StatusCreated)
+
+	if err := json.NewEncoder(w).Encode(extSACandidates); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleListProjectSACandidates returns a list of externalized ServiceAccountCandidate
+// ([]models.ServiceAccountCandidateExternal) based on a project ID
+func (app *App) HandleListProjectSACandidates(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	saCandidates, err := app.repo.ServiceAccount.ListServiceAccountCandidatesByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	extSACandidates := make([]*models.ServiceAccountCandidateExternal, 0)
+
+	for _, saCandidate := range saCandidates {
+		extSACandidates = append(extSACandidates, saCandidate.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(extSACandidates); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleResolveSACandidateActions accepts a list of action configurations for a
+// given ServiceAccountCandidate, which "resolves" that ServiceAccountCandidate
+// and creates a ServiceAccount for a specific project
+func (app *App) HandleResolveSACandidateActions(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	candID, err := strconv.ParseUint(chi.URLParam(r, "candidate_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// decode actions from request
+	actions := make([]*models.ServiceAccountAllActions, 0)
+
+	if err := json.NewDecoder(r.Body).Decode(&actions); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	var saResolverBase *forms.ServiceAccountActionResolver = &forms.ServiceAccountActionResolver{
+		ServiceAccountCandidateID: uint(candID),
+		SA:                        nil,
+		SACandidate:               nil,
+	}
+
+	// for each action, create the relevant form and populate the service account
+	// we'll chain the .PopulateServiceAccount functions
+	for _, action := range actions {
+		var err error
+		switch action.Name {
+		case models.ClusterCADataAction:
+			form := &forms.ClusterCADataAction{
+				ServiceAccountActionResolver: saResolverBase,
+				ClusterCAData:                action.ClusterCAData,
+			}
+
+			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
+		case models.ClientCertDataAction:
+			form := &forms.ClientCertDataAction{
+				ServiceAccountActionResolver: saResolverBase,
+				ClientCertData:               action.ClientCertData,
+			}
+
+			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
+		case models.ClientKeyDataAction:
+			form := &forms.ClientKeyDataAction{
+				ServiceAccountActionResolver: saResolverBase,
+				ClientKeyData:                action.ClientKeyData,
+			}
+
+			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
+		case models.OIDCIssuerDataAction:
+			form := &forms.OIDCIssuerDataAction{
+				ServiceAccountActionResolver: saResolverBase,
+				OIDCIssuerCAData:             action.OIDCIssuerCAData,
+			}
+
+			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
+		case models.TokenDataAction:
+			form := &forms.TokenDataAction{
+				ServiceAccountActionResolver: saResolverBase,
+				TokenData:                    action.TokenData,
+			}
+
+			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
+		case models.GCPKeyDataAction:
+			form := &forms.GCPKeyDataAction{
+				ServiceAccountActionResolver: saResolverBase,
+				GCPKeyData:                   action.GCPKeyData,
+			}
+
+			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
+		case models.AWSKeyDataAction:
+			form := &forms.AWSKeyDataAction{
+				ServiceAccountActionResolver: saResolverBase,
+				AWSKeyData:                   action.AWSKeyData,
+			}
+
+			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
+		}
+
+		if err != nil {
+			app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+			return
+		}
+	}
+
+	sa, err := app.repo.ServiceAccount.CreateServiceAccount(saResolverBase.SA)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	if sa != nil {
+		app.logger.Info().Msgf("New service account created: %d", sa.ID)
+
+		saExternal := sa.Externalize()
+
+		w.WriteHeader(http.StatusCreated)
+
+		if err := json.NewEncoder(w).Encode(saExternal); err != nil {
+			app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+			return
+		}
+	} else {
+		w.WriteHeader(http.StatusNotModified)
+	}
+}

+ 442 - 0
server/api/project_handler_test.go

@@ -0,0 +1,442 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type projTest struct {
+	initializers []func(t *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *projTest, tester *tester, t *testing.T)
+}
+
+func testProjRequests(t *testing.T, tests []*projTest, canQuery bool) {
+	for _, c := range tests {
+		// create a new tester
+		tester := newTester(canQuery)
+
+		// if there's an initializer, call it
+		for _, init := range c.initializers {
+			init(tester)
+		}
+
+		req, err := http.NewRequest(
+			c.method,
+			c.endpoint,
+			strings.NewReader(c.body),
+		)
+
+		tester.req = req
+
+		if c.useCookie {
+			req.AddCookie(tester.cookie)
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		tester.execute()
+		rr := tester.rr
+
+		// first, check that the status matches
+		if status := rr.Code; status != c.expStatus {
+			t.Errorf("%s, handler returned wrong status code: got %v want %v",
+				c.msg, status, c.expStatus)
+		}
+
+		// if there's a validator, call it
+		for _, validate := range c.validators {
+			validate(c, tester, t)
+		}
+	}
+}
+
+// ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
+
+var createProjectTests = []*projTest{
+	&projTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+		},
+		msg:      "Create project",
+		method:   "POST",
+		endpoint: "/api/projects",
+		body: `{
+			"name": "project-test"
+		}`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"id":1,"name":"project-test","roles":[{"id":0,"kind":"admin","user_id":1,"project_id":1}]}`,
+		useCookie: true,
+		validators: []func(c *projTest, tester *tester, t *testing.T){
+			projectModelBodyValidator,
+		},
+	},
+}
+
+func TestHandleCreateProject(t *testing.T) {
+	testProjRequests(t, createProjectTests, true)
+}
+
+var readProjectTests = []*projTest{
+	&projTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:       "Read project",
+		method:    "GET",
+		endpoint:  "/api/projects/1",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `{"id":1,"name":"project-test","roles":[{"id":0,"kind":"admin","user_id":1,"project_id":1}]}`,
+		useCookie: true,
+		validators: []func(c *projTest, tester *tester, t *testing.T){
+			projectModelBodyValidator,
+		},
+	},
+}
+
+func TestHandleReadProject(t *testing.T) {
+	testProjRequests(t, readProjectTests, true)
+}
+
+var listProjectClustersTest = []*projTest{
+	&projTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initProjectSADefault,
+		},
+		msg:       "List project clusters",
+		method:    "GET",
+		endpoint:  "/api/projects/1/clusters",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `[{"id":1,"service_account_id":1,"name":"cluster-test","server":"https://localhost"}]`,
+		useCookie: true,
+		validators: []func(c *projTest, tester *tester, t *testing.T){
+			projectClustersValidator,
+		},
+	},
+}
+
+func TestHandleListProjectClusters(t *testing.T) {
+	testProjRequests(t, listProjectClustersTest, true)
+}
+
+var createProjectSACandidatesTests = []*projTest{
+	&projTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:       "Create project SA candidate w/ no actions -- should create SA by default",
+		method:    "POST",
+		endpoint:  "/api/projects/1/candidates",
+		body:      `{"kubeconfig":"` + OIDCAuthWithDataForJSON + `"}`,
+		expStatus: http.StatusCreated,
+		expBody:   `[{"id":1,"actions":[],"project_id":1,"kind":"connector","cluster_name":"cluster-test","cluster_endpoint":"https://localhost","auth_mechanism":"oidc"}]`,
+		useCookie: true,
+		validators: []func(c *projTest, tester *tester, t *testing.T){
+			projectSACandidateBodyValidator,
+			// check that ServiceAccount was created by default
+			func(c *projTest, tester *tester, t *testing.T) {
+				serviceAccounts, err := tester.repo.ServiceAccount.ListServiceAccountsByProjectID(1)
+
+				if err != nil {
+					t.Fatalf("%v\n", err)
+				}
+
+				if len(serviceAccounts) != 1 {
+					t.Fatal("Expected service account to be created by default, but does not exist\n")
+				}
+
+				sa := serviceAccounts[0]
+
+				if len(sa.Clusters) != 1 {
+					t.Fatalf("cluster not written\n")
+				}
+
+				if sa.Clusters[0].ServiceAccountID != 1 {
+					t.Errorf("service account ID of joined cluster is not 1")
+				}
+
+				if sa.AuthMechanism != models.OIDC {
+					t.Errorf("service account auth mechanism is not %s\n", models.OIDC)
+				}
+
+				if string(sa.OIDCCertificateAuthorityData) != "LS0tLS1CRUdJTiBDRVJ=" {
+					t.Errorf("service account key data and input do not match: expected %s, got %s\n",
+						string(sa.OIDCCertificateAuthorityData), "LS0tLS1CRUdJTiBDRVJ=")
+				}
+
+				if sa.OIDCClientID != "porter-api" {
+					t.Errorf("service account oidc client id is not %s\n", "porter-api")
+				}
+
+				if sa.OIDCIDToken != "token" {
+					t.Errorf("service account oidc id token is not %s\n", "token")
+				}
+			},
+		},
+	},
+	&projTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:       "Create project SA candidate",
+		method:    "POST",
+		endpoint:  "/api/projects/1/candidates",
+		body:      `{"kubeconfig":"` + OIDCAuthWithoutDataForJSON + `"}`,
+		expStatus: http.StatusCreated,
+		expBody:   `[{"id":1,"actions":[{"name":"upload-oidc-idp-issuer-ca-data","filename":"/fake/path/to/ca.pem","docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"project_id":1,"kind":"connector","cluster_name":"cluster-test","cluster_endpoint":"https://localhost","auth_mechanism":"oidc"}]`,
+		useCookie: true,
+		validators: []func(c *projTest, tester *tester, t *testing.T){
+			projectSACandidateBodyValidator,
+		},
+	},
+}
+
+func TestHandleCreateProjectSACandidate(t *testing.T) {
+	testProjRequests(t, createProjectSACandidatesTests, true)
+}
+
+var listProjectSACandidatesTests = []*projTest{
+	&projTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initProjectSACandidate,
+		},
+		msg:       "List project SA candidates",
+		method:    "GET",
+		endpoint:  "/api/projects/1/candidates",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `[{"id":1,"actions":[{"name":"upload-oidc-idp-issuer-ca-data","filename":"/fake/path/to/ca.pem","docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"project_id":1,"kind":"connector","cluster_name":"cluster-test","cluster_endpoint":"https://localhost","auth_mechanism":"oidc"}]`,
+		useCookie: true,
+		validators: []func(c *projTest, tester *tester, t *testing.T){
+			projectSACandidateBodyValidator,
+		},
+	},
+}
+
+func TestHandleListProjectSACandidates(t *testing.T) {
+	testProjRequests(t, listProjectSACandidatesTests, true)
+}
+
+var resolveProjectSACandidatesTests = []*projTest{
+	&projTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initProjectSACandidate,
+		},
+		msg:       "Resolve project SA candidate",
+		method:    "POST",
+		endpoint:  "/api/projects/1/candidates/1/resolve",
+		body:      `[{"name": "upload-oidc-idp-issuer-ca-data", "oidc_idp_issuer_ca_data": "LS0tLS1CRUdJTiBDRVJ="}]`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"id":1,"project_id":1,"kind":"connector","clusters":[{"id":1,"service_account_id":1,"name":"cluster-test","server":"https://localhost"}],"auth_mechanism":"oidc"}`,
+		useCookie: true,
+		validators: []func(c *projTest, tester *tester, t *testing.T){
+			projectSABodyValidator,
+		},
+	},
+}
+
+func TestHandleResolveProjectSACandidate(t *testing.T) {
+	testProjRequests(t, resolveProjectSACandidatesTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initProject(tester *tester) {
+	user, _ := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
+
+	// handle write to the database
+	projModel, _ := tester.repo.Project.CreateProject(&models.Project{
+		Name: "project-test",
+	})
+
+	// create a new Role with the user as the owner
+	tester.repo.Project.CreateProjectRole(projModel, &models.Role{
+		UserID:    user.ID,
+		ProjectID: projModel.ID,
+		Kind:      models.RoleAdmin,
+	})
+}
+
+func initProjectSACandidate(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	form := &forms.CreateServiceAccountCandidatesForm{
+		ProjectID:  uint(proj.ID),
+		Kubeconfig: OIDCAuthWithoutData,
+	}
+
+	// convert the form to a ServiceAccountCandidate
+	saCandidates, _ := form.ToServiceAccountCandidates()
+
+	for _, saCandidate := range saCandidates {
+		tester.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+}
+
+func initProjectSADefault(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	form := &forms.CreateServiceAccountCandidatesForm{
+		ProjectID:  uint(proj.ID),
+		Kubeconfig: OIDCAuthWithData,
+	}
+
+	// convert the form to a ServiceAccountCandidate
+	saCandidates, _ := form.ToServiceAccountCandidates()
+
+	for _, saCandidate := range saCandidates {
+		tester.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	saForm := forms.ServiceAccountActionResolver{
+		ServiceAccountCandidateID: 1,
+	}
+
+	saForm.PopulateServiceAccount(tester.repo.ServiceAccount)
+	tester.repo.ServiceAccount.CreateServiceAccount(saForm.SA)
+}
+
+func projectBasicBodyValidator(c *projTest, tester *tester, t *testing.T) {
+	if body := tester.rr.Body.String(); strings.TrimSpace(body) != strings.TrimSpace(c.expBody) {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, body, c.expBody)
+	}
+}
+
+func projectModelBodyValidator(c *projTest, tester *tester, t *testing.T) {
+	gotBody := &models.ProjectExternal{}
+	expBody := &models.ProjectExternal{}
+
+	json.Unmarshal(tester.rr.Body.Bytes(), gotBody)
+	json.Unmarshal([]byte(c.expBody), expBody)
+
+	if !reflect.DeepEqual(gotBody, expBody) {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, gotBody, expBody)
+	}
+}
+
+func projectSACandidateBodyValidator(c *projTest, tester *tester, t *testing.T) {
+	gotBody := make([]*models.ServiceAccountCandidateExternal, 0)
+	expBody := make([]*models.ServiceAccountCandidateExternal, 0)
+
+	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if !reflect.DeepEqual(gotBody, expBody) {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, gotBody, expBody)
+	}
+}
+
+func projectSABodyValidator(c *projTest, tester *tester, t *testing.T) {
+	gotBody := &models.ServiceAccountExternal{}
+	expBody := &models.ServiceAccountExternal{}
+
+	json.Unmarshal(tester.rr.Body.Bytes(), gotBody)
+	json.Unmarshal([]byte(c.expBody), expBody)
+
+	if !reflect.DeepEqual(gotBody, expBody) {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, gotBody, expBody)
+	}
+}
+
+func projectClustersValidator(c *projTest, tester *tester, t *testing.T) {
+	gotBody := make([]*models.ClusterExternal, 0)
+	expBody := make([]*models.ClusterExternal, 0)
+
+	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}
+
+const OIDCAuthWithDataForJSON string = `apiVersion: v1\nclusters:\n- cluster:\n    server: https://localhost\n    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\ncurrent-context: context-test\nkind: Config\npreferences: {}\nusers:\n- name: test-admin\n  user:\n    auth-provider:\n      config:\n        client-id: porter-api\n        id-token: token\n        idp-issuer-url: https://localhost\n        idp-certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n      name: oidc`
+
+const OIDCAuthWithoutDataForJSON string = `apiVersion: v1\nclusters:\n- cluster:\n    server: https://localhost\n    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\ncurrent-context: context-test\nkind: Config\npreferences: {}\nusers:\n- name: test-admin\n  user:\n    auth-provider:\n      config:\n        client-id: porter-api\n        id-token: token\n        idp-issuer-url: https://localhost\n        idp-certificate-authority: /fake/path/to/ca.pem\n      name: oidc`
+
+const OIDCAuthWithoutData string = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    auth-provider:
+      config:
+        client-id: porter-api
+        id-token: token
+        idp-issuer-url: https://localhost
+        idp-certificate-authority: /fake/path/to/ca.pem
+      name: oidc
+`
+
+const OIDCAuthWithData string = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    auth-provider:
+      config:
+        client-id: porter-api
+        id-token: token
+        idp-issuer-url: https://localhost
+        idp-certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+      name: oidc
+`

+ 32 - 15
server/api/release_handler.go

@@ -10,6 +10,7 @@ import (
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm/grapher"
 	"github.com/porter-dev/porter/internal/helm/grapher"
+	"github.com/porter-dev/porter/internal/repository"
 )
 )
 
 
 // Enumeration of release API error codes, represented as int64
 // Enumeration of release API error codes, represented as int64
@@ -195,6 +196,13 @@ func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request)
 func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")
 	name := chi.URLParam(r, "name")
 
 
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
 	form := &forms.UpgradeReleaseForm{
 	form := &forms.UpgradeReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{},
 			Form: &helm.Form{},
@@ -202,6 +210,11 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		Name: name,
 		Name: name,
 	}
 	}
 
 
+	form.ReleaseForm.PopulateHelmOptionsFromQueryParams(
+		vals,
+		app.repo.ServiceAccount,
+	)
+
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 		return
@@ -236,6 +249,13 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")
 	name := chi.URLParam(r, "name")
 
 
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
 	form := &forms.RollbackReleaseForm{
 	form := &forms.RollbackReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{},
 			Form: &helm.Form{},
@@ -243,6 +263,11 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 		Name: name,
 		Name: name,
 	}
 	}
 
 
+	form.ReleaseForm.PopulateHelmOptionsFromQueryParams(
+		vals,
+		app.repo.ServiceAccount,
+	)
+
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 		return
@@ -283,7 +308,7 @@ func (app *App) getAgentFromQueryParams(
 	r *http.Request,
 	r *http.Request,
 	form *forms.ReleaseForm,
 	form *forms.ReleaseForm,
 	// populate uses the query params to populate a form
 	// populate uses the query params to populate a form
-	populate ...func(vals url.Values),
+	populate ...func(vals url.Values, repo repository.ServiceAccountRepository) error,
 ) (*helm.Agent, error) {
 ) (*helm.Agent, error) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 
 
@@ -293,7 +318,11 @@ func (app *App) getAgentFromQueryParams(
 	}
 	}
 
 
 	for _, f := range populate {
 	for _, f := range populate {
-		f(vals)
+		err := f(vals, app.repo.ServiceAccount)
+
+		if err != nil {
+			return nil, err
+		}
 	}
 	}
 
 
 	return app.getAgentFromReleaseForm(w, r, form)
 	return app.getAgentFromReleaseForm(w, r, form)
@@ -306,19 +335,7 @@ func (app *App) getAgentFromReleaseForm(
 	r *http.Request,
 	r *http.Request,
 	form *forms.ReleaseForm,
 	form *forms.ReleaseForm,
 ) (*helm.Agent, error) {
 ) (*helm.Agent, error) {
-	// read the session in order to generate the Helm agent
-	session, err := app.store.Get(r, app.cookieName)
-
-	// since we have already authenticated the user, throw a data read error if the session
-	// cannot be found
-	if err != nil {
-		app.handleErrorDataRead(err, w)
-		return nil, err
-	}
-
-	if userID, ok := session.Values["user_id"].(uint); ok {
-		form.PopulateHelmOptionsFromUserID(userID, app.repo.User)
-	}
+	var err error
 
 
 	// validate the form
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
 	if err := app.validator.Struct(form); err != nil {

+ 68 - 51
server/api/release_handler_test.go

@@ -93,14 +93,15 @@ var listReleasesTests = []*releaseTest{
 		},
 		},
 		msg:    "List releases no namespace",
 		msg:    "List releases no namespace",
 		method: "GET",
 		method: "GET",
-		endpoint: "/api/releases?" + url.Values{
-			"namespace":    []string{""},
-			"context":      []string{"context-test"},
-			"storage":      []string{"memory"},
-			"limit":        []string{"20"},
-			"skip":         []string{"0"},
-			"byDate":       []string{"false"},
-			"statusFilter": []string{"deployed"},
+		endpoint: "/api/projects/1/releases?" + url.Values{
+			"namespace":          []string{""},
+			"cluster_id":         []string{"1"},
+			"service_account_id": []string{"1"},
+			"storage":            []string{"memory"},
+			"limit":              []string{"20"},
+			"skip":               []string{"0"},
+			"byDate":             []string{"false"},
+			"statusFilter":       []string{"deployed"},
 		}.Encode(),
 		}.Encode(),
 		body:      "",
 		body:      "",
 		expStatus: http.StatusOK,
 		expStatus: http.StatusOK,
@@ -117,14 +118,15 @@ var listReleasesTests = []*releaseTest{
 		msg:       "List releases with namespace",
 		msg:       "List releases with namespace",
 		method:    "GET",
 		method:    "GET",
 		namespace: "default",
 		namespace: "default",
-		endpoint: "/api/releases?" + url.Values{
-			"namespace":    []string{"default"},
-			"context":      []string{"context-test"},
-			"storage":      []string{"memory"},
-			"limit":        []string{"20"},
-			"skip":         []string{"0"},
-			"byDate":       []string{"false"},
-			"statusFilter": []string{"deployed"},
+		endpoint: "/api/projects/1/releases?" + url.Values{
+			"namespace":          []string{"default"},
+			"cluster_id":         []string{"1"},
+			"service_account_id": []string{"1"},
+			"storage":            []string{"memory"},
+			"limit":              []string{"20"},
+			"skip":               []string{"0"},
+			"byDate":             []string{"false"},
+			"statusFilter":       []string{"deployed"},
 		}.Encode(),
 		}.Encode(),
 		body:      "",
 		body:      "",
 		expStatus: http.StatusOK,
 		expStatus: http.StatusOK,
@@ -144,13 +146,14 @@ var listReleasesTests = []*releaseTest{
 		msg:       "List releases missing required",
 		msg:       "List releases missing required",
 		method:    "GET",
 		method:    "GET",
 		namespace: "default",
 		namespace: "default",
-		endpoint: "/api/releases?" + url.Values{
-			"namespace":    []string{"default"},
-			"storage":      []string{"memory"},
-			"limit":        []string{"20"},
-			"skip":         []string{"0"},
-			"byDate":       []string{"false"},
-			"statusFilter": []string{"deployed"},
+		endpoint: "/api/projects/1/releases?" + url.Values{
+			"service_account_id": []string{"1"},
+			"namespace":          []string{"default"},
+			"storage":            []string{"memory"},
+			"limit":              []string{"20"},
+			"skip":               []string{"0"},
+			"byDate":             []string{"false"},
+			"statusFilter":       []string{"deployed"},
 		}.Encode(),
 		}.Encode(),
 		body:      "",
 		body:      "",
 		expStatus: http.StatusUnprocessableEntity,
 		expStatus: http.StatusUnprocessableEntity,
@@ -174,10 +177,11 @@ var getReleaseTests = []*releaseTest{
 		msg:       "Get releases",
 		msg:       "Get releases",
 		method:    "GET",
 		method:    "GET",
 		namespace: "default",
 		namespace: "default",
-		endpoint: "/api/releases/airwatch/1?" + url.Values{
-			"namespace": []string{""},
-			"context":   []string{"context-test"},
-			"storage":   []string{"memory"},
+		endpoint: "/api/projects/1/releases/airwatch/1?" + url.Values{
+			"namespace":          []string{""},
+			"cluster_id":         []string{"1"},
+			"service_account_id": []string{"1"},
+			"storage":            []string{"memory"},
 		}.Encode(),
 		}.Encode(),
 		body:      "",
 		body:      "",
 		expStatus: http.StatusOK,
 		expStatus: http.StatusOK,
@@ -194,10 +198,11 @@ var getReleaseTests = []*releaseTest{
 		msg:       "Release not found",
 		msg:       "Release not found",
 		method:    "GET",
 		method:    "GET",
 		namespace: "default",
 		namespace: "default",
-		endpoint: "/api/releases/airwatch/5?" + url.Values{
-			"namespace": []string{""},
-			"context":   []string{"context-test"},
-			"storage":   []string{"memory"},
+		endpoint: "/api/projects/1/releases/airwatch/5?" + url.Values{
+			"namespace":          []string{""},
+			"cluster_id":         []string{"1"},
+			"service_account_id": []string{"1"},
+			"storage":            []string{"memory"},
 		}.Encode(),
 		}.Encode(),
 		body:      "",
 		body:      "",
 		expStatus: http.StatusNotFound,
 		expStatus: http.StatusNotFound,
@@ -221,10 +226,11 @@ var listReleaseHistoryTests = []*releaseTest{
 		msg:       "List release history",
 		msg:       "List release history",
 		method:    "GET",
 		method:    "GET",
 		namespace: "default",
 		namespace: "default",
-		endpoint: "/api/releases/wordpress/history?" + url.Values{
-			"namespace": []string{""},
-			"context":   []string{"context-test"},
-			"storage":   []string{"memory"},
+		endpoint: "/api/projects/1/releases/wordpress/history?" + url.Values{
+			"namespace":          []string{""},
+			"cluster_id":         []string{"1"},
+			"service_account_id": []string{"1"},
+			"storage":            []string{"memory"},
 		}.Encode(),
 		}.Encode(),
 		body:      "",
 		body:      "",
 		expStatus: http.StatusOK,
 		expStatus: http.StatusOK,
@@ -241,10 +247,11 @@ var listReleaseHistoryTests = []*releaseTest{
 		msg:       "Release not found",
 		msg:       "Release not found",
 		method:    "GET",
 		method:    "GET",
 		namespace: "default",
 		namespace: "default",
-		endpoint: "/api/releases/asldfkja/history?" + url.Values{
-			"namespace": []string{""},
-			"context":   []string{"context-test"},
-			"storage":   []string{"memory"},
+		endpoint: "/api/projects/1/releases/asldfkja/history?" + url.Values{
+			"namespace":          []string{""},
+			"cluster_id":         []string{"1"},
+			"service_account_id": []string{"1"},
+			"storage":            []string{"memory"},
 		}.Encode(),
 		}.Encode(),
 		body:      "",
 		body:      "",
 		expStatus: http.StatusNotFound,
 		expStatus: http.StatusNotFound,
@@ -268,11 +275,13 @@ var upgradeReleaseTests = []*releaseTest{
 		msg:       "Upgrade relase",
 		msg:       "Upgrade relase",
 		method:    "POST",
 		method:    "POST",
 		namespace: "default",
 		namespace: "default",
-		endpoint:  "/api/releases/wordpress/upgrade",
+		endpoint: "/api/projects/1/releases/wordpress/upgrade?" + url.Values{
+			"cluster_id":         []string{"1"},
+			"service_account_id": []string{"1"},
+		}.Encode(),
 		body: `
 		body: `
 			{
 			{
 				"namespace": "default",
 				"namespace": "default",
-				"context": "context-test",
 				"storage": "memory",
 				"storage": "memory",
 				"values": "\nfoo: bar\n"
 				"values": "\nfoo: bar\n"
 			}
 			}
@@ -284,10 +293,11 @@ var upgradeReleaseTests = []*releaseTest{
 			func(c *releaseTest, tester *tester, t *testing.T) {
 			func(c *releaseTest, tester *tester, t *testing.T) {
 				req, err := http.NewRequest(
 				req, err := http.NewRequest(
 					"GET",
 					"GET",
-					"/api/releases/wordpress/3?"+url.Values{
-						"namespace": []string{"default"},
-						"context":   []string{"context-test"},
-						"storage":   []string{"memory"},
+					"/api/projects/1/releases/wordpress/3?"+url.Values{
+						"namespace":          []string{"default"},
+						"cluster_id":         []string{"1"},
+						"service_account_id": []string{"1"},
+						"storage":            []string{"memory"},
 					}.Encode(),
 					}.Encode(),
 					strings.NewReader(""),
 					strings.NewReader(""),
 				)
 				)
@@ -345,11 +355,13 @@ var rollbackReleaseTests = []*releaseTest{
 		msg:       "Rollback relase",
 		msg:       "Rollback relase",
 		method:    "POST",
 		method:    "POST",
 		namespace: "default",
 		namespace: "default",
-		endpoint:  "/api/releases/wordpress/rollback",
+		endpoint: "/api/projects/1/releases/wordpress/rollback?" + url.Values{
+			"cluster_id":         []string{"1"},
+			"service_account_id": []string{"1"},
+		}.Encode(),
 		body: `
 		body: `
 			{
 			{
 				"namespace": "default",
 				"namespace": "default",
-				"context": "context-test",
 				"storage": "memory",
 				"storage": "memory",
 				"revision": 1
 				"revision": 1
 			}
 			}
@@ -361,10 +373,11 @@ var rollbackReleaseTests = []*releaseTest{
 			func(c *releaseTest, tester *tester, t *testing.T) {
 			func(c *releaseTest, tester *tester, t *testing.T) {
 				req, err := http.NewRequest(
 				req, err := http.NewRequest(
 					"GET",
 					"GET",
-					"/api/releases/wordpress/3?"+url.Values{
-						"namespace": []string{"default"},
-						"context":   []string{"context-test"},
-						"storage":   []string{"memory"},
+					"/api/projects/1/releases/wordpress/3?"+url.Values{
+						"namespace":          []string{"default"},
+						"cluster_id":         []string{"1"},
+						"service_account_id": []string{"1"},
+						"storage":            []string{"memory"},
 					}.Encode(),
 					}.Encode(),
 					strings.NewReader(""),
 					strings.NewReader(""),
 				)
 				)
@@ -411,6 +424,8 @@ func TestRollbackRelease(t *testing.T) {
 
 
 func initDefaultReleases(tester *tester) {
 func initDefaultReleases(tester *tester) {
 	initUserDefault(tester)
 	initUserDefault(tester)
+	initProject(tester)
+	initProjectSADefault(tester)
 
 
 	agent := tester.app.TestAgents.HelmAgent
 	agent := tester.app.TestAgents.HelmAgent
 
 
@@ -423,6 +438,8 @@ func initDefaultReleases(tester *tester) {
 
 
 func initHistoryReleases(tester *tester) {
 func initHistoryReleases(tester *tester) {
 	initUserDefault(tester)
 	initUserDefault(tester)
+	initProject(tester)
+	initProjectSADefault(tester)
 
 
 	agent := tester.app.TestAgents.HelmAgent
 	agent := tester.app.TestAgents.HelmAgent
 
 

+ 95 - 22
server/api/repo_handler.go

@@ -6,6 +6,9 @@ import (
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
 	"net/url"
 	"net/url"
+	"strconv"
+
+	"golang.org/x/oauth2"
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 	"github.com/google/go-github/v32/github"
 	"github.com/google/go-github/v32/github"
@@ -25,49 +28,75 @@ type DirectoryItem struct {
 
 
 // HandleListRepos retrieves a list of repo names
 // HandleListRepos retrieves a list of repo names
 func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
-	client := github.NewClient(nil)
+	tok, err := app.githubTokenFromRequest(r)
 
 
-	// list all organizations for specified user
-	// TODO: fix hardcoded user/org
-	repos, _, err := client.Repositories.List(context.Background(), "porter-dev", nil)
 	if err != nil {
 	if err != nil {
-		fmt.Println(err)
+		app.handleErrorInternal(err, w)
 		return
 		return
 	}
 	}
 
 
-	res := []Repo{}
-	for i := range repos {
-		r := Repo{}
-		r.FullName = *repos[i].FullName
-		r.Kind = "github"
-		res = append(res, r)
+	res := make([]Repo, 0)
+
+	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
+
+	// list all repositories for specified user
+	repos, _, err := client.Repositories.List(context.Background(), "", nil)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// TODO -- check if repo has already been appended -- there may be duplicates
+	for _, repo := range repos {
+		res = append(res, Repo{
+			FullName: repo.GetFullName(),
+			Kind:     "github",
+		})
 	}
 	}
+
 	json.NewEncoder(w).Encode(res)
 	json.NewEncoder(w).Encode(res)
 }
 }
 
 
 // HandleGetBranches retrieves a list of branch names for a specified repo
 // HandleGetBranches retrieves a list of branch names for a specified repo
 func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
+	tok, err := app.githubTokenFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
 	name := chi.URLParam(r, "name")
 	name := chi.URLParam(r, "name")
-	client := github.NewClient(nil)
+
+	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
 
 
 	// List all branches for a specified repo
 	// List all branches for a specified repo
-	// TODO: fix hardcoded user/org
-	branches, _, err := client.Repositories.ListBranches(context.Background(), "porter-dev", name, nil)
+	branches, _, err := client.Repositories.ListBranches(context.Background(), "", name, nil)
 	if err != nil {
 	if err != nil {
 		fmt.Println(err)
 		fmt.Println(err)
 		return
 		return
 	}
 	}
 
 
 	res := []string{}
 	res := []string{}
-	for i := range branches {
-		b := *branches[i].Name
-		res = append(res, b)
+	for _, b := range branches {
+		res = append(res, b.GetName())
 	}
 	}
+
 	json.NewEncoder(w).Encode(res)
 	json.NewEncoder(w).Encode(res)
 }
 }
 
 
 // HandleGetBranchContents retrieves the contents of a specific branch and subdirectory
 // HandleGetBranchContents retrieves the contents of a specific branch and subdirectory
 func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request) {
+	tok, err := app.githubTokenFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
+
 	queryParams, err := url.ParseQuery(r.URL.RawQuery)
 	queryParams, err := url.ParseQuery(r.URL.RawQuery)
 	if err != nil {
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
@@ -76,14 +105,12 @@ func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request)
 
 
 	name := chi.URLParam(r, "name")
 	name := chi.URLParam(r, "name")
 	branch := chi.URLParam(r, "branch")
 	branch := chi.URLParam(r, "branch")
-	client := github.NewClient(nil)
 
 
-	// TODO: fix hardcoded user/org
 	repoContentOptions := github.RepositoryContentGetOptions{}
 	repoContentOptions := github.RepositoryContentGetOptions{}
 	repoContentOptions.Ref = branch
 	repoContentOptions.Ref = branch
-	_, directoryContents, _, err := client.Repositories.GetContents(context.Background(), "porter-dev", name, queryParams["dir"][0], &repoContentOptions)
+	_, directoryContents, _, err := client.Repositories.GetContents(context.Background(), "", name, queryParams["dir"][0], &repoContentOptions)
 	if err != nil {
 	if err != nil {
-		fmt.Println(err)
+		app.handleErrorInternal(err, w)
 		return
 		return
 	}
 	}
 
 
@@ -97,6 +124,52 @@ func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request)
 
 
 	// Ret2: recursively traverse all dirs to create config bundle (case on type == dir)
 	// Ret2: recursively traverse all dirs to create config bundle (case on type == dir)
 	// https://api.github.com/repos/porter-dev/porter/contents?ref=frontend-graph
 	// https://api.github.com/repos/porter-dev/porter/contents?ref=frontend-graph
-	fmt.Println(res)
+	// fmt.Println(res)
 	json.NewEncoder(w).Encode(res)
 	json.NewEncoder(w).Encode(res)
 }
 }
+
+func (app *App) githubTokenFromRequest(
+	r *http.Request,
+) (*oauth2.Token, error) {
+	// read project id
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		return nil, fmt.Errorf("could not read project id")
+	}
+
+	// read user id
+	session, err := app.store.Get(r, app.cookieName)
+
+	if err != nil {
+		return nil, fmt.Errorf("could not read user id")
+	}
+
+	userID, ok := session.Values["user_id"].(uint)
+
+	if !ok {
+		return nil, fmt.Errorf("could not read user id")
+	}
+
+	// query for repo client
+	repoClients, err := app.repo.RepoClient.ListRepoClientsByProjectID(uint(projID))
+
+	if err != nil {
+		return nil, err
+	}
+
+	for _, rc := range repoClients {
+		// find the RepoClient that matches the user id in the request
+		if rc.UserID == userID {
+			// TODO -- refresh token is irrelevant at the moment, because the access token
+			// doesn't expire.
+			return &oauth2.Token{
+				AccessToken:  rc.AccessToken,
+				RefreshToken: rc.RefreshToken,
+				TokenType:    "Bearer",
+			}, nil
+		}
+	}
+
+	return nil, fmt.Errorf("could not find matching token")
+}

+ 14 - 33
server/api/user_handler.go

@@ -7,7 +7,6 @@ import (
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 
 
-	"github.com/porter-dev/porter/internal/kubernetes"
 	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/crypto/bcrypt"
 
 
 	"gorm.io/gorm"
 	"gorm.io/gorm"
@@ -166,56 +165,38 @@ func (app *App) HandleReadUser(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusOK)
 	w.WriteHeader(http.StatusOK)
 }
 }
 
 
-// HandleReadUserContexts returns the externalized User.Contexts ([]models.Context)
-// based on a user ID
-func (app *App) HandleReadUserContexts(w http.ResponseWriter, r *http.Request) {
-	user, err := app.readUser(w, r)
+// HandleListUserProjects lists all projects belonging to a given user
+func (app *App) HandleListUserProjects(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "user_id"), 0, 64)
 
 
-	// error already handled by helper
-	if err != nil {
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 		return
 	}
 	}
 
 
-	contexts, err := kubernetes.GetContextsFromBytes(user.RawKubeConfig, user.ContextToSlice())
+	projects, err := app.repo.Project.ListProjectsByUserID(uint(id))
 
 
 	if err != nil {
 	if err != nil {
-		app.handleErrorFormDecoding(err, ErrUserDecode, w)
-		return
+		app.handleErrorRead(err, ErrUserDataRead, w)
 	}
 	}
 
 
-	if err := json.NewEncoder(w).Encode(contexts); err != nil {
-		app.handleErrorFormDecoding(err, ErrUserDecode, w)
-		return
+	projectsExt := make([]*models.ProjectExternal, 0)
+
+	for _, project := range projects {
+		projectsExt = append(projectsExt, project.Externalize())
 	}
 	}
 
 
 	w.WriteHeader(http.StatusOK)
 	w.WriteHeader(http.StatusOK)
-}
-
-// HandleUpdateUser validates an update user form entry, updates the user
-// in the database, and writes status accepted
-func (app *App) HandleUpdateUser(w http.ResponseWriter, r *http.Request) {
-	id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
 
 
-	if err != nil || id == 0 {
+	if err := json.NewEncoder(w).Encode(projectsExt); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 		return
 	}
 	}
-
-	form := &forms.UpdateUserForm{
-		ID: uint(id),
-	}
-
-	user, err := app.writeUser(form, app.repo.User.UpdateUser, w, r)
-
-	if err == nil {
-		app.logger.Info().Msgf("User updated: %d", user.ID)
-		w.WriteHeader(http.StatusNoContent)
-	}
 }
 }
 
 
 // HandleDeleteUser removes a user after checking that the sent password is correct
 // HandleDeleteUser removes a user after checking that the sent password is correct
 func (app *App) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
-	id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+	id, err := strconv.ParseUint(chi.URLParam(r, "user_id"), 0, 64)
 
 
 	if err != nil || id == 0 {
 	if err != nil || id == 0 {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
@@ -308,7 +289,7 @@ func (app *App) writeUser(
 }
 }
 
 
 func (app *App) readUser(w http.ResponseWriter, r *http.Request) (*models.User, error) {
 func (app *App) readUser(w http.ResponseWriter, r *http.Request) (*models.User, error) {
-	id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+	id, err := strconv.ParseUint(chi.URLParam(r, "user_id"), 0, 64)
 
 
 	if err != nil || id == 0 {
 	if err != nil || id == 0 {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)

+ 35 - 240
server/api/user_handler_test.go

@@ -2,13 +2,13 @@ package api_test
 
 
 import (
 import (
 	"encoding/json"
 	"encoding/json"
-	"fmt"
 	"net/http"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptest"
 	"reflect"
 	"reflect"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
 
 
+	"github.com/go-test/deep"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 )
 )
 
 
@@ -80,7 +80,7 @@ var authCheckTests = []*userTest{
 		endpoint:  "/api/auth/check",
 		endpoint:  "/api/auth/check",
 		expStatus: http.StatusOK,
 		expStatus: http.StatusOK,
 		body:      "",
 		body:      "",
-		expBody:   `{"id":1,"email":"","contexts":null,"rawKubeConfig":""}`,
+		expBody:   `{"id":1,"email":"belanger@getporter.dev"}`,
 		useCookie: true,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 			userBasicBodyValidator,
 			userBasicBodyValidator,
@@ -116,7 +116,10 @@ var createUserTests = []*userTest{
 			"password": "hello"
 			"password": "hello"
 		}`,
 		}`,
 		expStatus: http.StatusCreated,
 		expStatus: http.StatusCreated,
-		expBody:   `{"id":1,"email":"","contexts":null,"rawKubeConfig":""}`,
+		expBody:   `{"id":1,"email":"belanger@getporter.dev"}`,
+		validators: []func(c *userTest, tester *tester, t *testing.T){
+			userModelBodyValidator,
+		},
 	},
 	},
 	&userTest{
 	&userTest{
 		msg:      "Create user invalid email",
 		msg:      "Create user invalid email",
@@ -216,7 +219,7 @@ var loginUserTests = []*userTest{
 			"password": "hello"
 			"password": "hello"
 		}`,
 		}`,
 		expStatus: http.StatusOK,
 		expStatus: http.StatusOK,
-		expBody:   `{"id":1,"email":"","contexts":null,"rawKubeConfig":""}`,
+		expBody:   `{"id":1,"email":"belanger@getporter.dev"}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 			userBasicBodyValidator,
 			userBasicBodyValidator,
 		},
 		},
@@ -233,7 +236,7 @@ var loginUserTests = []*userTest{
 			"password": "hello"
 			"password": "hello"
 		}`,
 		}`,
 		expStatus: http.StatusOK,
 		expStatus: http.StatusOK,
-		expBody:   `{"id":1,"email":"","contexts":null,"rawKubeConfig":""}`,
+		expBody:   `{"id":1,"email":"belanger@getporter.dev"}`,
 		useCookie: true,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 			userBasicBodyValidator,
 			userBasicBodyValidator,
@@ -325,14 +328,14 @@ func TestHandleLogoutUser(t *testing.T) {
 var readUserTests = []*userTest{
 var readUserTests = []*userTest{
 	&userTest{
 	&userTest{
 		initializers: []func(tester *tester){
 		initializers: []func(tester *tester){
-			initUserWithContexts,
+			initUserDefault,
 		},
 		},
 		msg:       "Read user successful",
 		msg:       "Read user successful",
 		method:    "GET",
 		method:    "GET",
 		endpoint:  "/api/users/1",
 		endpoint:  "/api/users/1",
 		body:      "",
 		body:      "",
 		expStatus: http.StatusOK,
 		expStatus: http.StatusOK,
-		expBody:   `{"id":1,"email":"belanger@getporter.dev","contexts":["context-test"],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`,
+		expBody:   `{"id":1,"email":"belanger@getporter.dev"}`,
 		useCookie: true,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 			userModelBodyValidator,
 			userModelBodyValidator,
@@ -358,251 +361,41 @@ func TestHandleReadUser(t *testing.T) {
 	testUserRequests(t, readUserTests, true)
 	testUserRequests(t, readUserTests, true)
 }
 }
 
 
-var readUserContextsTests = []*userTest{
+var listUserProjectsTests = []*userTest{
 	&userTest{
 	&userTest{
 		initializers: []func(tester *tester){
 		initializers: []func(tester *tester){
-			initUserWithContexts,
+			initUserDefault,
+			initProject,
 		},
 		},
-		msg:       "Read user context selected successful",
+		msg:       "List user projects successful",
 		method:    "GET",
 		method:    "GET",
-		endpoint:  "/api/users/1/contexts",
+		endpoint:  "/api/users/1/projects",
 		body:      "",
 		body:      "",
 		expStatus: http.StatusOK,
 		expStatus: http.StatusOK,
+		expBody:   `[{"id":1,"name":"project-test","roles":[{"id":0,"kind":"admin","user_id":1,"project_id":1}]}]`,
 		useCookie: true,
 		useCookie: true,
-		expBody:   `[{"name":"context-test","server":"https://localhost","cluster":"cluster-test","user":"test-admin","selected":true}]`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			userContextBodyValidator,
+			userProjectsListValidator,
 		},
 		},
 	},
 	},
 	&userTest{
 	&userTest{
 		initializers: []func(tester *tester){
 		initializers: []func(tester *tester){
-			func(tester *tester) {
-				initUserDefault(tester)
-
-				user, _ := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
-				user.Contexts = ""
-				user.RawKubeConfig = []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin")
-
-				tester.repo.User.UpdateUser(user)
-			},
+			initUserDefault,
 		},
 		},
-		msg:       "Read user context not selected successful",
+		msg:       "List user projects unauthorized",
 		method:    "GET",
 		method:    "GET",
-		endpoint:  "/api/users/1/contexts",
+		endpoint:  "/api/users/2/projects",
 		body:      "",
 		body:      "",
-		expStatus: http.StatusOK,
-		useCookie: true,
-		expBody:   `[{"name":"context-test","server":"https://localhost","cluster":"cluster-test","user":"test-admin","selected":false}]`,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			userContextBodyValidator,
-		},
-	},
-}
-
-func TestHandleReadUserContexts(t *testing.T) {
-	testUserRequests(t, readUserContextsTests, true)
-}
-
-var updateUserTests = []*userTest{
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserDefault,
-		},
-		msg:       "Update user successful",
-		method:    "PUT",
-		endpoint:  "/api/users/1",
-		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedContexts":[]}`,
-		expStatus: http.StatusNoContent,
-		expBody:   "",
-		useCookie: true,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			func(c *userTest, tester *tester, t *testing.T) {
-				req, err := http.NewRequest(
-					"GET",
-					"/api/users/1",
-					strings.NewReader(""),
-				)
-
-				req.AddCookie(tester.cookie)
-
-				if err != nil {
-					t.Fatal(err)
-				}
-
-				rr2 := httptest.NewRecorder()
-				tester.router.ServeHTTP(rr2, req)
-
-				gotBody := &models.UserExternal{}
-				expBody := &models.UserExternal{}
-
-				json.Unmarshal(rr2.Body.Bytes(), gotBody)
-				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","contexts":[],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
-
-				fmt.Println(rr2.Body.String())
-
-				if !reflect.DeepEqual(gotBody, expBody) {
-					t.Errorf("%s, handler returned wrong body: got %v want %v",
-						"validator failed", gotBody, expBody)
-				}
-			},
-		},
-	},
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserDefault,
-		},
-		msg:       "Update user successful without allowedContexts parameter",
-		method:    "PUT",
-		endpoint:  "/api/users/1",
-		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`,
-		expStatus: http.StatusNoContent,
-		expBody:   "",
-		useCookie: true,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			func(c *userTest, tester *tester, t *testing.T) {
-				req, err := http.NewRequest(
-					"GET",
-					"/api/users/1",
-					strings.NewReader(""),
-				)
-
-				req.AddCookie(tester.cookie)
-
-				if err != nil {
-					t.Fatal(err)
-				}
-
-				rr2 := httptest.NewRecorder()
-				tester.router.ServeHTTP(rr2, req)
-
-				gotBody := &models.UserExternal{}
-				expBody := &models.UserExternal{}
-
-				json.Unmarshal(rr2.Body.Bytes(), gotBody)
-				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","contexts":[],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
-
-				if !reflect.DeepEqual(gotBody, expBody) {
-					t.Errorf("%s, handler returned wrong body: got %v want %v",
-						"validator failed", gotBody, expBody)
-				}
-			},
-		},
-	},
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserDefault,
-		},
-		msg:       "Update user successful with allowedContexts",
-		method:    "PUT",
-		endpoint:  "/api/users/1",
-		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedContexts":["context-test"]}`,
-		expStatus: http.StatusNoContent,
-		expBody:   "",
-		useCookie: true,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			func(c *userTest, tester *tester, t *testing.T) {
-				req, err := http.NewRequest(
-					"GET",
-					"/api/users/1",
-					strings.NewReader(""),
-				)
-
-				req.AddCookie(tester.cookie)
-
-				if err != nil {
-					t.Fatal(err)
-				}
-
-				rr2 := httptest.NewRecorder()
-				tester.router.ServeHTTP(rr2, req)
-
-				gotBody := &models.UserExternal{}
-				expBody := &models.UserExternal{}
-
-				json.Unmarshal(rr2.Body.Bytes(), gotBody)
-				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","contexts":["context-test"],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
-
-				if !reflect.DeepEqual(gotBody, expBody) {
-					t.Errorf("%s, handler returned wrong body: got %v want %v",
-						"validator failed", gotBody, expBody)
-				}
-			},
-		},
-	},
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserWithContexts,
-		},
-		msg:       "Update user successful without rawKubeConfig",
-		method:    "PUT",
-		endpoint:  "/api/users/1",
-		body:      `{"allowedContexts":[]}`,
-		expStatus: http.StatusNoContent,
-		expBody:   "",
-		useCookie: true,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			func(c *userTest, tester *tester, t *testing.T) {
-				req, err := http.NewRequest(
-					"GET",
-					"/api/users/1",
-					strings.NewReader(""),
-				)
-
-				req.AddCookie(tester.cookie)
-
-				if err != nil {
-					t.Fatal(err)
-				}
-
-				rr2 := httptest.NewRecorder()
-				tester.router.ServeHTTP(rr2, req)
-
-				gotBody := &models.UserExternal{}
-				expBody := &models.UserExternal{}
-
-				json.Unmarshal(rr2.Body.Bytes(), gotBody)
-				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","contexts":[],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
-
-				if !reflect.DeepEqual(gotBody, expBody) {
-					t.Errorf("%s, handler returned wrong body: got %v want %v",
-						"validator failed", gotBody, expBody)
-				}
-			},
-		},
-	},
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserDefault,
-		},
-		msg:       "Update user invalid id",
-		method:    "PUT",
-		endpoint:  "/api/users/alsdfjk",
-		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedContexts":[]}`,
 		expStatus: http.StatusForbidden,
 		expStatus: http.StatusForbidden,
 		expBody:   http.StatusText(http.StatusForbidden) + "\n",
 		expBody:   http.StatusText(http.StatusForbidden) + "\n",
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 			userBasicBodyValidator,
 			userBasicBodyValidator,
 		},
 		},
 	},
 	},
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserDefault,
-		},
-		msg:       "Update user bad kubeconfig",
-		method:    "PUT",
-		endpoint:  "/api/users/1",
-		body:      `{"rawKubeConfig":"notvalidyaml", "allowedContexts":[]}`,
-		expStatus: http.StatusBadRequest,
-		expBody:   `{"code":600,"errors":["could not process request"]}`,
-		useCookie: true,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			userBasicBodyValidator,
-		},
-	},
 }
 }
 
 
-func TestHandleUpdateUser(t *testing.T) {
-	testUserRequests(t, updateUserTests, true)
+func TestHandleListUserProjects(t *testing.T) {
+	testUserRequests(t, listUserProjectsTests, true)
 }
 }
 
 
 var deleteUserTests = []*userTest{
 var deleteUserTests = []*userTest{
@@ -694,17 +487,6 @@ func initUserDefault(tester *tester) {
 	tester.createUserSession("belanger@getporter.dev", "hello")
 	tester.createUserSession("belanger@getporter.dev", "hello")
 }
 }
 
 
-func initUserWithContexts(tester *tester) {
-	initUserDefault(tester)
-
-	user, _ := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
-	user.Contexts = "context-test"
-
-	user.RawKubeConfig = []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin")
-
-	tester.repo.User.UpdateUser(user)
-}
-
 func userBasicBodyValidator(c *userTest, tester *tester, t *testing.T) {
 func userBasicBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	if body := tester.rr.Body.String(); strings.TrimSpace(body) != strings.TrimSpace(c.expBody) {
 	if body := tester.rr.Body.String(); strings.TrimSpace(body) != strings.TrimSpace(c.expBody) {
 		t.Errorf("%s, handler returned wrong body: got %v want %v",
 		t.Errorf("%s, handler returned wrong body: got %v want %v",
@@ -737,3 +519,16 @@ func userContextBodyValidator(c *userTest, tester *tester, t *testing.T) {
 			c.msg, gotBody, expBody)
 			c.msg, gotBody, expBody)
 	}
 	}
 }
 }
+
+func userProjectsListValidator(c *userTest, tester *tester, t *testing.T) {
+	gotBody := make([]*models.ProjectExternal, 0)
+	expBody := make([]*models.ProjectExternal, 0)
+
+	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}

+ 273 - 17
server/router/middleware/auth.go

@@ -3,27 +3,33 @@ package middleware
 import (
 import (
 	"bytes"
 	"bytes"
 	"encoding/json"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"fmt"
 	"io/ioutil"
 	"io/ioutil"
 	"net/http"
 	"net/http"
+	"net/url"
 	"strconv"
 	"strconv"
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 	"github.com/gorilla/sessions"
 	"github.com/gorilla/sessions"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
 )
 )
 
 
 // Auth implements the authorization functions
 // Auth implements the authorization functions
 type Auth struct {
 type Auth struct {
 	store      sessions.Store
 	store      sessions.Store
 	cookieName string
 	cookieName string
+	repo       *repository.Repository
 }
 }
 
 
 // NewAuth returns a new Auth instance
 // NewAuth returns a new Auth instance
 func NewAuth(
 func NewAuth(
 	store sessions.Store,
 	store sessions.Store,
 	cookieName string,
 	cookieName string,
+	repo *repository.Repository,
 ) *Auth {
 ) *Auth {
-	return &Auth{store, cookieName}
+	return &Auth{store, cookieName, repo}
 }
 }
 
 
 // BasicAuthenticate just checks that a user is logged in
 // BasicAuthenticate just checks that a user is logged in
@@ -44,43 +50,158 @@ func (auth *Auth) BasicAuthenticate(next http.Handler) http.Handler {
 type IDLocation uint
 type IDLocation uint
 
 
 const (
 const (
-	// URLParam location looks for {id} in the URL
+	// URLParam location looks for a parameter in the URL endpoint
 	URLParam IDLocation = iota
 	URLParam IDLocation = iota
-	// BodyParam location looks for user_id in the body
+	// BodyParam location looks for a parameter in the body
 	BodyParam
 	BodyParam
+	// QueryParam location looks for a parameter in the query string
+	QueryParam
 )
 )
 
 
-type bodyID struct {
+type bodyUserID struct {
 	UserID uint64 `json:"user_id"`
 	UserID uint64 `json:"user_id"`
 }
 }
 
 
+type bodyProjectID struct {
+	ProjectID uint64 `json:"project_id"`
+}
+
+type bodyServiceAccountID struct {
+	ServiceAccountID uint64 `json:"service_account_id"`
+}
+
 // DoesUserIDMatch checks the id URL parameter and verifies that it matches
 // DoesUserIDMatch checks the id URL parameter and verifies that it matches
 // the one stored in the session
 // the one stored in the session
 func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handler {
 func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		var id uint64
 		var err error
 		var err error
+		id, err := findUserIDInRequest(r, loc)
+
+		if err == nil && auth.doesSessionMatchID(r, uint(id)) {
+			next.ServeHTTP(w, r)
+		} else {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
 
 
-		if loc == URLParam {
-			id, err = strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
-		} else if loc == BodyParam {
-			form := &bodyID{}
-			body, _ := ioutil.ReadAll(r.Body)
-			err = json.Unmarshal(body, form)
+		return
+	})
+}
 
 
-			id = form.UserID
+// AccessType represents the various access types for a project
+type AccessType string
 
 
-			// need to create a new stream for the body
-			r.Body = ioutil.NopCloser(bytes.NewReader(body))
+// The various access types
+const (
+	ReadAccess  AccessType = "read"
+	WriteAccess AccessType = "write"
+)
+
+// DoesUserHaveProjectAccess looks for a project_id parameter and checks that the
+// user has access via the specified accessType
+func (auth *Auth) DoesUserHaveProjectAccess(
+	next http.Handler,
+	projLoc IDLocation,
+	accessType AccessType,
+) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var err error
+		projID, err := findProjIDInRequest(r, projLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
 		}
 		}
 
 
-		if err == nil && auth.doesSessionMatchID(r, uint(id)) {
-			next.ServeHTTP(w, r)
-		} else {
+		session, err := auth.store.Get(r, auth.cookieName)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		userID, ok := session.Values["user_id"].(uint)
+
+		if !ok {
 			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
 			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
 			return
 			return
 		}
 		}
 
 
+		// get the project
+		proj, err := auth.repo.Project.ReadProject(uint(projID))
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return
+		}
+
+		// look for the user role in the project
+		for _, role := range proj.Roles {
+			if role.UserID == userID {
+				if accessType == ReadAccess {
+					next.ServeHTTP(w, r)
+					return
+				} else if accessType == WriteAccess {
+					if role.Kind == models.RoleAdmin {
+						next.ServeHTTP(w, r)
+						return
+					}
+				}
+
+			}
+		}
+
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	})
+}
+
+// DoesUserHaveServiceAccountAccess looks for a project_id parameter and a
+// service_account_id parameter, and verifies that the service account belongs
+// to the project
+func (auth *Auth) DoesUserHaveServiceAccountAccess(
+	next http.Handler,
+	projLoc IDLocation,
+	saLoc IDLocation,
+) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		serviceAccountID, err := findServiceAccountIDInRequest(r, saLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		projID, err := findProjIDInRequest(r, projLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		// get the service accounts belonging to the project
+		serviceAccounts, err := auth.repo.ServiceAccount.ListServiceAccountsByProjectID(uint(projID))
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return
+		}
+
+		doesExist := false
+
+		for _, sa := range serviceAccounts {
+			if sa.ID == uint(serviceAccountID) {
+				doesExist = true
+				break
+			}
+		}
+
+		if doesExist {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
 		return
 		return
 	})
 	})
 }
 }
@@ -111,3 +232,138 @@ func (auth *Auth) isLoggedIn(w http.ResponseWriter, r *http.Request) bool {
 	}
 	}
 	return true
 	return true
 }
 }
+
+func findUserIDInRequest(r *http.Request, userLoc IDLocation) (uint64, error) {
+	var userID uint64
+	var err error
+
+	if userLoc == URLParam {
+		userID, err = strconv.ParseUint(chi.URLParam(r, "user_id"), 0, 64)
+
+		if err != nil {
+			return 0, err
+		}
+	} else if userLoc == BodyParam {
+		form := &bodyUserID{}
+		body, err := ioutil.ReadAll(r.Body)
+
+		if err != nil {
+			return 0, err
+		}
+
+		err = json.Unmarshal(body, form)
+
+		if err != nil {
+			return 0, err
+		}
+
+		userID = form.UserID
+
+		// need to create a new stream for the body
+		r.Body = ioutil.NopCloser(bytes.NewReader(body))
+	} else {
+		vals, err := url.ParseQuery(r.URL.RawQuery)
+
+		if err != nil {
+			return 0, err
+		}
+
+		if userStrArr, ok := vals["user_id"]; ok && len(userStrArr) == 1 {
+			userID, err = strconv.ParseUint(userStrArr[0], 10, 64)
+		} else {
+			return 0, errors.New("user id not found")
+		}
+	}
+
+	return userID, nil
+}
+
+func findProjIDInRequest(r *http.Request, projLoc IDLocation) (uint64, error) {
+	var projID uint64
+	var err error
+
+	if projLoc == URLParam {
+		projID, err = strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+		if err != nil {
+			return 0, err
+		}
+	} else if projLoc == BodyParam {
+		form := &bodyProjectID{}
+		body, err := ioutil.ReadAll(r.Body)
+
+		if err != nil {
+			return 0, err
+		}
+
+		err = json.Unmarshal(body, form)
+
+		if err != nil {
+			return 0, err
+		}
+
+		projID = form.ProjectID
+
+		// need to create a new stream for the body
+		r.Body = ioutil.NopCloser(bytes.NewReader(body))
+	} else {
+		vals, err := url.ParseQuery(r.URL.RawQuery)
+
+		if err != nil {
+			return 0, err
+		}
+
+		if projStrArr, ok := vals["project_id"]; ok && len(projStrArr) == 1 {
+			projID, err = strconv.ParseUint(projStrArr[0], 10, 64)
+		} else {
+			return 0, errors.New("project id not found")
+		}
+	}
+
+	return projID, nil
+}
+
+func findServiceAccountIDInRequest(r *http.Request, saLoc IDLocation) (uint64, error) {
+	var saID uint64
+	var err error
+
+	if saLoc == URLParam {
+		saID, err = strconv.ParseUint(chi.URLParam(r, "service_account_id"), 0, 64)
+
+		if err != nil {
+			return 0, err
+		}
+	} else if saLoc == BodyParam {
+		form := &bodyServiceAccountID{}
+		body, err := ioutil.ReadAll(r.Body)
+
+		if err != nil {
+			return 0, err
+		}
+
+		err = json.Unmarshal(body, form)
+
+		if err != nil {
+			return 0, err
+		}
+
+		saID = form.ServiceAccountID
+
+		// need to create a new stream for the body
+		r.Body = ioutil.NopCloser(bytes.NewReader(body))
+	} else {
+		vals, err := url.ParseQuery(r.URL.RawQuery)
+
+		if err != nil {
+			return 0, err
+		}
+
+		if saStrArr, ok := vals["service_account_id"]; ok && len(saStrArr) == 1 {
+			saID, err = strconv.ParseUint(saStrArr[0], 10, 64)
+		} else {
+			return 0, errors.New("service account id not found")
+		}
+	}
+
+	return saID, nil
+}

+ 216 - 20
server/router/router.go

@@ -6,6 +6,7 @@ import (
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 	"github.com/gorilla/sessions"
 	"github.com/gorilla/sessions"
+	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/requestlog"
 	"github.com/porter-dev/porter/server/requestlog"
 	mw "github.com/porter-dev/porter/server/router/middleware"
 	mw "github.com/porter-dev/porter/server/router/middleware"
@@ -17,42 +18,237 @@ func New(
 	store sessions.Store,
 	store sessions.Store,
 	cookieName string,
 	cookieName string,
 	staticFilePath string,
 	staticFilePath string,
+	repo *repository.Repository,
 ) *chi.Mux {
 ) *chi.Mux {
 	l := a.Logger()
 	l := a.Logger()
 	r := chi.NewRouter()
 	r := chi.NewRouter()
-	auth := mw.NewAuth(store, cookieName)
+	auth := mw.NewAuth(store, cookieName, repo)
 
 
 	r.Route("/api", func(r chi.Router) {
 	r.Route("/api", func(r chi.Router) {
 		r.Use(mw.ContentTypeJSON)
 		r.Use(mw.ContentTypeJSON)
 
 
+		// health checks
+		r.Method("GET", "/livez", http.HandlerFunc(a.HandleLive))
+		r.Method("GET", "/readyz", http.HandlerFunc(a.HandleReady))
+
 		// /api/users routes
 		// /api/users routes
-		r.Method("GET", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUser, l), mw.URLParam))
-		r.Method("GET", "/users/{id}/contexts", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUserContexts, l), mw.URLParam))
+		r.Method("GET", "/users/{user_id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUser, l), mw.URLParam))
+		r.Method("GET", "/users/{user_id}/projects", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleListUserProjects, l), mw.URLParam))
 		r.Method("POST", "/users", requestlog.NewHandler(a.HandleCreateUser, l))
 		r.Method("POST", "/users", requestlog.NewHandler(a.HandleCreateUser, l))
-		r.Method("PUT", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleUpdateUser, l), mw.URLParam))
-		r.Method("DELETE", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleDeleteUser, l), mw.URLParam))
+		r.Method("DELETE", "/users/{user_id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleDeleteUser, l), mw.URLParam))
 		r.Method("POST", "/login", requestlog.NewHandler(a.HandleLoginUser, l))
 		r.Method("POST", "/login", requestlog.NewHandler(a.HandleLoginUser, l))
 		r.Method("GET", "/auth/check", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleAuthCheck, l)))
 		r.Method("GET", "/auth/check", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleAuthCheck, l)))
 		r.Method("POST", "/logout", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleLogoutUser, l)))
 		r.Method("POST", "/logout", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleLogoutUser, l)))
 
 
-		// /api/releases routes
-		r.Method("GET", "/releases", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListReleases, l)))
-		r.Method("GET", "/releases/{name}/{revision}/components", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetReleaseComponents, l)))
-		r.Method("GET", "/releases/{name}/history", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListReleaseHistory, l)))
-		r.Method("POST", "/releases/{name}/upgrade", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleUpgradeRelease, l)))
-		r.Method("GET", "/releases/{name}/{revision}", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetRelease, l)))
-		r.Method("POST", "/releases/{name}/rollback", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleRollbackRelease, l)))
+		// /api/oauth routes
+		r.Method(
+			"GET",
+			"/oauth/projects/{project_id}/github",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleGithubOAuthStartProject, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
 
 
-		// /api/templates routes
-		r.Method("GET", "/templates", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListTemplates, l)))
+		r.Method(
+			"GET",
+			"/oauth/github/callback",
+			requestlog.NewHandler(a.HandleGithubOAuthCallback, l),
+		)
+
+		// /api/projects routes
+		r.Method(
+			"GET",
+			"/projects/{project_id}",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleReadProject, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/clusters",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListProjectClusters, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method("POST", "/projects", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleCreateProject, l)))
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/candidates",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleCreateProjectSACandidates, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/candidates",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListProjectSACandidates, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/candidates/{candidate_id}/resolve",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleResolveSACandidateActions, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		// /api/projects/{project_id}/releases routes
+		r.Method(
+			"GET",
+			"/projects/{project_id}/releases",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleListReleases, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
 
 
-		// /api/repos routes
-		r.Method("GET", "/repos", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListRepos, l)))
-		r.Method("GET", "/repos/{kind}/{name}/branches", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetBranches, l)))
-		r.Method("GET", "/repos/{kind}/{name}/{branch}/contents", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetBranchContents, l)))
+		r.Method(
+			"GET",
+			"/projects/{project_id}/releases/{name}/{revision}/components",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleGetReleaseComponents, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/releases/{name}/history",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleListReleaseHistory, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/releases/{name}/upgrade",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleUpgradeRelease, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/releases/{name}/{revision}",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleGetRelease, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/releases/{name}/rollback",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleRollbackRelease, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		// /api/projects/{project_id}/repos routes
+		r.Method(
+			"GET",
+			"/projects/{project_id}/repos",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListRepos, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/repos/{kind}/{name}/branches",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleGetBranches, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/repos/{kind}/{name}/{branch}/contents",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleGetBranchContents, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		// /api/templates routes
+		r.Method(
+			"GET",
+			"/templates",
+			auth.BasicAuthenticate(
+				requestlog.NewHandler(a.HandleListTemplates, l),
+			),
+		)
 
 
-		// /api/k8s routes
-		r.Method("GET", "/k8s/namespaces", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListNamespaces, l)))
+		// /api/projects/{project_id}/k8s routes
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/namespaces",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleListNamespaces, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
 	})
 	})
 
 
 	fs := http.FileServer(http.Dir(staticFilePath))
 	fs := http.FileServer(http.Dir(staticFilePath))