diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index f3f1a688b..da575e894 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -58,6 +58,7 @@ func NewHandler(bouncer *security.RequestBouncer, authorizationService *authoriz endpointRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllers)).Methods(http.MethodPut) endpointRouter.Handle("/ingresses/delete", httperror.LoggerHandler(h.deleteKubernetesIngresses)).Methods(http.MethodPost) endpointRouter.Handle("/services/delete", httperror.LoggerHandler(h.deleteKubernetesServices)).Methods(http.MethodPost) + endpointRouter.Path("/rbac_enabled").Handler(httperror.LoggerHandler(h.isRBACEnabled)).Methods(http.MethodGet) endpointRouter.Path("/namespaces").Handler(httperror.LoggerHandler(h.createKubernetesNamespace)).Methods(http.MethodPost) endpointRouter.Path("/namespaces").Handler(httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut) endpointRouter.Path("/namespaces").Handler(httperror.LoggerHandler(h.getKubernetesNamespaces)).Methods(http.MethodGet) diff --git a/api/http/handler/kubernetes/rbac.go b/api/http/handler/kubernetes/rbac.go new file mode 100644 index 000000000..012ff4af8 --- /dev/null +++ b/api/http/handler/kubernetes/rbac.go @@ -0,0 +1,51 @@ +package kubernetes + +import ( + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" +) + +// @id IsRBACEnabled +// @summary Check if RBAC is enabled +// @description Check if RBAC is enabled in the current Kubernetes cluster. +// @description **Access policy**: administrator +// @tags rbac_enabled +// @security ApiKeyAuth +// @security jwt +// @produce text/plain +// @param id path int true "Environment(Endpoint) identifier" +// @success 200 "Success" +// @failure 500 "Server error" +// @router /kubernetes/{id}/rbac_enabled [get] +func (handler *Handler) isRBACEnabled(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + // with the endpoint id and user auth, create a kube client instance + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest( + "Invalid environment identifier route variable", + err, + ) + } + + cli, ok := handler.KubernetesClientFactory.GetProxyKubeClient( + strconv.Itoa(endpointID), r.Header.Get("Authorization"), + ) + if !ok { + return httperror.InternalServerError( + "Failed to lookup KubeClient", + nil, + ) + } + + // with the kube client instance, check if RBAC is enabled + isRBACEnabled, err := cli.IsRBACEnabled() + if err != nil { + return httperror.InternalServerError("Failed to check RBAC status", err) + } + + return response.JSON(w, isRBACEnabled) +} diff --git a/api/internal/randomstring/random_string.go b/api/internal/randomstring/random_string.go new file mode 100644 index 000000000..0f2074e58 --- /dev/null +++ b/api/internal/randomstring/random_string.go @@ -0,0 +1,14 @@ +package randomstring + +import "math/rand" + +const letterBytes = "abcdefghijklmnopqrstuvwxyz0123456789" + +// RandomString returns a random lowercase alphanumeric string of length n +func RandomString(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Intn(len(letterBytes))] + } + return string(b) +} diff --git a/api/kubernetes/cli/rbac.go b/api/kubernetes/cli/rbac.go new file mode 100644 index 000000000..edea808f9 --- /dev/null +++ b/api/kubernetes/cli/rbac.go @@ -0,0 +1,166 @@ +package cli + +import ( + "context" + + "github.com/portainer/portainer/api/internal/randomstring" + + "github.com/rs/zerolog/log" + authv1 "k8s.io/api/authorization/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + authv1types "k8s.io/client-go/kubernetes/typed/authorization/v1" + corev1types "k8s.io/client-go/kubernetes/typed/core/v1" + rbacv1types "k8s.io/client-go/kubernetes/typed/rbac/v1" +) + +// IsRBACEnabled checks if RBAC is enabled in the cluster by creating a service account, then checking it's access to a resourcequota before and after setting a cluster role and cluster role binding +func (kcl *KubeClient) IsRBACEnabled() (bool, error) { + namespace := "default" + verb := "list" + resource := "resourcequotas" + + saClient := kcl.cli.CoreV1().ServiceAccounts(namespace) + uniqueString := randomstring.RandomString(4) // append a unique string to resource names, incase they already exist + saName := "portainer-rbac-test-sa-" + uniqueString + err := createServiceAccount(saClient, saName, namespace) + if err != nil { + log.Error().Err(err).Msg("Error creating service account") + return false, err + } + defer deleteServiceAccount(saClient, saName) + + accessReviewClient := kcl.cli.AuthorizationV1().LocalSubjectAccessReviews(namespace) + allowed, err := checkServiceAccountAccess(accessReviewClient, saName, verb, resource, namespace) + if err != nil { + log.Error().Err(err).Msg("Error checking service account access") + return false, err + } + + // if the service account with no authorizations is allowed, RBAC must be disabled + if allowed { + return false, nil + } + + // otherwise give the service account an rbac authorisation and check again + roleClient := kcl.cli.RbacV1().Roles(namespace) + roleName := "portainer-rbac-test-role-" + uniqueString + err = createRole(roleClient, roleName, verb, resource, namespace) + if err != nil { + log.Error().Err(err).Msg("Error creating role") + return false, err + } + defer deleteRole(roleClient, roleName) + + roleBindingClient := kcl.cli.RbacV1().RoleBindings(namespace) + roleBindingName := "portainer-rbac-test-role-binding-" + uniqueString + err = createRoleBinding(roleBindingClient, roleBindingName, roleName, saName, namespace) + if err != nil { + log.Error().Err(err).Msg("Error creating role binding") + return false, err + } + defer deleteRoleBinding(roleBindingClient, roleBindingName) + + allowed, err = checkServiceAccountAccess(accessReviewClient, saName, verb, resource, namespace) + if err != nil { + log.Error().Err(err).Msg("Error checking service account access with authorizations added") + return false, err + } + + // if the service account allowed to list resource quotas after given rbac role, then RBAC is enabled + return allowed, nil +} + +func createServiceAccount(saClient corev1types.ServiceAccountInterface, name string, namespace string) error { + serviceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + _, err := saClient.Create(context.Background(), serviceAccount, metav1.CreateOptions{}) + return err +} + +func deleteServiceAccount(saClient corev1types.ServiceAccountInterface, name string) { + err := saClient.Delete(context.Background(), name, metav1.DeleteOptions{}) + if err != nil { + log.Error().Err(err).Msg("Error deleting service account: " + name) + } +} + +func createRole(roleClient rbacv1types.RoleInterface, name string, verb string, resource string, namespace string) error { + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Verbs: []string{verb}, + Resources: []string{resource}, + }, + }, + } + _, err := roleClient.Create(context.Background(), role, metav1.CreateOptions{}) + return err +} + +func deleteRole(roleClient rbacv1types.RoleInterface, name string) { + err := roleClient.Delete(context.Background(), name, metav1.DeleteOptions{}) + if err != nil { + log.Error().Err(err).Msg("Error deleting role: " + name) + } +} + +func createRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, clusterRoleBindingName string, roleName string, serviceAccountName string, namespace string) error { + clusterRoleBinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleBindingName, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: serviceAccountName, + Namespace: namespace, + }, + }, + RoleRef: rbacv1.RoleRef{ + Kind: "Role", + Name: roleName, + APIGroup: "rbac.authorization.k8s.io", + }, + } + _, err := roleBindingClient.Create(context.Background(), clusterRoleBinding, metav1.CreateOptions{}) + return err +} + +func deleteRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, name string) { + err := roleBindingClient.Delete(context.Background(), name, metav1.DeleteOptions{}) + if err != nil { + log.Error().Err(err).Msg("Error deleting role binding: " + name) + } +} + +func checkServiceAccountAccess(accessReviewClient authv1types.LocalSubjectAccessReviewInterface, serviceAccountName string, verb string, resource string, namespace string) (bool, error) { + subjectAccessReview := &authv1.LocalSubjectAccessReview{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + }, + Spec: authv1.SubjectAccessReviewSpec{ + ResourceAttributes: &authv1.ResourceAttributes{ + Namespace: namespace, + Verb: verb, + Resource: resource, + }, + User: "system:serviceaccount:default:" + serviceAccountName, // a workaround to be able to use the service account as a user + }, + } + result, err := accessReviewClient.Create(context.Background(), subjectAccessReview, metav1.CreateOptions{}) + if err != nil { + return false, err + } + return result.Status.Allowed, nil +} diff --git a/api/portainer.go b/api/portainer.go index ff55d12be..0215cedd5 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1357,6 +1357,7 @@ type ( // KubeClient represents a service used to query a Kubernetes environment(endpoint) KubeClient interface { SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error + IsRBACEnabled() (bool, error) GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, error) GetServiceAccountBearerToken(userID int) (string, error) CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error) diff --git a/app/kubernetes/views/configure/configure.html b/app/kubernetes/views/configure/configure.html index d1a8c22e8..fe3471405 100644 --- a/app/kubernetes/views/configure/configure.html +++ b/app/kubernetes/views/configure/configure.html @@ -118,6 +118,29 @@
Your cluster does not have Kubernetes role-based access control (RBAC) enabled.
+This means you can't use Portainer RBAC functionality to regulate access to environment resources based on user roles.
+
+ To enable RBAC, start the API server with the --authorization-mode
flag set to a
+ comma-separated list that includes RBAC
, for example:
+ kube-apiserver --authorization-mode=Example1,RBAC,Example2
.
+