ソースを参照

kubeconfig resolver actions

Alexander Belanger 5 年 前
コミット
b26994308d

+ 3 - 0
internal/config/config.go

@@ -29,6 +29,9 @@ type ServerConf struct {
 // DBConf is the database configuration: if generated from environment variables,
 // it assumes the default docker-compose configuration is used
 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"`
 	Port     int    `env:"DB_PORT,default=5432"`
 	Username string `env:"DB_USER,default=porter"`

+ 323 - 0
internal/forms/action.go

@@ -0,0 +1,323 @@
+package forms
+
+import (
+	"encoding/base64"
+
+	"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: 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 {
+			sar.SA.OIDCCertificateAuthorityData = []byte(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
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(oida.OIDCIssuerCAData)
+
+	// skip if decoding error
+	if err != nil {
+		return err
+	}
+
+	oida.ServiceAccountActionResolver.SA.OIDCCertificateAuthorityData = decoded
+
+	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
+}

+ 678 - 0
internal/forms/action_test.go

@@ -0,0 +1,678 @@
+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)
+	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 sa.AuthMechanism != models.OIDC {
+		t.Errorf("service account auth mechanism is not %s\n", models.OIDC)
+	}
+
+	if string(sa.OIDCCertificateAuthorityData) != string(decodedStr) {
+		t.Errorf("service account key data and input do not match: expected %s, got %s\n",
+			string(sa.OIDCCertificateAuthorityData), string(decodedStr))
+	}
+}
+
+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
+`

+ 195 - 0
internal/kubernetes/kubeconfig.go

@@ -1,11 +1,206 @@
 package kubernetes
 
 import (
+	"errors"
+	"strings"
+
 	"github.com/porter-dev/porter/internal/models"
 	"k8s.io/client-go/tools/clientcmd"
 	"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])
+
+		actionsArr := make([]string, 0)
+
+		if authInfoActions != "" {
+			actionsArr = append(actionsArr, strings.Split(authInfoActions, ",")...)
+		}
+
+		if clusterActions != "" {
+			actionsArr = append(actionsArr, strings.Split(clusterActions, ",")...)
+		}
+
+		// join the cluster and auth info actions together
+		actions := strings.Join(actionsArr, ",")
+
+		// 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{
+				ActionNames:     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 {
+		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 string) {
+	actionsArr := make([]string, 0)
+
+	if (authInfo.ClientCertificate != "" || len(authInfo.ClientCertificateData) != 0) &&
+		(authInfo.ClientKey != "" || len(authInfo.ClientKeyData) != 0) {
+		if len(authInfo.ClientCertificateData) == 0 {
+			actionsArr = append(actionsArr, models.ClientCertDataAction)
+		}
+
+		if len(authInfo.ClientKeyData) == 0 {
+			actionsArr = append(actionsArr, models.ClientKeyDataAction)
+		}
+
+		return models.X509, strings.Join(actionsArr, ",")
+	}
+
+	if authInfo.AuthProvider != nil {
+		switch authInfo.AuthProvider.Name {
+		case "oidc":
+			_, isFile := authInfo.AuthProvider.Config["idp-certificate-authority"]
+			data, isData := authInfo.AuthProvider.Config["idp-certificate-authority-data"]
+
+			if isFile && (!isData || data == "") {
+				return models.OIDC, models.OIDCIssuerDataAction
+			}
+
+			return models.OIDC, ""
+		case "gcp":
+			return models.GCP, models.GCPKeyDataAction
+		}
+	}
+
+	if authInfo.Exec != nil {
+		if authInfo.Exec.Command == "aws" || authInfo.Exec.Command == "aws-iam-authenticator" {
+			return models.AWS, models.AWSKeyDataAction
+		}
+	}
+
+	if authInfo.Token != "" || authInfo.TokenFile != "" {
+		if authInfo.Token == "" {
+			return models.Bearer, models.TokenDataAction
+		}
+
+		return models.Bearer, ""
+	}
+
+	if authInfo.Username != "" && authInfo.Password != "" {
+		return models.Basic, ""
+	}
+
+	return models.NotAvailable, ""
+}
+
+// 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 string) {
+	if cluster.CertificateAuthority != "" && len(cluster.CertificateAuthorityData) == 0 {
+		return models.ClusterCADataAction
+	}
+
+	return ""
+}
+
+// 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
+}
+
 // GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
 // a context name, and the set of allowed contexts.
 func GetRestrictedClientConfigFromBytes(

+ 545 - 9
internal/kubernetes/kubeconfig_test.go

@@ -7,6 +7,7 @@ import (
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
+	"k8s.io/client-go/tools/clientcmd"
 )
 
 type kubeConfigTest struct {
@@ -165,6 +166,274 @@ 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{
+				ActionNames:     "upload-cluster-ca-data",
+				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{
+				ActionNames:     "",
+				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{
+				ActionNames:     "upload-client-cert-data",
+				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{
+				ActionNames:     "upload-client-key-data",
+				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{
+				ActionNames:     "upload-client-cert-data,upload-client-key-data",
+				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{
+				ActionNames:     "",
+				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{
+				ActionNames:     "upload-token-data",
+				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{
+				ActionNames:     "upload-gcp-key-data",
+				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{
+				ActionNames:     "upload-aws-key-data",
+				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{
+				ActionNames:     "upload-aws-key-data",
+				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{
+				ActionNames:     "upload-oidc-idp-issuer-ca-data",
+				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{
+				ActionNames:     "",
+				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{
+				ActionNames:     "",
+				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)
+			}
+
+			// compare action names by splitting the arrays (actions may be out of order)
+			resActions := strings.Split(res.ActionNames, ",")
+			expActions := strings.Split(exp.ActionNames, ",")
+
+			if len(resActions) != len(expActions) {
+				t.Errorf("%s failed on action names: expected length %d, got length %d\n",
+					c.name, len(expActions), len(resActions))
+			} else {
+				for _, actionName := range expActions {
+					if !strings.Contains(res.ActionNames, actionName) {
+						t.Errorf("%s failed on action names: expected res to contain %s, got %s\n",
+							c.name, actionName, res.ActionNames)
+					}
+				}
+			}
+
+			// 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 = `
 apiVersion: v1
 kind: Config
