diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index 778f569d2..eb22a439c 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -78,6 +78,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza 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) diff --git a/api/http/handler/kubernetes/service_accounts.go b/api/http/handler/kubernetes/service_accounts.go index 424634804..3991ac7c6 100644 --- a/api/http/handler/kubernetes/service_accounts.go +++ b/api/http/handler/kubernetes/service_accounts.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) getAllKubernetesServiceAccounts(w http.ResponseWriter, r return response.JSON(w, serviceAccounts) } + +// @id DeleteServiceAccounts +// @summary Delete the provided service accounts +// @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 models.K8sServiceAccountDeleteRequests true "Service accounts to delete " +// @success 200 "Success" +// @failure 500 "Server error" +// @router /kubernetes/{id}/service_accounts/delete [POST] +func (handler *Handler) deleteKubernetesServiceAccounts(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sServiceAccountDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + err = cli.DeleteServiceAccounts(payload) + if err != nil { + return httperror.InternalServerError("Unable to delete service accounts", err) + } + + return nil +} diff --git a/api/http/models/kubernetes/service_accounts.go b/api/http/models/kubernetes/service_accounts.go index 533d4a986..13b279222 100644 --- a/api/http/models/kubernetes/service_accounts.go +++ b/api/http/models/kubernetes/service_accounts.go @@ -1,9 +1,30 @@ package kubernetes -import "time" +import ( + "errors" + "net/http" + "time" +) -type K8sServiceAccount struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - CreationDate time.Time `json:"creationDate"` +type ( + K8sServiceAccount struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + CreationDate time.Time `json:"creationDate"` + } + + // K8sServiceAcountDeleteRequests is a mapping of namespace names to a slice of service account names. + K8sServiceAccountDeleteRequests map[string][]string +) + +func (r K8sServiceAccountDeleteRequests) 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/internal/errorlist/errorlist.go b/api/internal/errorlist/errorlist.go new file mode 100644 index 000000000..79b5eea18 --- /dev/null +++ b/api/internal/errorlist/errorlist.go @@ -0,0 +1,18 @@ +package errorlist + +import "errors" + +// Combine a slice of errors into a single error +// to use this, generate errors by appending to errorList in a loop, then return combine(errorList) +func Combine(errorList []error) error { + if len(errorList) == 0 { + return nil + } + + errorMsg := "Multiple errors occurred:" + for _, err := range errorList { + errorMsg += "\n" + err.Error() + } + + return errors.New(errorMsg) +} diff --git a/api/kubernetes/cli/namespace.go b/api/kubernetes/cli/namespace.go index 57cd759cd..24c6cab60 100644 --- a/api/kubernetes/cli/namespace.go +++ b/api/kubernetes/cli/namespace.go @@ -94,7 +94,7 @@ func parseNamespace(namespace *corev1.Namespace) portainer.K8sNamespaceInfo { Status: namespace.Status, CreationDate: namespace.CreationTimestamp.Format(time.RFC3339), NamespaceOwner: namespace.Labels[namespaceOwnerLabel], - IsSystem: isSystemNamespace(*namespace), + IsSystem: isSystemNamespace(namespace), IsDefault: namespace.Name == defaultNamespace, } } @@ -171,7 +171,7 @@ func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1 return namespace, nil } -func isSystemNamespace(namespace corev1.Namespace) bool { +func isSystemNamespace(namespace *corev1.Namespace) bool { systemLabelValue, hasSystemLabel := namespace.Labels[systemNamespaceLabel] if hasSystemLabel { return systemLabelValue == "true" @@ -184,6 +184,15 @@ func isSystemNamespace(namespace corev1.Namespace) bool { return isSystem } +func (kcl *KubeClient) isSystemNamespace(namespace string) bool { + ns, err := kcl.cli.CoreV1().Namespaces().Get(context.TODO(), namespace, metav1.GetOptions{}) + if err != nil { + return false + } + + return isSystemNamespace(ns) +} + // ToggleSystemState will set a namespace as a system namespace, or remove this state // if isSystem is true it will set `systemNamespaceLabel` to "true" and false otherwise // this will skip if namespace is "default" or if the required state is already set @@ -199,7 +208,7 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er return errors.Wrap(err, "failed fetching namespace object") } - if isSystemNamespace(*namespace) == isSystem { + if isSystemNamespace(namespace) == isSystem { return nil } diff --git a/api/kubernetes/cli/namespace_test.go b/api/kubernetes/cli/namespace_test.go index bd7a6a5c7..d3d25a4d0 100644 --- a/api/kubernetes/cli/namespace_test.go +++ b/api/kubernetes/cli/namespace_test.go @@ -65,7 +65,7 @@ func Test_ToggleSystemState(t *testing.T) { ns, err := kcl.cli.CoreV1().Namespaces().Get(context.Background(), nsName, metav1.GetOptions{}) assert.NoError(t, err) - assert.Equal(t, test.isSystem, isSystemNamespace(*ns)) + assert.Equal(t, test.isSystem, isSystemNamespace(ns)) }) } }) diff --git a/api/kubernetes/cli/service_account.go b/api/kubernetes/cli/service_account.go index f0b2679ab..d115f89d4 100644 --- a/api/kubernetes/cli/service_account.go +++ b/api/kubernetes/cli/service_account.go @@ -2,9 +2,12 @@ package cli import ( "context" + "fmt" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/models/kubernetes" models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/portainer/portainer/api/internal/errorlist" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -81,6 +84,36 @@ func (kcl *KubeClient) GetPortainerUserServiceAccount(tokenData *portainer.Token return serviceAccount, nil } +// DeleteServices processes a K8sServiceDeleteRequest by deleting each service +// in its given namespace. +func (kcl *KubeClient) DeleteServiceAccounts(reqs kubernetes.K8sServiceAccountDeleteRequests) error { + var errors []error + for namespace := range reqs { + for _, serviceName := range reqs[namespace] { + client := kcl.cli.CoreV1().ServiceAccounts(namespace) + + sa, err := client.Get(context.Background(), serviceName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + continue + } + + return err + } + + if kcl.isSystemNamespace(sa.Namespace) { + return fmt.Errorf("cannot delete system service account %q", namespace+"/"+serviceName) + } + + if err := client.Delete(context.Background(), serviceName, metav1.DeleteOptions{}); err != nil { + errors = append(errors, err) + } + } + } + + return errorlist.Combine(errors) +} + // GetServiceAccountBearerToken returns the ServiceAccountToken associated to the specified user. func (kcl *KubeClient) GetServiceAccountBearerToken(userID int) (string, error) { serviceAccountName := UserServiceAccountName(userID, kcl.instanceID)