Selaa lähdekoodia

service account candidate endpoints

Alexander Belanger 5 vuotta sitten
vanhempi
sitoutus
72f51c49ca

+ 8 - 1
internal/forms/action.go

@@ -124,7 +124,14 @@ func (sar *ServiceAccountActionResolver) PopulateServiceAccount(
 		}
 
 		if caData, ok := authInfo.AuthProvider.Config["idp-certificate-authority-data"]; ok {
-			sar.SA.OIDCCertificateAuthorityData = []byte(caData)
+			decoded, err := base64.StdEncoding.DecodeString(caData)
+
+			// skip if decoding error
+			if err != nil {
+				return err
+			}
+
+			sar.SA.OIDCCertificateAuthorityData = decoded
 		}
 
 		if idToken, ok := authInfo.AuthProvider.Config["id-token"]; ok {

+ 2 - 2
internal/forms/candidate.go

@@ -9,13 +9,13 @@ import (
 // creating a list of ServiceAccountCandidates from a kubeconfig
 type CreateServiceAccountCandidatesForm struct {
 	ProjectID  uint   `json:"project_id"`
-	Kubeconfig []byte `json:"kubeconfig"`
+	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(csa.Kubeconfig)
+	candidates, err := kubernetes.GetServiceAccountCandidates([]byte(csa.Kubeconfig))
 
 	if err != nil {
 		return nil, err

+ 15 - 0
internal/models/action.go

@@ -44,6 +44,21 @@ type ServiceAccountActionExternal struct {
 	Fields   string `json:"fields"`
 }
 
+// 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" form:"required"`
+	ClientCertData   string `json:"client_cert_data" form:"required"`
+	ClientKeyData    string `json:"client_key_data" form:"required"`
+	OIDCIssuerCAData string `json:"oidc_idp_issuer_ca_data" form:"required"`
+	TokenData        string `json:"token_data" form:"required"`
+	GCPKeyData       string `json:"gcp_key_data" form:"required"`
+	AWSKeyData       string `json:"aws_key_data" form:"required"`
+}
+
 // ServiceAccountActionInfo contains the information for actions to be
 // performed in order to initialize a ServiceAccount
 type ServiceAccountActionInfo struct {

+ 6 - 0
internal/models/role.go

@@ -4,6 +4,12 @@ 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

+ 4 - 0
internal/models/serviceaccount.go

@@ -34,6 +34,7 @@ type ServiceAccountCandidate struct {
 // 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"`
@@ -51,6 +52,7 @@ func (s *ServiceAccountCandidate) Externalize() *ServiceAccountCandidateExternal
 	}
 
 	return &ServiceAccountCandidateExternal{
+		ID:              s.ID,
 		Actions:         actions,
 		ProjectID:       s.ProjectID,
 		Kind:            s.Kind,
@@ -113,6 +115,7 @@ type ServiceAccount struct {
 
 // 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"`
@@ -128,6 +131,7 @@ func (s *ServiceAccount) Externalize() *ServiceAccountExternal {
 	}
 
 	return &ServiceAccountExternal{
+		ID:            s.ID,
 		ProjectID:     s.ProjectID,
 		Kind:          s.Kind,
 		Clusters:      clusters,

+ 2 - 0
internal/repository/serviceaccount.go

@@ -9,8 +9,10 @@ import (
 type ServiceAccountRepository interface {
 	CreateServiceAccountCandidate(saCandidate *models.ServiceAccountCandidate) (*models.ServiceAccountCandidate, error)
 	ReadServiceAccountCandidate(id uint) (*models.ServiceAccountCandidate, error)
+	ListServiceAccountCandidatesByProjectID(projectID uint) ([]*models.ServiceAccountCandidate, error)
 	DeleteServiceAccountCandidate(saCandidate *models.ServiceAccountCandidate) (*models.ServiceAccountCandidate, error)
 	CreateServiceAccount(sa *models.ServiceAccount) (*models.ServiceAccount, error)
 	ReadServiceAccount(id uint) (*models.ServiceAccount, error)
+	ListServiceAccountsByProjectID(projectID uint) ([]*models.ServiceAccount, error)
 	DeleteServiceAccount(sa *models.ServiceAccount) (*models.ServiceAccount, error)
 }

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

@@ -56,6 +56,26 @@ func (repo *ServiceAccountRepository) ReadServiceAccountCandidate(
 	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
+}
+
 // DeleteServiceAccountCandidate deletes a service account candidate
 func (repo *ServiceAccountRepository) DeleteServiceAccountCandidate(
 	saCandidate *models.ServiceAccountCandidate,
@@ -82,6 +102,10 @@ func (repo *ServiceAccountRepository) CreateServiceAccount(
 		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))
 
@@ -109,6 +133,26 @@ func (repo *ServiceAccountRepository) ReadServiceAccount(
 	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
+}
+
 // DeleteServiceAccount deletes a service account
 func (repo *ServiceAccountRepository) DeleteServiceAccount(
 	sa *models.ServiceAccount,

+ 186 - 4
server/api/project_handler.go

@@ -59,11 +59,11 @@ func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// create a new Role with the user as the owner
+	// create a new Role with the user as the admin
 	_, err = app.repo.Project.CreateProjectRole(projModel, &models.Role{
 		UserID:    userID,
 		ProjectID: projModel.ID,
-		Kind:      "Owner",
+		Kind:      models.RoleAdmin,
 	})
 
 	if err != nil {
@@ -79,7 +79,7 @@ func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
 // 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, "id"), 0, 64)
+	id, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
 	if err != nil || id == 0 {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
@@ -98,7 +98,7 @@ func (app *App) HandleReadProject(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusOK)
 
 	if err := json.NewEncoder(w).Encode(projExt); err != nil {
-		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 	}
 }
@@ -137,6 +137,8 @@ func (app *App) HandleCreateProjectSACandidates(w http.ResponseWriter, r *http.R
 		return
 	}
 
+	extSACandidates := make([]*models.ServiceAccountCandidateExternal, 0)
+
 	for _, saCandidate := range saCandidates {
 		// handle write to the database
 		saCandidate, err = app.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
@@ -147,7 +149,187 @@ func (app *App) HandleCreateProjectSACandidates(w http.ResponseWriter, r *http.R
 		}
 
 		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)
+	}
 }

+ 205 - 2
server/api/project_handler_test.go

@@ -1,12 +1,14 @@
 package api_test
 
 import (
+	"encoding/base64"
 	"encoding/json"
 	"net/http"
 	"reflect"
 	"strings"
 	"testing"
 
+	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
 )
 
@@ -103,7 +105,7 @@ var readProjectTests = []*projTest{
 		endpoint:  "/api/projects/1",
 		body:      ``,
 		expStatus: http.StatusOK,
-		expBody:   `{"id":1,"name":"project-test","roles":[{"id":0,"kind":"Owner","user_id":1,"project_id":1}]}`,
+		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,
@@ -115,6 +117,134 @@ func TestHandleReadProject(t *testing.T) {
 	testProjRequests(t, readProjectTests, 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]
+
+				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))
+				}
+
+				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","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","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":[{"service_account_id":1,"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) {
@@ -129,10 +259,26 @@ func initProject(tester *tester) {
 	tester.repo.Project.CreateProjectRole(projModel, &models.Role{
 		UserID:    user.ID,
 		ProjectID: projModel.ID,
-		Kind:      "Owner",
+		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 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",
@@ -152,3 +298,60 @@ func projectModelBodyValidator(c *projTest, tester *tester, t *testing.T) {
 			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)
+	}
+}
+
+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
+`

+ 3 - 3
server/api/user_handler.go

@@ -194,7 +194,7 @@ func (app *App) HandleReadUserContexts(w http.ResponseWriter, r *http.Request) {
 // 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)
+	id, err := strconv.ParseUint(chi.URLParam(r, "user_id"), 0, 64)
 
 	if err != nil || id == 0 {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
@@ -215,7 +215,7 @@ func (app *App) HandleUpdateUser(w http.ResponseWriter, r *http.Request) {
 
 // HandleDeleteUser removes a user after checking that the sent password is correct
 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 {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
@@ -308,7 +308,7 @@ func (app *App) writeUser(
 }
 
 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 {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)

+ 26 - 8
server/router/middleware/auth.go

@@ -10,6 +10,7 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/gorilla/sessions"
+	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 )
 
@@ -79,12 +80,21 @@ func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handle
 	})
 }
 
-// DoesUserHaveProjectReadAccess looks for a project_id parameter and checks that the
-// user has access to read that project
-func (auth *Auth) DoesUserHaveProjectReadAccess(
+// AccessType represents the various access types for a project
+type AccessType string
+
+// 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,
-	userLoc IDLocation,
 	projLoc IDLocation,
+	accessType AccessType,
 ) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		var err error
@@ -115,8 +125,16 @@ func (auth *Auth) DoesUserHaveProjectReadAccess(
 		// look for the user role in the project
 		for _, role := range proj.Roles {
 			if role.UserID == userID {
-				next.ServeHTTP(w, r)
-				return
+				if accessType == ReadAccess {
+					next.ServeHTTP(w, r)
+					return
+				} else if accessType == WriteAccess {
+					if role.Kind == models.RoleAdmin {
+						next.ServeHTTP(w, r)
+						return
+					}
+				}
+
 			}
 		}
 
@@ -156,7 +174,7 @@ func findUserIDInRequest(r *http.Request, userLoc IDLocation) uint64 {
 	var userID uint64
 
 	if userLoc == URLParam {
-		userID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+		userID, _ = strconv.ParseUint(chi.URLParam(r, "user_id"), 0, 64)
 	} else if userLoc == BodyParam {
 		form := &bodyUserID{}
 		body, _ := ioutil.ReadAll(r.Body)
@@ -175,7 +193,7 @@ func findProjIDInRequest(r *http.Request, projLoc IDLocation) uint64 {
 	var projID uint64
 
 	if projLoc == URLParam {
-		projID, _ = strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+		projID, _ = strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 	} else if projLoc == BodyParam {
 		form := &bodyProjectID{}
 		body, _ := ioutil.ReadAll(r.Body)

+ 44 - 5
server/router/router.go

@@ -28,19 +28,58 @@ func New(
 		r.Use(mw.ContentTypeJSON)
 
 		// /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}/contexts", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUserContexts, l), mw.URLParam))
 		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("PUT", "/users/{user_id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleUpdateUser, 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("GET", "/auth/check", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleAuthCheck, l)))
 		r.Method("POST", "/logout", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleLogoutUser, l)))
 
 		// /api/projects routes
-		r.Method("GET", "/projects/{id}", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleReadProject, l)))
+		r.Method(
+			"GET",
+			"/projects/{project_id}",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleReadProject, 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/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)))