@@ -266,7 +535,28 @@ users:
   - 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
 kind: Config
 preferences: {}
@@ -281,16 +571,262 @@ contexts:
     user: test-admin
   name: context-test
 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:
     auth-provider:
       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
 `
+
+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
+`

+ 62 - 0
internal/models/action.go

@@ -0,0 +1,62 @@
+package models
+
+// ServiceAccountAction is an action that needs to be performed to set up
+// a service account
+type ServiceAccountAction 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"`
+}
+
+// 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"
+)
+
+// ServiceAccountsActions are actions must be performed to initialize a
+// ServiceAccount
+var ServiceAccountsActions = map[string]ServiceAccountAction{
+	"upload-cluster-ca-data": ServiceAccountAction{
+		Name:   ClusterCADataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "cluster_ca_data",
+	},
+	"upload-client-cert-data": ServiceAccountAction{
+		Name:   ClientCertDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "client_cert_data",
+	},
+	"upload-client-key-data": ServiceAccountAction{
+		Name:   ClientKeyDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "client_key_data",
+	},
+	"upload-oidc-idp-issuer-ca-data": ServiceAccountAction{
+		Name:   OIDCIssuerDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "oidc_idp_issuer_ca_data",
+	},
+	"upload-token-data": ServiceAccountAction{
+		Name:   TokenDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "token_data",
+	},
+	"upload-gcp-key-data": ServiceAccountAction{
+		Name:   GCPKeyDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "gcp_key_data",
+	},
+	"upload-aws-key-data": ServiceAccountAction{
+		Name:   AWSKeyDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "aws_key_data",
+	},
+}

