diff --git a/api/http/handler/kubernetes/cluster_role_bindings.go b/api/http/handler/kubernetes/cluster_role_bindings.go index a5ad040cf..8e681e5cf 100644 --- a/api/http/handler/kubernetes/cluster_role_bindings.go +++ b/api/http/handler/kubernetes/cluster_role_bindings.go @@ -18,7 +18,7 @@ import ( // @security ApiKeyAuth || jwt // @produce json // @param id path int true "Environment identifier" -// @success 200 {array} models.K8sClusterRoleBinding "Success" +// @success 200 {array} kubernetes.K8sClusterRoleBinding "Success" // @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." // @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." // @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." @@ -55,7 +55,7 @@ func (handler *Handler) getAllKubernetesClusterRoleBindings(w http.ResponseWrite // @security jwt // @produce text/plain // @param id path int true "Environment(Endpoint) identifier" -// @param payload body models.K8sClusterRoleBindingDeleteRequests true "Cluster role bindings to delete" +// @param payload body kubernetes.K8sClusterRoleBindingDeleteRequests true "Cluster role bindings to delete" // @success 200 "Success" // @failure 500 "Server error" // @router /kubernetes/{id}/cluster_role_bindings/delete [POST] diff --git a/api/http/handler/kubernetes/cluster_roles.go b/api/http/handler/kubernetes/cluster_roles.go index 35541e2be..4ea5b09da 100644 --- a/api/http/handler/kubernetes/cluster_roles.go +++ b/api/http/handler/kubernetes/cluster_roles.go @@ -55,7 +55,7 @@ func (handler *Handler) getAllKubernetesClusterRoles(w http.ResponseWriter, r *h // @security jwt // @produce text/plain // @param id path int true "Environment(Endpoint) identifier" -// @param payload body models.K8sClusterRoleDeleteRequests true "Cluster roles to delete" +// @param payload body kubernetes.K8sClusterRoleDeleteRequests true "Cluster roles to delete" // @success 200 "Success" // @failure 500 "Server error" // @router /kubernetes/{id}/cluster_roles/delete [POST] diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index 4faca2b69..d3ae22f73 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -74,16 +74,12 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza endpointRouter.Handle("/ingresses/delete", httperror.LoggerHandler(h.deleteKubernetesIngresses)).Methods(http.MethodPost) endpointRouter.Handle("/ingresses", httperror.LoggerHandler(h.GetAllKubernetesClusterIngresses)).Methods(http.MethodGet) endpointRouter.Handle("/ingresses/count", httperror.LoggerHandler(h.getAllKubernetesClusterIngressesCount)).Methods(http.MethodGet) - endpointRouter.Handle("/service_accounts", httperror.LoggerHandler(h.getAllKubernetesServiceAccounts)).Methods(http.MethodGet) endpointRouter.Handle("/services", httperror.LoggerHandler(h.GetAllKubernetesServices)).Methods(http.MethodGet) endpointRouter.Handle("/services/count", httperror.LoggerHandler(h.getAllKubernetesServicesCount)).Methods(http.MethodGet) endpointRouter.Handle("/secrets", httperror.LoggerHandler(h.GetAllKubernetesSecrets)).Methods(http.MethodGet) endpointRouter.Handle("/secrets/count", httperror.LoggerHandler(h.getAllKubernetesSecretsCount)).Methods(http.MethodGet) endpointRouter.Handle("/services/delete", httperror.LoggerHandler(h.deleteKubernetesServices)).Methods(http.MethodPost) - endpointRouter.Handle("/service_accounts/delete", httperror.LoggerHandler(h.deleteKubernetesServiceAccounts)).Methods(http.MethodPost) endpointRouter.Handle("/rbac_enabled", httperror.LoggerHandler(h.getKubernetesRBACStatus)).Methods(http.MethodGet) - endpointRouter.Handle("/roles", httperror.LoggerHandler(h.getAllKubernetesRoles)).Methods(http.MethodGet) - endpointRouter.Handle("/role_bindings", httperror.LoggerHandler(h.getAllKubernetesRoleBindings)).Methods(http.MethodGet) endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.createKubernetesNamespace)).Methods(http.MethodPost) endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut) endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.deleteKubernetesNamespace)).Methods(http.MethodDelete) @@ -92,6 +88,16 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.getKubernetesNamespace)).Methods(http.MethodGet) endpointRouter.Handle("/volumes", httperror.LoggerHandler(h.GetAllKubernetesVolumes)).Methods(http.MethodGet) endpointRouter.Handle("/volumes/count", httperror.LoggerHandler(h.getAllKubernetesVolumesCount)).Methods(http.MethodGet) + endpointRouter.Handle("/service_accounts", httperror.LoggerHandler(h.getAllKubernetesServiceAccounts)).Methods(http.MethodGet) + endpointRouter.Handle("/service_accounts/delete", httperror.LoggerHandler(h.deleteKubernetesServiceAccounts)).Methods(http.MethodPost) + endpointRouter.Handle("/roles", httperror.LoggerHandler(h.getAllKubernetesRoles)).Methods(http.MethodGet) + endpointRouter.Handle("/roles/delete", httperror.LoggerHandler(h.deleteRoles)).Methods(http.MethodPost) + endpointRouter.Handle("/role_bindings", httperror.LoggerHandler(h.getAllKubernetesRoleBindings)).Methods(http.MethodGet) + endpointRouter.Handle("/role_bindings/delete", httperror.LoggerHandler(h.deleteRoleBindings)).Methods(http.MethodPost) + endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet) + endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost) + endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet) + endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost) // namespaces // in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?) diff --git a/api/http/handler/kubernetes/role_bindings.go b/api/http/handler/kubernetes/role_bindings.go index 6c58f4b50..ae7c163fa 100644 --- a/api/http/handler/kubernetes/role_bindings.go +++ b/api/http/handler/kubernetes/role_bindings.go @@ -3,7 +3,9 @@ package kubernetes import ( "net/http" + models "github.com/portainer/portainer/api/http/models/kubernetes" httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" "github.com/rs/zerolog/log" ) @@ -38,3 +40,35 @@ func (handler *Handler) getAllKubernetesRoleBindings(w http.ResponseWriter, r *h return response.JSON(w, rolebindings) } + +// @id DeleteRoleBindings +// @summary Delete the provided role bindings +// @description Delete the provided role bindings for the given Kubernetes environment +// @description **Access policy**: administrator +// @tags rbac_enabled +// @security ApiKeyAuth +// @security jwt +// @produce text/plain +// @param id path int true "Environment(Endpoint) identifier" +// @param payload body models.K8sRoleDeleteRequests true "Role bindings to delete" +// @success 200 "Success" +// @failure 500 "Server error" +// @router /kubernetes/{id}/role_bindings/delete [POST] +func (h *Handler) deleteRoleBindings(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sRoleBindingDeleteRequests + + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + cli, handlerErr := h.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + if err := cli.DeleteRoleBindings(payload); err != nil { + return httperror.InternalServerError("Failed to delete role bindings", err) + } + + return nil +} diff --git a/api/http/handler/kubernetes/roles.go b/api/http/handler/kubernetes/roles.go index f44309ff2..ee8fcdf59 100644 --- a/api/http/handler/kubernetes/roles.go +++ b/api/http/handler/kubernetes/roles.go @@ -3,7 +3,9 @@ package kubernetes import ( "net/http" + models "github.com/portainer/portainer/api/http/models/kubernetes" httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" "github.com/rs/zerolog/log" ) @@ -38,3 +40,36 @@ func (handler *Handler) getAllKubernetesRoles(w http.ResponseWriter, r *http.Req return response.JSON(w, roles) } + +// @id DeleteRoles +// @summary Delete the provided roles +// @description Delete the provided roles for the given Kubernetes environment +// @description **Access policy**: administrator +// @tags rbac_enabled +// @security ApiKeyAuth +// @security jwt +// @produce text/plain +// @param id path int true "Environment(Endpoint) identifier" +// @param payload body kubernetes.K8sRoleDeleteRequests true "Roles to delete " +// @success 200 "Success" +// @failure 500 "Server error" +// @router /kubernetes/{id}/roles/delete [POST] +func (h *Handler) deleteRoles(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sRoleDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + cli, handlerErr := h.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + err = cli.DeleteRoles(payload) + if err != nil { + return httperror.InternalServerError("Failed to delete roles", err) + } + + return nil +} diff --git a/api/http/handler/kubernetes/service_accounts.go b/api/http/handler/kubernetes/service_accounts.go index 3991ac7c6..c5ade6bd8 100644 --- a/api/http/handler/kubernetes/service_accounts.go +++ b/api/http/handler/kubernetes/service_accounts.go @@ -50,7 +50,7 @@ func (handler *Handler) getAllKubernetesServiceAccounts(w http.ResponseWriter, r // @security jwt // @produce text/plain // @param id path int true "Environment(Endpoint) identifier" -// @param payload body models.K8sServiceAccountDeleteRequests true "Service accounts to delete " +// @param payload body kubernetes.K8sServiceAccountDeleteRequests true "Service accounts to delete " // @success 200 "Success" // @failure 500 "Server error" // @router /kubernetes/{id}/service_accounts/delete [POST] diff --git a/api/http/models/kubernetes/cluster_role_bindings.go b/api/http/models/kubernetes/cluster_role_bindings.go index 549c1e1b6..546973937 100644 --- a/api/http/models/kubernetes/cluster_role_bindings.go +++ b/api/http/models/kubernetes/cluster_role_bindings.go @@ -13,6 +13,7 @@ type ( K8sClusterRoleBinding struct { Name string `json:"name"` UID types.UID `json:"uid"` + Namespace string `json:"namespace"` RoleRef rbacv1.RoleRef `json:"roleRef"` Subjects []rbacv1.Subject `json:"subjects"` CreationDate time.Time `json:"creationDate"` diff --git a/api/http/models/kubernetes/role_bindings.go b/api/http/models/kubernetes/role_bindings.go index 7f75dd191..2078244eb 100644 --- a/api/http/models/kubernetes/role_bindings.go +++ b/api/http/models/kubernetes/role_bindings.go @@ -1,17 +1,38 @@ package kubernetes import ( + "errors" + "net/http" "time" rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/types" ) type ( K8sRoleBinding struct { Name string `json:"name"` + UID types.UID `json:"uid"` Namespace string `json:"namespace"` RoleRef rbacv1.RoleRef `json:"roleRef"` Subjects []rbacv1.Subject `json:"subjects"` CreationDate time.Time `json:"creationDate"` + IsSystem bool `json:"isSystem"` } + + // K8sRoleBindingDeleteRequests is a mapping of namespace names to a slice of role bindings. + K8sRoleBindingDeleteRequests map[string][]string ) + +func (r K8sRoleBindingDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + + for ns := range r { + if len(ns) == 0 { + return errors.New("deletion given with empty namespace") + } + } + return nil +} diff --git a/api/http/models/kubernetes/roles.go b/api/http/models/kubernetes/roles.go index 65757def3..aba205b21 100644 --- a/api/http/models/kubernetes/roles.go +++ b/api/http/models/kubernetes/roles.go @@ -1,9 +1,36 @@ package kubernetes -import "time" +import ( + "errors" + "net/http" + "time" -type K8sRole struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - CreationDate time.Time `json:"creationDate"` + "k8s.io/apimachinery/pkg/types" +) + +type ( + K8sRole struct { + Name string `json:"name"` + UID types.UID `json:"uid"` + Namespace string `json:"namespace"` + CreationDate time.Time `json:"creationDate"` + // isSystem is true if prefixed with "system:" or exists in the kube-system namespace + // or is one of the portainer roles + IsSystem bool `json:"isSystem"` + } + + // K8sRoleDeleteRequests is a mapping of namespace names to a slice of roles. + K8sRoleDeleteRequests map[string][]string +) + +func (r K8sRoleDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + for ns := range r { + if len(ns) == 0 { + return errors.New("deletion given with empty namespace") + } + } + return nil } diff --git a/api/kubernetes/cli/cluster_role_binding.go b/api/kubernetes/cli/cluster_role_binding.go index 70dcc7cc5..41eb6de05 100644 --- a/api/kubernetes/cli/cluster_role_binding.go +++ b/api/kubernetes/cli/cluster_role_binding.go @@ -43,6 +43,7 @@ func parseClusterRoleBinding(clusterRoleBinding rbacv1.ClusterRoleBinding) model return models.K8sClusterRoleBinding{ Name: clusterRoleBinding.Name, UID: clusterRoleBinding.UID, + Namespace: clusterRoleBinding.Namespace, RoleRef: clusterRoleBinding.RoleRef, Subjects: clusterRoleBinding.Subjects, CreationDate: clusterRoleBinding.CreationTimestamp.Time, diff --git a/api/kubernetes/cli/role.go b/api/kubernetes/cli/role.go index 53ed19b15..9a3c6635f 100644 --- a/api/kubernetes/cli/role.go +++ b/api/kubernetes/cli/role.go @@ -2,11 +2,15 @@ package cli import ( "context" + "strings" models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/portainer/portainer/api/internal/errorlist" + "github.com/rs/zerolog/log" rbacv1 "k8s.io/api/rbac/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // GetRoles gets all the roles for either at the cluster level or a given namespace in a k8s endpoint. @@ -48,18 +52,20 @@ func (kcl *KubeClient) fetchRoles(namespace string) ([]models.K8sRole, error) { results := make([]models.K8sRole, 0) for _, role := range roles.Items { - results = append(results, parseRole(role)) + results = append(results, kcl.parseRole(role)) } return results, nil } // parseRole converts a rbacv1.Role object to a models.K8sRole object. -func parseRole(role rbacv1.Role) models.K8sRole { +func (kcl *KubeClient) parseRole(role rbacv1.Role) models.K8sRole { return models.K8sRole{ Name: role.Name, + UID: role.UID, Namespace: role.Namespace, CreationDate: role.CreationTimestamp.Time, + IsSystem: kcl.isSystemRole(&role), } } @@ -114,3 +120,42 @@ func getPortainerDefaultK8sRoleNames() []string { string(portainerUserCRName), } } + +func (kcl *KubeClient) isSystemRole(role *rbacv1.Role) bool { + if strings.HasPrefix(role.Name, "system:") { + return true + } + + return kcl.isSystemNamespace(role.Namespace) +} + +// DeleteRoles processes a K8sServiceDeleteRequest by deleting each role +// in its given namespace. +func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error { + var errors []error + for namespace := range reqs { + for _, name := range reqs[namespace] { + client := kcl.cli.RbacV1().Roles(namespace) + + role, err := client.Get(context.Background(), name, v1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + continue + } + + // This is a more serious error to do with the client so we return right away + return err + } + + if kcl.isSystemRole(role) { + log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role, not allowed") + } + + if err := client.Delete(context.TODO(), name, metav1.DeleteOptions{}); err != nil { + errors = append(errors, err) + } + } + } + + return errorlist.Combine(errors) +} diff --git a/api/kubernetes/cli/role_binding.go b/api/kubernetes/cli/role_binding.go index 09d4f813b..e8e90cbb0 100644 --- a/api/kubernetes/cli/role_binding.go +++ b/api/kubernetes/cli/role_binding.go @@ -2,10 +2,16 @@ package cli import ( "context" + "strings" models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/portainer/portainer/api/internal/errorlist" + "github.com/rs/zerolog/log" + corev1 "k8s.io/api/rbac/v1" rbacv1 "k8s.io/api/rbac/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // GetRoleBindings gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint. @@ -47,19 +53,82 @@ func (kcl *KubeClient) fetchRoleBindings(namespace string) ([]models.K8sRoleBind results := make([]models.K8sRoleBinding, 0) for _, roleBinding := range roleBindings.Items { - results = append(results, parseRoleBinding(roleBinding)) + results = append(results, kcl.parseRoleBinding(roleBinding)) } return results, nil } // parseRoleBinding converts a rbacv1.RoleBinding object to a models.K8sRoleBinding object. -func parseRoleBinding(roleBinding rbacv1.RoleBinding) models.K8sRoleBinding { +func (kcl *KubeClient) parseRoleBinding(roleBinding rbacv1.RoleBinding) models.K8sRoleBinding { return models.K8sRoleBinding{ Name: roleBinding.Name, + UID: roleBinding.UID, Namespace: roleBinding.Namespace, RoleRef: roleBinding.RoleRef, Subjects: roleBinding.Subjects, CreationDate: roleBinding.CreationTimestamp.Time, + IsSystem: kcl.isSystemRoleBinding(&roleBinding), } } + +func (kcl *KubeClient) isSystemRoleBinding(rb *rbacv1.RoleBinding) bool { + if strings.HasPrefix(rb.Name, "system:") { + return true + } + + if rb.Labels != nil { + if rb.Labels["kubernetes.io/bootstrapping"] == "rbac-defaults" { + return true + } + } + + if rb.RoleRef.Name != "" { + role, err := kcl.getRole(rb.Namespace, rb.RoleRef.Name) + if err != nil { + return false + } + + // Linked to a role that is marked a system role + if kcl.isSystemRole(role) { + return true + } + } + + return false +} + +func (kcl *KubeClient) getRole(namespace, name string) (*corev1.Role, error) { + client := kcl.cli.RbacV1().Roles(namespace) + return client.Get(context.Background(), name, metav1.GetOptions{}) +} + +// DeleteRoleBindings processes a K8sServiceDeleteRequest by deleting each service +// in its given namespace. +func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteRequests) error { + var errors []error + for namespace := range reqs { + for _, name := range reqs[namespace] { + client := kcl.cli.RbacV1().RoleBindings(namespace) + + roleBinding, err := client.Get(context.Background(), name, v1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + continue + } + + // This is a more serious error to do with the client so we return right away + return err + } + + if kcl.isSystemRoleBinding(roleBinding) { + log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role binding, not allowed") + } + + if err := client.Delete(context.Background(), name, v1.DeleteOptions{}); err != nil { + errors = append(errors, err) + } + } + } + return errorlist.Combine(errors) +} diff --git a/api/portainer.go b/api/portainer.go index a087887fe..761d80abf 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1498,6 +1498,8 @@ type ( SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error IsRBACEnabled() (bool, error) GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error) + GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error) + DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error GetServiceAccountBearerToken(userID int) (string, error) CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) @@ -1531,6 +1533,16 @@ type ( CreateRegistrySecret(registry *Registry, namespace string) error IsRegistrySecret(namespace, secretName string) (bool, error) ToggleSystemState(namespace string, isSystem bool) error + + GetClusterRoles() ([]models.K8sClusterRole, error) + DeleteClusterRoles(models.K8sClusterRoleDeleteRequests) error + GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error) + DeleteClusterRoleBindings(models.K8sClusterRoleBindingDeleteRequests) error + + GetRoles(namespace string) ([]models.K8sRole, error) + DeleteRoles(models.K8sRoleDeleteRequests) error + GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error) + DeleteRoleBindings(models.K8sRoleBindingDeleteRequests) error } // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint)