Browse Source

release + k8s endpoints restructuring

Alexander Belanger 5 years ago
parent
commit
876b9a6a29

+ 51 - 57
cmd/app/main.go

@@ -2,16 +2,10 @@ package main
 
 import (
 	"fmt"
-	"io/ioutil"
 	"log"
 	"net/http"
-	"os"
-
-	"github.com/porter-dev/porter/internal/kubernetes"
 
 	"github.com/gorilla/sessions"
-	"github.com/porter-dev/porter/internal/forms"
-	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	"github.com/porter-dev/porter/server/api"
@@ -38,13 +32,13 @@ func main() {
 	repo := gorm.NewRepository(db)
 
 	// upsert admin if config requires
-	if appConf.Db.AdminInit {
-		err := upsertAdmin(repo.User, appConf.Db.AdminEmail, appConf.Db.AdminPassword)
+	// if appConf.Db.AdminInit {
+	// 	err := upsertAdmin(repo.User, appConf.Db.AdminEmail, appConf.Db.AdminPassword)
 
-		if err != nil {
-			fmt.Println("Error while upserting admin: " + err.Error())
-		}
-	}
+	// 	if err != nil {
+	// 		fmt.Println("Error while upserting admin: " + err.Error())
+	// 	}
+	// }
 
 	// declare as Store interface (methods Get, New, Save)
 	var store sessions.Store
@@ -73,64 +67,64 @@ func main() {
 	}
 }
 
-func upsertAdmin(repo repository.UserRepository, email, pw string) error {
-	admUser, err := repo.ReadUserByEmail(email)
+// func upsertAdmin(repo repository.UserRepository, email, pw string) error {
+// 	admUser, err := repo.ReadUserByEmail(email)
 
-	// create the user in this case
-	if err != nil {
-		form := forms.CreateUserForm{
-			Email:    email,
-			Password: pw,
-		}
+// 	// create the user in this case
+// 	if err != nil {
+// 		form := forms.CreateUserForm{
+// 			Email:    email,
+// 			Password: pw,
+// 		}
 
-		admUser, err = form.ToUser(repo)
+// 		admUser, err = form.ToUser(repo)
 
-		if err != nil {
-			return err
-		}
+// 		if err != nil {
+// 			return err
+// 		}
 
-		admUser, err = repo.CreateUser(admUser)
+// 		admUser, err = repo.CreateUser(admUser)
 
-		if err != nil {
-			return err
-		}
-	}
+// 		if err != nil {
+// 			return err
+// 		}
+// 	}
 
-	filename := "/porter/porter.kubeconfig"
+// 	filename := "/porter/porter.kubeconfig"
 
-	// read if kubeconfig file exists, if it does update the user
-	if _, err := os.Stat(filename); !os.IsNotExist(err) {
-		fileBytes, err := ioutil.ReadFile(filename)
+// 	// read if kubeconfig file exists, if it does update the user
+// 	if _, err := os.Stat(filename); !os.IsNotExist(err) {
+// 		fileBytes, err := ioutil.ReadFile(filename)
 
-		contexts := make([]string, 0)
-		allContexts, err := kubernetes.GetContextsFromBytes(fileBytes, []string{})
+// 		contexts := make([]string, 0)
+// 		allContexts, err := kubernetes.GetContextsFromBytes(fileBytes, []string{})
 
-		if err != nil {
-			return err
-		}
+// 		if err != nil {
+// 			return err
+// 		}
 
-		for _, context := range allContexts {
-			contexts = append(contexts, context.Name)
-		}
+// 		for _, context := range allContexts {
+// 			contexts = append(contexts, context.Name)
+// 		}
 
-		form := forms.UpdateUserForm{
-			ID:              admUser.ID,
-			RawKubeConfig:   string(fileBytes),
-			AllowedContexts: contexts,
-		}
+// 		form := forms.UpdateUserForm{
+// 			ID:              admUser.ID,
+// 			RawKubeConfig:   string(fileBytes),
+// 			AllowedContexts: contexts,
+// 		}
 
-		admUser, err = form.ToUser(repo)
+// 		admUser, err = form.ToUser(repo)
 
-		if err != nil {
-			return err
-		}
+// 		if err != nil {
+// 			return err
+// 		}
 
-		admUser, err = repo.UpdateUser(admUser)
+// 		admUser, err = repo.UpdateUser(admUser)
 
-		if err != nil {
-			return err
-		}
-	}
+// 		if err != nil {
+// 			return err
+// 		}
+// 	}
 
-	return nil
-}
+// 	return nil
+// }

+ 8 - 16
internal/forms/action.go

@@ -124,14 +124,10 @@ func (sar *ServiceAccountActionResolver) PopulateServiceAccount(
 		}
 
 		if caData, ok := authInfo.AuthProvider.Config["idp-certificate-authority-data"]; ok {
-			decoded, err := base64.StdEncoding.DecodeString(caData)
-
-			// skip if decoding error
-			if err != nil {
-				return err
-			}
-
-			sar.SA.OIDCCertificateAuthorityData = decoded
+			// based on the implementation, the oidc plugin expects the data to be base64 encoded,
+			// which means we will not decode it here
+			// reference: https://github.com/kubernetes/kubernetes/blob/9dfb4c876bfca7a5ae84259fae2bc337ed90c2d7/staging/src/k8s.io/client-go/plugin/pkg/client/auth/oidc/oidc.go#L135
+			sar.SA.OIDCCertificateAuthorityData = caData
 		}
 
 		if idToken, ok := authInfo.AuthProvider.Config["id-token"]; ok {
@@ -254,14 +250,10 @@ func (oida *OIDCIssuerDataAction) PopulateServiceAccount(
 		return err
 	}
 
-	decoded, err := base64.StdEncoding.DecodeString(oida.OIDCIssuerCAData)
-
-	// skip if decoding error
-	if err != nil {
-		return err
-	}
-
-	oida.ServiceAccountActionResolver.SA.OIDCCertificateAuthorityData = decoded
+	// based on the implementation, the oidc plugin expects the data to be base64 encoded,
+	// which means we will not decode it here
+	// reference: https://github.com/kubernetes/kubernetes/blob/9dfb4c876bfca7a5ae84259fae2bc337ed90c2d7/staging/src/k8s.io/client-go/plugin/pkg/client/auth/oidc/oidc.go#L135
+	oida.ServiceAccountActionResolver.SA.OIDCCertificateAuthorityData = oida.OIDCIssuerCAData
 
 	return nil
 }

+ 2 - 3
internal/forms/action_test.go

@@ -478,7 +478,6 @@ func TestPopulateServiceAccountOIDCAction(t *testing.T) {
 	}
 
 	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
-	decodedStr, _ := base64.StdEncoding.DecodeString("LS0tLS1CRUdJTiBDRVJ=")
 
 	if len(sa.Clusters) != 1 {
 		t.Fatalf("cluster not written\n")
@@ -492,9 +491,9 @@ func TestPopulateServiceAccountOIDCAction(t *testing.T) {
 		t.Errorf("service account auth mechanism is not %s\n", models.OIDC)
 	}
 
-	if string(sa.OIDCCertificateAuthorityData) != string(decodedStr) {
+	if string(sa.OIDCCertificateAuthorityData) != "LS0tLS1CRUdJTiBDRVJ=" {
 		t.Errorf("service account key data and input do not match: expected %s, got %s\n",
-			string(sa.OIDCCertificateAuthorityData), string(decodedStr))
+			string(sa.OIDCCertificateAuthorityData), "LS0tLS1CRUdJTiBDRVJ=")
 	}
 }
 

+ 26 - 12
internal/forms/k8s.go

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

+ 34 - 16
internal/forms/release.go

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

+ 0 - 61
internal/forms/user.go

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

+ 7 - 8
internal/helm/config.go

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

+ 6 - 7
internal/kubernetes/config.go

@@ -6,6 +6,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/porter-dev/porter/internal/models"
 	"k8s.io/apimachinery/pkg/api/meta"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
@@ -59,9 +60,8 @@ func GetAgentTesting(objects ...runtime.Object) *Agent {
 // OutOfClusterConfig is the set of parameters required for an out-of-cluster connection.
 // This implements RESTClientGetter
 type OutOfClusterConfig struct {
-	KubeConfig      []byte
-	AllowedContexts []string
-	Context         string `json:"context" form:"required"`
+	ServiceAccount *models.ServiceAccount `form:"required"`
+	ClusterID      uint                   `json:"cluster_id" form:"required"`
 }
 
 // ToRESTConfig creates a kubernetes REST client factory -- it simply calls ClientConfig on
@@ -80,10 +80,9 @@ func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
 // ToRawKubeConfigLoader creates a clientcmd.ClientConfig from the raw kubeconfig found in
 // the OutOfClusterConfig. It does not implement loading rules or overrides.
 func (conf *OutOfClusterConfig) ToRawKubeConfigLoader() clientcmd.ClientConfig {
-	cmdConf, _ := GetRestrictedClientConfigFromBytes(
-		conf.KubeConfig,
-		conf.Context,
-		conf.AllowedContexts,
+	cmdConf, _ := GetClientConfigFromServiceAccount(
+		conf.ServiceAccount,
+		conf.ClusterID,
 	)
 
 	return cmdConf

+ 101 - 0
internal/kubernetes/kubeconfig.go

@@ -222,6 +222,107 @@ func getConfigForContext(
 	return copyConf, nil
 }
 
+// GetClientConfigFromServiceAccount will construct new clientcmd.ClientConfig using
+// the configuration saved within a ServiceAccount model
+func GetClientConfigFromServiceAccount(
+	sa *models.ServiceAccount,
+	clusterID uint,
+) (clientcmd.ClientConfig, error) {
+	apiConfig, err := createRawConfigFromServiceAccount(sa, clusterID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	config := clientcmd.NewDefaultClientConfig(*apiConfig, &clientcmd.ConfigOverrides{})
+
+	return config, nil
+}
+
+func createRawConfigFromServiceAccount(
+	sa *models.ServiceAccount,
+	clusterID uint,
+) (*api.Config, error) {
+	apiConfig := &api.Config{}
+
+	var cluster *models.Cluster = nil
+
+	// find the cluster within the ServiceAccount configuration
+	for _, _cluster := range sa.Clusters {
+		if _cluster.ID == clusterID {
+			cluster = &_cluster
+		}
+	}
+
+	if cluster == nil {
+		return nil, errors.New("cluster not found")
+	}
+
+	clusterMap := make(map[string]*api.Cluster)
+
+	clusterMap[cluster.Name] = &api.Cluster{
+		LocationOfOrigin:         cluster.LocationOfOrigin,
+		Server:                   cluster.Server,
+		TLSServerName:            cluster.TLSServerName,
+		InsecureSkipTLSVerify:    cluster.InsecureSkipTLSVerify,
+		CertificateAuthorityData: cluster.CertificateAuthorityData,
+	}
+
+	// construct the auth infos
+	authInfoName := cluster.Name + "-" + sa.AuthMechanism
+
+	authInfoMap := make(map[string]*api.AuthInfo)
+
+	authInfoMap[authInfoName] = &api.AuthInfo{
+		LocationOfOrigin:  sa.LocationOfOrigin,
+		Impersonate:       sa.Impersonate,
+		ImpersonateGroups: sa.ImpersonateGroups,
+	}
+
+	switch sa.AuthMechanism {
+	case models.X509:
+		authInfoMap[authInfoName].ClientCertificateData = sa.ClientCertificateData
+		authInfoMap[authInfoName].ClientKeyData = sa.ClientKeyData
+	case models.Basic:
+		authInfoMap[authInfoName].Username = sa.Username
+		authInfoMap[authInfoName].Password = sa.Password
+	case models.Bearer:
+		authInfoMap[authInfoName].Token = sa.Token
+	case models.OIDC:
+		authInfoMap[authInfoName].AuthProvider = &api.AuthProviderConfig{
+			Name: "oidc",
+			Config: map[string]string{
+				"idp-issuer-url":                 sa.OIDCIssuerURL,
+				"client-id":                      sa.OIDCClientID,
+				"client-secret":                  sa.OIDCClientSecret,
+				"idp-certificate-authority-data": sa.OIDCCertificateAuthorityData,
+				"id-token":                       sa.OIDCIDToken,
+				"refresh-token":                  sa.OIDCRefreshToken,
+			},
+		}
+	case models.GCP:
+		return nil, errors.New("gcp unimplemented")
+	case models.AWS:
+		return nil, errors.New("gcp unimplemented")
+	}
+
+	// create a context of the cluster name
+	contextMap := make(map[string]*api.Context)
+
+	contextMap[cluster.Name] = &api.Context{
+		LocationOfOrigin: cluster.LocationOfOrigin,
+		Cluster:          cluster.Name,
+		AuthInfo:         authInfoName,
+	}
+
+	apiConfig.Clusters = clusterMap
+	apiConfig.AuthInfos = authInfoMap
+	apiConfig.Contexts = contextMap
+	apiConfig.CurrentContext = cluster.Name
+
+	return apiConfig, nil
+}
+
 // GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
 // a context name, and the set of allowed contexts.
 func GetRestrictedClientConfigFromBytes(

+ 9 - 7
internal/models/cluster.go

@@ -6,14 +6,16 @@ import "gorm.io/gorm"
 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"`
+	Name                  string `json:"name"`
+	ServiceAccountID      uint   `json:"service_account_id"`
+	LocationOfOrigin      string `json:"location_of_origin"`
+	Server                string `json:"server"`
+	TLSServerName         string `json:"tls-server-name,omitempty"`
+	InsecureSkipTLSVerify bool   `json:"insecure-skip-tls-verify,omitempty"`
+	ProxyURL              string `json:"proxy-url,omitempty"`
+
+	// CertificateAuthorityData is encrypted at rest
 	CertificateAuthorityData []byte `json:"certificate-authority-data,omitempty"`
-	ProxyURL                 string `json:"proxy-url,omitempty"`
 }
 
 // ClusterExternal is the external cluster type to be sent over REST

+ 1 - 1
internal/models/serviceaccount.go

@@ -108,7 +108,7 @@ type ServiceAccount struct {
 	OIDCIssuerURL                string `json:"idp-issuer-url"`
 	OIDCClientID                 string `json:"client-id"`
 	OIDCClientSecret             string `json:"client-secret"`
-	OIDCCertificateAuthorityData []byte `json:"idp-certificate-authority-data"`
+	OIDCCertificateAuthorityData string `json:"idp-certificate-authority-data"`
 	OIDCIDToken                  string `json:"id-token"`
 	OIDCRefreshToken             string `json:"refresh-token"`
 }

+ 6 - 25
internal/models/user.go

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

+ 2 - 12
internal/models/user_test.go

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

+ 19 - 1
internal/repository/test/serviceaccount.go

@@ -111,7 +111,8 @@ func (repo *ServiceAccountRepository) CreateServiceAccount(
 
 	for i, cluster := range sa.Clusters {
 		(&cluster).ServiceAccountID = sa.ID
-		sa.Clusters[i] = cluster
+		clusterP, _ := repo.createCluster(&cluster)
+		sa.Clusters[i] = *clusterP
 	}
 
 	return sa, nil
@@ -170,3 +171,20 @@ func (repo *ServiceAccountRepository) DeleteServiceAccount(
 
 	return sa, nil
 }
+
+func (repo *ServiceAccountRepository) createCluster(
+	cluster *models.Cluster,
+) (*models.Cluster, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if cluster == nil {
+		return nil, nil
+	}
+
+	repo.clusters = append(repo.clusters, cluster)
+	cluster.ID = uint(len(repo.clusters))
+
+	return cluster, nil
+}

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

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

+ 1 - 11
server/api/k8s_handler.go

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

+ 5 - 2
server/api/k8s_handler_test.go

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

+ 52 - 5
server/api/project_handler_test.go

@@ -1,7 +1,6 @@
 package api_test
 
 import (
-	"encoding/base64"
 	"encoding/json"
 	"net/http"
 	"reflect"
@@ -146,8 +145,6 @@ var createProjectSACandidatesTests = []*projTest{
 
 				sa := serviceAccounts[0]
 
-				decodedStr, _ := base64.StdEncoding.DecodeString("LS0tLS1CRUdJTiBDRVJ=")
-
 				if len(sa.Clusters) != 1 {
 					t.Fatalf("cluster not written\n")
 				}
@@ -160,9 +157,9 @@ var createProjectSACandidatesTests = []*projTest{
 					t.Errorf("service account auth mechanism is not %s\n", models.OIDC)
 				}
 
-				if string(sa.OIDCCertificateAuthorityData) != string(decodedStr) {
+				if string(sa.OIDCCertificateAuthorityData) != "LS0tLS1CRUdJTiBDRVJ=" {
 					t.Errorf("service account key data and input do not match: expected %s, got %s\n",
-						string(sa.OIDCCertificateAuthorityData), string(decodedStr))
+						string(sa.OIDCCertificateAuthorityData), "LS0tLS1CRUdJTiBDRVJ=")
 				}
 
 				if sa.OIDCClientID != "porter-api" {
@@ -279,6 +276,29 @@ func initProjectSACandidate(tester *tester) {
 	}
 }
 
+func initProjectSADefault(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	form := &forms.CreateServiceAccountCandidatesForm{
+		ProjectID:  uint(proj.ID),
+		Kubeconfig: OIDCAuthWithData,
+	}
+
+	// convert the form to a ServiceAccountCandidate
+	saCandidates, _ := form.ToServiceAccountCandidates()
+
+	for _, saCandidate := range saCandidates {
+		tester.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	saForm := forms.ServiceAccountActionResolver{
+		ServiceAccountCandidateID: 1,
+	}
+
+	saForm.PopulateServiceAccount(tester.repo.ServiceAccount)
+	tester.repo.ServiceAccount.CreateServiceAccount(saForm.SA)
+}
+
 func projectBasicBodyValidator(c *projTest, tester *tester, t *testing.T) {
 	if body := tester.rr.Body.String(); strings.TrimSpace(body) != strings.TrimSpace(c.expBody) {
 		t.Errorf("%s, handler returned wrong body: got %v want %v",
@@ -355,3 +375,30 @@ users:
         idp-certificate-authority: /fake/path/to/ca.pem
       name: oidc
 `
+
+const OIDCAuthWithData string = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    auth-provider:
+      config:
+        client-id: porter-api
+        id-token: token
+        idp-issuer-url: https://localhost
+        idp-certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+      name: oidc
+`

+ 32 - 15
server/api/release_handler.go

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

+ 68 - 51
server/api/release_handler_test.go

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

+ 0 - 48
server/api/user_handler.go

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

+ 6 - 265
server/api/user_handler_test.go

@@ -2,7 +2,6 @@ package api_test
 
 import (
 	"encoding/json"
-	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"reflect"
@@ -80,7 +79,7 @@ var authCheckTests = []*userTest{
 		endpoint:  "/api/auth/check",
 		expStatus: http.StatusOK,
 		body:      "",
-		expBody:   `{"id":1,"email":"belanger@getporter.dev","contexts":null,"rawKubeConfig":""}`,
+		expBody:   `{"id":1,"email":"belanger@getporter.dev"}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 			userBasicBodyValidator,
@@ -116,7 +115,7 @@ var createUserTests = []*userTest{
 			"password": "hello"
 		}`,
 		expStatus: http.StatusCreated,
-		expBody:   `{"id":1,"email":"belanger@getporter.dev","contexts":null,"rawKubeConfig":""}`,
+		expBody:   `{"id":1,"email":"belanger@getporter.dev"}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 			userModelBodyValidator,
 		},
@@ -219,7 +218,7 @@ var loginUserTests = []*userTest{
 			"password": "hello"
 		}`,
 		expStatus: http.StatusOK,
-		expBody:   `{"id":1,"email":"belanger@getporter.dev","contexts":null,"rawKubeConfig":""}`,
+		expBody:   `{"id":1,"email":"belanger@getporter.dev"}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 			userBasicBodyValidator,
 		},
@@ -236,7 +235,7 @@ var loginUserTests = []*userTest{
 			"password": "hello"
 		}`,
 		expStatus: http.StatusOK,
-		expBody:   `{"id":1,"email":"belanger@getporter.dev","contexts":null,"rawKubeConfig":""}`,
+		expBody:   `{"id":1,"email":"belanger@getporter.dev"}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 			userBasicBodyValidator,
@@ -328,14 +327,14 @@ func TestHandleLogoutUser(t *testing.T) {
 var readUserTests = []*userTest{
 	&userTest{
 		initializers: []func(tester *tester){
-			initUserWithContexts,
+			initUserDefault,
 		},
 		msg:       "Read user successful",
 		method:    "GET",
 		endpoint:  "/api/users/1",
 		body:      "",
 		expStatus: http.StatusOK,
-		expBody:   `{"id":1,"email":"belanger@getporter.dev","contexts":["context-test"],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`,
+		expBody:   `{"id":1,"email":"belanger@getporter.dev"}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
 			userModelBodyValidator,
@@ -361,253 +360,6 @@ func TestHandleReadUser(t *testing.T) {
 	testUserRequests(t, readUserTests, true)
 }
 
-var readUserContextsTests = []*userTest{
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserWithContexts,
-		},
-		msg:       "Read user context selected successful",
-		method:    "GET",
-		endpoint:  "/api/users/1/contexts",
-		body:      "",
-		expStatus: http.StatusOK,
-		useCookie: true,
-		expBody:   `[{"name":"context-test","server":"https://localhost","cluster":"cluster-test","user":"test-admin","selected":true}]`,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			userContextBodyValidator,
-		},
-	},
-	&userTest{
-		initializers: []func(tester *tester){
-			func(tester *tester) {
-				initUserDefault(tester)
-
-				user, _ := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
-				user.Contexts = ""
-				user.RawKubeConfig = []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin")
-
-				tester.repo.User.UpdateUser(user)
-			},
-		},
-		msg:       "Read user context not selected successful",
-		method:    "GET",
-		endpoint:  "/api/users/1/contexts",
-		body:      "",
-		expStatus: http.StatusOK,
-		useCookie: true,
-		expBody:   `[{"name":"context-test","server":"https://localhost","cluster":"cluster-test","user":"test-admin","selected":false}]`,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			userContextBodyValidator,
-		},
-	},
-}
-
-func TestHandleReadUserContexts(t *testing.T) {
-	testUserRequests(t, readUserContextsTests, true)
-}
-
-var updateUserTests = []*userTest{
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserDefault,
-		},
-		msg:       "Update user successful",
-		method:    "PUT",
-		endpoint:  "/api/users/1",
-		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedContexts":[]}`,
-		expStatus: http.StatusNoContent,
-		expBody:   "",
-		useCookie: true,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			func(c *userTest, tester *tester, t *testing.T) {
-				req, err := http.NewRequest(
-					"GET",
-					"/api/users/1",
-					strings.NewReader(""),
-				)
-
-				req.AddCookie(tester.cookie)
-
-				if err != nil {
-					t.Fatal(err)
-				}
-
-				rr2 := httptest.NewRecorder()
-				tester.router.ServeHTTP(rr2, req)
-
-				gotBody := &models.UserExternal{}
-				expBody := &models.UserExternal{}
-
-				json.Unmarshal(rr2.Body.Bytes(), gotBody)
-				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","contexts":[],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
-
-				fmt.Println(rr2.Body.String())
-
-				if !reflect.DeepEqual(gotBody, expBody) {
-					t.Errorf("%s, handler returned wrong body: got %v want %v",
-						"validator failed", gotBody, expBody)
-				}
-			},
-		},
-	},
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserDefault,
-		},
-		msg:       "Update user successful without allowedContexts parameter",
-		method:    "PUT",
-		endpoint:  "/api/users/1",
-		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`,
-		expStatus: http.StatusNoContent,
-		expBody:   "",
-		useCookie: true,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			func(c *userTest, tester *tester, t *testing.T) {
-				req, err := http.NewRequest(
-					"GET",
-					"/api/users/1",
-					strings.NewReader(""),
-				)
-
-				req.AddCookie(tester.cookie)
-
-				if err != nil {
-					t.Fatal(err)
-				}
-
-				rr2 := httptest.NewRecorder()
-				tester.router.ServeHTTP(rr2, req)
-
-				gotBody := &models.UserExternal{}
-				expBody := &models.UserExternal{}
-
-				json.Unmarshal(rr2.Body.Bytes(), gotBody)
-				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","contexts":[],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
-
-				if !reflect.DeepEqual(gotBody, expBody) {
-					t.Errorf("%s, handler returned wrong body: got %v want %v",
-						"validator failed", gotBody, expBody)
-				}
-			},
-		},
-	},
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserDefault,
-		},
-		msg:       "Update user successful with allowedContexts",
-		method:    "PUT",
-		endpoint:  "/api/users/1",
-		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedContexts":["context-test"]}`,
-		expStatus: http.StatusNoContent,
-		expBody:   "",
-		useCookie: true,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			func(c *userTest, tester *tester, t *testing.T) {
-				req, err := http.NewRequest(
-					"GET",
-					"/api/users/1",
-					strings.NewReader(""),
-				)
-
-				req.AddCookie(tester.cookie)
-
-				if err != nil {
-					t.Fatal(err)
-				}
-
-				rr2 := httptest.NewRecorder()
-				tester.router.ServeHTTP(rr2, req)
-
-				gotBody := &models.UserExternal{}
-				expBody := &models.UserExternal{}
-
-				json.Unmarshal(rr2.Body.Bytes(), gotBody)
-				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","contexts":["context-test"],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
-
-				if !reflect.DeepEqual(gotBody, expBody) {
-					t.Errorf("%s, handler returned wrong body: got %v want %v",
-						"validator failed", gotBody, expBody)
-				}
-			},
-		},
-	},
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserWithContexts,
-		},
-		msg:       "Update user successful without rawKubeConfig",
-		method:    "PUT",
-		endpoint:  "/api/users/1",
-		body:      `{"allowedContexts":[]}`,
-		expStatus: http.StatusNoContent,
-		expBody:   "",
-		useCookie: true,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			func(c *userTest, tester *tester, t *testing.T) {
-				req, err := http.NewRequest(
-					"GET",
-					"/api/users/1",
-					strings.NewReader(""),
-				)
-
-				req.AddCookie(tester.cookie)
-
-				if err != nil {
-					t.Fatal(err)
-				}
-
-				rr2 := httptest.NewRecorder()
-				tester.router.ServeHTTP(rr2, req)
-
-				gotBody := &models.UserExternal{}
-				expBody := &models.UserExternal{}
-
-				json.Unmarshal(rr2.Body.Bytes(), gotBody)
-				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","contexts":[],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
-
-				if !reflect.DeepEqual(gotBody, expBody) {
-					t.Errorf("%s, handler returned wrong body: got %v want %v",
-						"validator failed", gotBody, expBody)
-				}
-			},
-		},
-	},
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserDefault,
-		},
-		msg:       "Update user invalid id",
-		method:    "PUT",
-		endpoint:  "/api/users/alsdfjk",
-		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedContexts":[]}`,
-		expStatus: http.StatusForbidden,
-		expBody:   http.StatusText(http.StatusForbidden) + "\n",
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			userBasicBodyValidator,
-		},
-	},
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserDefault,
-		},
-		msg:       "Update user bad kubeconfig",
-		method:    "PUT",
-		endpoint:  "/api/users/1",
-		body:      `{"rawKubeConfig":"notvalidyaml", "allowedContexts":[]}`,
-		expStatus: http.StatusBadRequest,
-		expBody:   `{"code":600,"errors":["could not process request"]}`,
-		useCookie: true,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			userBasicBodyValidator,
-		},
-	},
-}
-
-func TestHandleUpdateUser(t *testing.T) {
-	testUserRequests(t, updateUserTests, true)
-}
-
 var deleteUserTests = []*userTest{
 	&userTest{
 		initializers: []func(tester *tester){
@@ -697,17 +449,6 @@ func initUserDefault(tester *tester) {
 	tester.createUserSession("belanger@getporter.dev", "hello")
 }
 
-func initUserWithContexts(tester *tester) {
-	initUserDefault(tester)
-
-	user, _ := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
-	user.Contexts = "context-test"
-
-	user.RawKubeConfig = []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin")
-
-	tester.repo.User.UpdateUser(user)
-}
-
 func userBasicBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	if body := tester.rr.Body.String(); strings.TrimSpace(body) != strings.TrimSpace(c.expBody) {
 		t.Errorf("%s, handler returned wrong body: got %v want %v",

+ 175 - 15
server/router/middleware/auth.go

@@ -3,9 +3,11 @@ package middleware
 import (
 	"bytes"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io/ioutil"
 	"net/http"
+	"net/url"
 	"strconv"
 
 	"github.com/go-chi/chi"
@@ -48,10 +50,12 @@ func (auth *Auth) BasicAuthenticate(next http.Handler) http.Handler {
 type IDLocation uint
 
 const (
-	// URLParam location looks for {id} in the URL
+	// URLParam location looks for a parameter in the URL endpoint
 	URLParam IDLocation = iota
-	// BodyParam location looks for user_id in the body
+	// BodyParam location looks for a parameter in the body
 	BodyParam
+	// QueryParam location looks for a parameter in the query string
+	QueryParam
 )
 
 type bodyUserID struct {
@@ -62,12 +66,16 @@ type bodyProjectID struct {
 	ProjectID uint64 `json:"project_id"`
 }
 
+type bodyServiceAccountID struct {
+	ServiceAccountID uint64 `json:"service_account_id"`
+}
+
 // DoesUserIDMatch checks the id URL parameter and verifies that it matches
 // the one stored in the session
 func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		var err error
-		id := findUserIDInRequest(r, loc)
+		id, err := findUserIDInRequest(r, loc)
 
 		if err == nil && auth.doesSessionMatchID(r, uint(id)) {
 			next.ServeHTTP(w, r)
@@ -98,7 +106,12 @@ func (auth *Auth) DoesUserHaveProjectAccess(
 ) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		var err error
-		projID := uint(findProjIDInRequest(r, projLoc))
+		projID, err := findProjIDInRequest(r, projLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
 
 		session, err := auth.store.Get(r, auth.cookieName)
 
@@ -115,7 +128,7 @@ func (auth *Auth) DoesUserHaveProjectAccess(
 		}
 
 		// get the project
-		proj, err := auth.repo.Project.ReadProject(projID)
+		proj, err := auth.repo.Project.ReadProject(uint(projID))
 
 		if err != nil {
 			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@@ -143,6 +156,56 @@ func (auth *Auth) DoesUserHaveProjectAccess(
 	})
 }
 
+// DoesUserHaveServiceAccountAccess looks for a project_id parameter and a
+// service_account_id parameter, and verifies that the service account belongs
+// to the project
+func (auth *Auth) DoesUserHaveServiceAccountAccess(
+	next http.Handler,
+	projLoc IDLocation,
+	saLoc IDLocation,
+) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		serviceAccountID, err := findServiceAccountIDInRequest(r, saLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		projID, err := findProjIDInRequest(r, projLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		// get the service accounts belonging to the project
+		serviceAccounts, err := auth.repo.ServiceAccount.ListServiceAccountsByProjectID(uint(projID))
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return
+		}
+
+		doesExist := false
+
+		for _, sa := range serviceAccounts {
+			if sa.ID == uint(serviceAccountID) {
+				doesExist = true
+				break
+			}
+		}
+
+		if doesExist {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	})
+}
+
 // Helpers
 func (auth *Auth) doesSessionMatchID(r *http.Request, id uint) bool {
 	session, _ := auth.store.Get(r, auth.cookieName)
@@ -170,40 +233,137 @@ func (auth *Auth) isLoggedIn(w http.ResponseWriter, r *http.Request) bool {
 	return true
 }
 
-func findUserIDInRequest(r *http.Request, userLoc IDLocation) uint64 {
+func findUserIDInRequest(r *http.Request, userLoc IDLocation) (uint64, error) {
 	var userID uint64
+	var err error
 
 	if userLoc == URLParam {
-		userID, _ = strconv.ParseUint(chi.URLParam(r, "user_id"), 0, 64)
+		userID, err = strconv.ParseUint(chi.URLParam(r, "user_id"), 0, 64)
+
+		if err != nil {
+			return 0, err
+		}
 	} else if userLoc == BodyParam {
 		form := &bodyUserID{}
-		body, _ := ioutil.ReadAll(r.Body)
-		_ = json.Unmarshal(body, form)
+		body, err := ioutil.ReadAll(r.Body)
+
+		if err != nil {
+			return 0, err
+		}
+
+		err = json.Unmarshal(body, form)
+
+		if err != nil {
+			return 0, err
+		}
 
 		userID = form.UserID
 
 		// need to create a new stream for the body
 		r.Body = ioutil.NopCloser(bytes.NewReader(body))
+	} else {
+		vals, err := url.ParseQuery(r.URL.RawQuery)
+
+		if err != nil {
+			return 0, err
+		}
+
+		if userStrArr, ok := vals["user_id"]; ok && len(userStrArr) == 1 {
+			userID, err = strconv.ParseUint(userStrArr[0], 10, 64)
+		} else {
+			return 0, errors.New("user id not found")
+		}
 	}
 
-	return userID
+	return userID, nil
 }
 
-func findProjIDInRequest(r *http.Request, projLoc IDLocation) uint64 {
+func findProjIDInRequest(r *http.Request, projLoc IDLocation) (uint64, error) {
 	var projID uint64
+	var err error
 
 	if projLoc == URLParam {
-		projID, _ = strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+		projID, err = strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+		if err != nil {
+			return 0, err
+		}
 	} else if projLoc == BodyParam {
 		form := &bodyProjectID{}
-		body, _ := ioutil.ReadAll(r.Body)
-		_ = json.Unmarshal(body, form)
+		body, err := ioutil.ReadAll(r.Body)
+
+		if err != nil {
+			return 0, err
+		}
+
+		err = json.Unmarshal(body, form)
+
+		if err != nil {
+			return 0, err
+		}
 
 		projID = form.ProjectID
 
 		// need to create a new stream for the body
 		r.Body = ioutil.NopCloser(bytes.NewReader(body))
+	} else {
+		vals, err := url.ParseQuery(r.URL.RawQuery)
+
+		if err != nil {
+			return 0, err
+		}
+
+		if projStrArr, ok := vals["project_id"]; ok && len(projStrArr) == 1 {
+			projID, err = strconv.ParseUint(projStrArr[0], 10, 64)
+		} else {
+			return 0, errors.New("project id not found")
+		}
+	}
+
+	return projID, nil
+}
+
+func findServiceAccountIDInRequest(r *http.Request, saLoc IDLocation) (uint64, error) {
+	var saID uint64
+	var err error
+
+	if saLoc == URLParam {
+		saID, err = strconv.ParseUint(chi.URLParam(r, "service_account_id"), 0, 64)
+
+		if err != nil {
+			return 0, err
+		}
+	} else if saLoc == BodyParam {
+		form := &bodyServiceAccountID{}
+		body, err := ioutil.ReadAll(r.Body)
+
+		if err != nil {
+			return 0, err
+		}
+
+		err = json.Unmarshal(body, form)
+
+		if err != nil {
+			return 0, err
+		}
+
+		saID = form.ServiceAccountID
+
+		// need to create a new stream for the body
+		r.Body = ioutil.NopCloser(bytes.NewReader(body))
+	} else {
+		vals, err := url.ParseQuery(r.URL.RawQuery)
+
+		if err != nil {
+			return 0, err
+		}
+
+		if saStrArr, ok := vals["service_account_id"]; ok && len(saStrArr) == 1 {
+			saID, err = strconv.ParseUint(saStrArr[0], 10, 64)
+		} else {
+			return 0, errors.New("service account id not found")
+		}
 	}
 
-	return projID
+	return saID, nil
 }

+ 98 - 11
server/router/router.go

@@ -29,9 +29,7 @@ func New(
 
 		// /api/users routes
 		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/{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)))
@@ -80,16 +78,105 @@ func New(
 			),
 		)
 
-		// /api/releases routes
-		r.Method("GET", "/releases", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListReleases, l)))
-		r.Method("GET", "/releases/{name}/{revision}/components", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetReleaseComponents, l)))
-		r.Method("GET", "/releases/{name}/history", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListReleaseHistory, l)))
-		r.Method("POST", "/releases/{name}/upgrade", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleUpgradeRelease, l)))
-		r.Method("GET", "/releases/{name}/{revision}", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetRelease, l)))
-		r.Method("POST", "/releases/{name}/rollback", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleRollbackRelease, l)))
+		// /api/projects/{project_id}/releases routes
+		r.Method(
+			"GET",
+			"/projects/{project_id}/releases",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleListReleases, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/releases/{name}/{revision}/components",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleGetReleaseComponents, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/releases/{name}/history",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleListReleaseHistory, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/releases/{name}/upgrade",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleUpgradeRelease, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/releases/{name}/{revision}",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleGetRelease, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
 
-		// /api/k8s routes
-		r.Method("GET", "/k8s/namespaces", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListNamespaces, l)))
+		r.Method(
+			"POST",
+			"/projects/{project_id}/releases/{name}/rollback",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleRollbackRelease, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		// /api/projects/{project_id}/k8s routes
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/namespaces",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleListNamespaces, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
 	})
 
 	fs := http.FileServer(http.Dir(staticFilePath))