+ 37 - 0
internal/models/cluster.go

@@ -0,0 +1,37 @@
+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"`
+	CertificateAuthorityData []byte `json:"certificate-authority-data,omitempty"`
+	ProxyURL                 string `json:"proxy-url,omitempty"`
+}
+
+// ClusterExternal is the external cluster type to be sent over REST
+type ClusterExternal struct {
+	ServiceAccountID      uint   `json:"service_account_id"`
+	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{
+		ServiceAccountID:      c.ServiceAccountID,
+		Server:                c.Server,
+		TLSServerName:         c.TLSServerName,
+		InsecureSkipTLSVerify: c.InsecureSkipTLSVerify,
+		ProxyURL:              c.ProxyURL,
+	}
+}

+ 142 - 0
internal/models/serviceaccount.go

@@ -0,0 +1,142 @@
+package models
+
+import (
+	"strings"
+
+	"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"`
+
+	// a comma-separated list of action names to perform to create a ServiceAccount
+	ActionNames string `json:"action_names"`
+
+	ClusterName     string `json:"cluster_name"`
+	ClusterEndpoint string `json:"cluster_endpoint"`
+	AuthMechanism   string `json:"auth_mechanism"`
+	Kubeconfig      []byte `json:"kubeconfig"`
+}
+
+// ServiceAccountCandidateExternal represents the ServiceAccountCandidate type that is
+// sent over REST
+type ServiceAccountCandidateExternal struct {
+	Actions         []ServiceAccountAction `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([]ServiceAccountAction, 0)
+
+	// split the actions string and populate actions
+	actionsArr := strings.Split(s.ActionNames, ",")
+
+	for _, actionName := range actionsArr {
+		actions = append(actions, ServiceAccountsActions[actionName])
+	}
+
+	return &ServiceAccountCandidateExternal{
+		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 []byte `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 {
+	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{
+		ProjectID:     s.ProjectID,
+		Kind:          s.Kind,
+		Clusters:      clusters,
+		AuthMechanism: s.AuthMechanism,
+	}
+}

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

+ 4 - 3
internal/repository/repository.go

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

+ 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)
+	DeleteServiceAccountCandidate(saCandidate *models.ServiceAccountCandidate) (*models.ServiceAccountCandidate, error)
+	CreateServiceAccount(sa *models.ServiceAccount) (*models.ServiceAccount, error)
+	ReadServiceAccount(id uint) (*models.ServiceAccount, error)
+	DeleteServiceAccount(sa *models.ServiceAccount) (*models.ServiceAccount, error)
+}

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

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

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

@@ -0,0 +1,128 @@
+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
+}
+
+// DeleteServiceAccountCandidate deletes a service account candidate
+func (repo *ServiceAccountRepository) DeleteServiceAccountCandidate(
+	saCandidate *models.ServiceAccountCandidate,
+) (*models.ServiceAccountCandidate, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(saCandidate.ID-1) >= len(repo.serviceAccountCandidates) || repo.serviceAccountCandidates[saCandidate.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(saCandidate.ID - 1)
+	repo.serviceAccountCandidates[index] = nil
+
+	return saCandidate, 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")
+	}
+
+	repo.serviceAccounts = append(repo.serviceAccounts, sa)
+	sa.ID = uint(len(repo.serviceAccounts))
+
+	for i, cluster := range sa.Clusters {
+		(&cluster).ServiceAccountID = sa.ID
+		sa.Clusters[i] = cluster
+	}
+
+	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
+}
+
+// DeleteServiceAccount deletes a service account
+func (repo *ServiceAccountRepository) DeleteServiceAccount(
+	sa *models.ServiceAccount,
+) (*models.ServiceAccount, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(sa.ID-1) >= len(repo.serviceAccounts) || repo.serviceAccounts[sa.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(sa.ID - 1)
+	repo.serviceAccounts[index] = nil
+
+	return sa, nil
+}