From 280a2fe0935c06487b9d39ed3aac87ee15e36b2a Mon Sep 17 00:00:00 2001 From: Prabhat Khera <91852476+prabhat-org@users.noreply.github.com> Date: Fri, 10 Nov 2023 10:06:50 +1300 Subject: [PATCH] fix(kubernetes): clear user token from kube token cache on logout + update cluster rolebindings for user on change of team/user authorization [EE-6298] (#10603) --- api/dataservices/endpoint/endpoint.go | 18 ++++++ api/dataservices/endpoint/tx.go | 17 +++++ api/dataservices/interface.go | 1 + api/http/handler/auth/handler.go | 3 +- api/http/handler/auth/logout.go | 7 +-- api/http/handler/teammemberships/handler.go | 31 +++++++++- .../teammemberships/teammembership_create.go | 2 + .../teammemberships/teammembership_delete.go | 2 + .../teammemberships/teammembership_update.go | 2 + api/http/proxy/factory/kubernetes/token.go | 62 +++++++++++++++---- .../proxy/factory/kubernetes/transport.go | 10 +++ api/http/server.go | 1 + api/internal/testhelpers/datastore.go | 13 ++++ 13 files changed, 148 insertions(+), 21 deletions(-) diff --git a/api/dataservices/endpoint/endpoint.go b/api/dataservices/endpoint/endpoint.go index 51fa57f6c..c3ae36ba0 100644 --- a/api/dataservices/endpoint/endpoint.go +++ b/api/dataservices/endpoint/endpoint.go @@ -5,6 +5,7 @@ import ( "time" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" ) // BucketName represents the name of the bucket where this service stores data. @@ -144,6 +145,23 @@ func (service *Service) Create(endpoint *portainer.Endpoint) error { }) } +func (service *Service) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) { + var endpoints = make([]portainer.Endpoint, 0) + + return endpoints, service.connection.GetAll( + BucketName, + &portainer.Endpoint{}, + dataservices.FilterFn(&endpoints, func(e portainer.Endpoint) bool { + for t := range e.TeamAccessPolicies { + if t == teamID { + return true + } + } + return false + }), + ) +} + // GetNextIdentifier returns the next identifier for an environment(endpoint). func (service *Service) GetNextIdentifier() int { var identifier int diff --git a/api/dataservices/endpoint/tx.go b/api/dataservices/endpoint/tx.go index 9db2d291b..8c8e3c98b 100644 --- a/api/dataservices/endpoint/tx.go +++ b/api/dataservices/endpoint/tx.go @@ -122,6 +122,23 @@ func (service ServiceTx) Create(endpoint *portainer.Endpoint) error { return nil } +func (service ServiceTx) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) { + var endpoints = make([]portainer.Endpoint, 0) + + return endpoints, service.tx.GetAll( + BucketName, + &portainer.Endpoint{}, + dataservices.FilterFn(&endpoints, func(e portainer.Endpoint) bool { + for t := range e.TeamAccessPolicies { + if t == teamID { + return true + } + } + return false + }), + ) +} + // GetNextIdentifier returns the next identifier for an environment(endpoint). func (service ServiceTx) GetNextIdentifier() int { return service.tx.GetNextIdentifier(BucketName) diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go index ecb84e15a..291af0855 100644 --- a/api/dataservices/interface.go +++ b/api/dataservices/interface.go @@ -89,6 +89,7 @@ type ( EndpointService interface { Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) + EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) Heartbeat(endpointID portainer.EndpointID) (int64, bool) UpdateHeartbeat(endpointID portainer.EndpointID) Endpoints() ([]portainer.Endpoint, error) diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index bd1e6d90b..f283bbec4 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -24,6 +24,7 @@ type Handler struct { ProxyManager *proxy.Manager KubernetesTokenCacheManager *kubernetes.TokenCacheManager passwordStrengthChecker security.PasswordStrengthChecker + bouncer security.BouncerService } // NewHandler creates a handler to manage authentication operations. @@ -31,6 +32,7 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit h := &Handler{ Router: mux.NewRouter(), passwordStrengthChecker: passwordStrengthChecker, + bouncer: bouncer, } h.Handle("/auth/oauth/validate", @@ -39,6 +41,5 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost) h.Handle("/auth/logout", bouncer.PublicAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost) - return h } diff --git a/api/http/handler/auth/logout.go b/api/http/handler/auth/logout.go index e6b6ac88e..21d8d340f 100644 --- a/api/http/handler/auth/logout.go +++ b/api/http/handler/auth/logout.go @@ -5,9 +5,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/logoutcontext" - "github.com/rs/zerolog/log" ) // @id Logout @@ -21,10 +19,7 @@ import ( // @router /auth/logout [post] func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - log.Warn().Err(err).Msg("unable to retrieve user details from authentication token") - } + tokenData := handler.bouncer.JWTAuthLookup(r) if tokenData != nil { handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID) diff --git a/api/http/handler/teammemberships/handler.go b/api/http/handler/teammemberships/handler.go index 38d5cbf61..ec7f9ab14 100644 --- a/api/http/handler/teammemberships/handler.go +++ b/api/http/handler/teammemberships/handler.go @@ -4,8 +4,12 @@ import ( "net/http" httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/rs/zerolog/log" "github.com/gorilla/mux" ) @@ -13,7 +17,8 @@ import ( // Handler is the HTTP handler used to handle team membership operations. type Handler struct { *mux.Router - DataStore dataservices.DataStore + DataStore dataservices.DataStore + K8sClientFactory *cli.ClientFactory } // NewHandler creates a handler to manage team membership operations. @@ -31,3 +36,27 @@ func NewHandler(bouncer security.BouncerService) *Handler { return h } + +func (handler *Handler) updateUserServiceAccounts(membership *portainer.TeamMembership) { + endpoints, err := handler.DataStore.Endpoint().EndpointsByTeamID(membership.TeamID) + if err != nil { + log.Error().Err(err).Msgf("failed fetching environments for team %d", membership.TeamID) + return + } + for _, endpoint := range endpoints { + restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace + // update kubernenets service accounts if the team is associated with a kubernetes environment + if endpointutils.IsKubernetesEndpoint(&endpoint) { + kubecli, err := handler.K8sClientFactory.GetKubeClient(&endpoint) + if err != nil { + log.Error().Err(err).Msgf("failed getting kube client for environment %d", endpoint.ID) + continue + } + teamIDs := []int{int(membership.TeamID)} + err = kubecli.SetupUserServiceAccount(int(membership.UserID), teamIDs, restrictDefaultNamespace) + if err != nil { + log.Error().Err(err).Msgf("failed setting-up service account for user %d", membership.UserID) + } + } + } +} diff --git a/api/http/handler/teammemberships/teammembership_create.go b/api/http/handler/teammemberships/teammembership_create.go index 099ae92a3..916f9ee14 100644 --- a/api/http/handler/teammemberships/teammembership_create.go +++ b/api/http/handler/teammemberships/teammembership_create.go @@ -91,5 +91,7 @@ func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Requ return httperror.InternalServerError("Unable to persist team memberships inside the database", err) } + defer handler.updateUserServiceAccounts(membership) + return response.JSON(w, membership) } diff --git a/api/http/handler/teammemberships/teammembership_delete.go b/api/http/handler/teammemberships/teammembership_delete.go index e945aec56..1fddcabd2 100644 --- a/api/http/handler/teammemberships/teammembership_delete.go +++ b/api/http/handler/teammemberships/teammembership_delete.go @@ -52,5 +52,7 @@ func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Requ return httperror.InternalServerError("Unable to remove the team membership from the database", err) } + defer handler.updateUserServiceAccounts(membership) + return response.Empty(w) } diff --git a/api/http/handler/teammemberships/teammembership_update.go b/api/http/handler/teammemberships/teammembership_update.go index b5da6f2cc..1074e619b 100644 --- a/api/http/handler/teammemberships/teammembership_update.go +++ b/api/http/handler/teammemberships/teammembership_update.go @@ -90,5 +90,7 @@ func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Requ return httperror.InternalServerError("Unable to persist membership changes inside the database", err) } + defer handler.updateUserServiceAccounts(membership) + return response.JSON(w, membership) } diff --git a/api/http/proxy/factory/kubernetes/token.go b/api/http/proxy/factory/kubernetes/token.go index e794f4189..24bf09885 100644 --- a/api/http/proxy/factory/kubernetes/token.go +++ b/api/http/proxy/factory/kubernetes/token.go @@ -1,10 +1,12 @@ package kubernetes import ( + "fmt" "os" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/rs/zerolog/log" ) const defaultServiceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" @@ -43,28 +45,62 @@ func (manager *tokenManager) GetAdminServiceAccountToken() string { return manager.adminToken } -// GetUserServiceAccountToken setup a user's service account if it does not exist, then retrieve its token -func (manager *tokenManager) GetUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) { - tokenFunc := func() (string, error) { - memberships, err := manager.dataStore.TeamMembership().TeamMembershipsByUserID(portainer.UserID(userID)) - if err != nil { - return "", err - } +func (manager *tokenManager) setupUserServiceAccounts(userID portainer.UserID, endpoint *portainer.Endpoint) error { + memberships, err := manager.dataStore.TeamMembership().TeamMembershipsByUserID(userID) + if err != nil { + return err + } + + teamIds := make([]int, 0, len(memberships)) + for _, membership := range memberships { + teamIds = append(teamIds, int(membership.TeamID)) + } + + restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace + err = manager.kubecli.SetupUserServiceAccount(int(userID), teamIds, restrictDefaultNamespace) + if err != nil { + return err + } + + return nil +} - teamIds := make([]int, 0, len(memberships)) +func (manager *tokenManager) UpdateUserServiceAccountsForEndpoint(endpointID portainer.EndpointID) { + endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID) + if err != nil { + log.Error().Err(err).Msgf("failed fetching environments %d", endpointID) + return + } + + userIDs := make([]portainer.UserID, 0) + for u := range endpoint.UserAccessPolicies { + userIDs = append(userIDs, u) + } + for t := range endpoint.TeamAccessPolicies { + memberships, _ := manager.dataStore.TeamMembership().TeamMembershipsByTeamID(portainer.TeamID(t)) for _, membership := range memberships { - teamIds = append(teamIds, int(membership.TeamID)) + userIDs = append(userIDs, membership.UserID) } + } + for _, userID := range userIDs { + if err := manager.setupUserServiceAccounts(userID, endpoint); err != nil { + log.Error().Err(err).Msgf("failed setting-up service account for user %d", userID) + } + } +} + +// GetUserServiceAccountToken setup a user's service account if it does not exist, then retrieve its token +func (manager *tokenManager) GetUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) { + tokenFunc := func() (string, error) { endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID) if err != nil { + log.Error().Err(err).Msgf("failed fetching environment %d", endpointID) return "", err } - restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace - err = manager.kubecli.SetupUserServiceAccount(userID, teamIds, restrictDefaultNamespace) - if err != nil { - return "", err + if err := manager.setupUserServiceAccounts(portainer.UserID(userID), endpoint); err != nil { + return "", fmt.Errorf("failed setting-up service account for user %d: %w", userID, err) } return manager.kubecli.GetServiceAccountBearerToken(userID) diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index 2de13c695..bcf065e87 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -49,7 +49,17 @@ func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (* apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/(api|apis/apps)/v[0-9](\.[0-9])?`) requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "") + endpointRe := regexp.MustCompile(`([0-9]+)`) + endpointIDMatch := endpointRe.FindAllString(request.RequestURI, 1) + endpointID := 0 + if len(endpointIDMatch) > 0 { + endpointID, _ = strconv.Atoi(endpointIDMatch[0]) + } + switch { + case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"): + defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID)) + return transport.executeKubernetesRequest(request) case strings.EqualFold(requestPath, "/namespaces"): return transport.executeKubernetesRequest(request) case strings.HasPrefix(requestPath, "/namespaces"): diff --git a/api/http/server.go b/api/http/server.go index e69dfef39..2ae37de7c 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -259,6 +259,7 @@ func (server *Server) Start() error { var teamMembershipHandler = teammemberships.NewHandler(requestBouncer) teamMembershipHandler.DataStore = server.DataStore + teamMembershipHandler.K8sClientFactory = server.KubernetesClientFactory var systemHandler = system.NewHandler(requestBouncer, server.Status, diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 617499a00..133a84bdd 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -301,6 +301,19 @@ func (s *stubEndpointService) GetNextIdentifier() int { return len(s.endpoints) } +func (s *stubEndpointService) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) { + var endpoints = make([]portainer.Endpoint, 0) + + for _, e := range s.endpoints { + for t := range e.TeamAccessPolicies { + if t == teamID { + endpoints = append(endpoints, e) + } + } + } + return endpoints, nil +} + // WithEndpoints option will instruct testDatastore to return provided environments(endpoints) func WithEndpoints(endpoints []portainer.Endpoint) datastoreOption { return func(d *testDatastore) {