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 @@
Security
+
+
+ +
+
+

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. +

+
+
+
By default, all the users have access to the default namespace. Enable this option to set accesses on the default namespace. @@ -126,10 +149,17 @@
- - + +
@@ -141,6 +171,7 @@ switch-class="'col-sm-8 text-muted'" data-cy="kubeSetup-restrictStandardUserIngressWToggle" feature-id="ctrl.limitedFeatureIngressDeploy" + disabled="!ctrl.isRBACEnabled" >
diff --git a/app/kubernetes/views/configure/configureController.js b/app/kubernetes/views/configure/configureController.js index 502dcaaf4..fb2bce797 100644 --- a/app/kubernetes/views/configure/configureController.js +++ b/app/kubernetes/views/configure/configureController.js @@ -7,6 +7,7 @@ import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils'; +import { getIsRBACEnabled } from '@/react/kubernetes/cluster/service'; class KubernetesConfigureController { /* #region CONSTRUCTOR */ @@ -54,6 +55,7 @@ class KubernetesConfigureController { this.onToggleIngressAvailabilityPerNamespace = this.onToggleIngressAvailabilityPerNamespace.bind(this); this.onToggleAllowNoneIngressClass = this.onToggleAllowNoneIngressClass.bind(this); this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this); + this.onToggleRestrictNs = this.onToggleRestrictNs.bind(this); } /* #endregion */ @@ -261,6 +263,12 @@ class KubernetesConfigureController { }); } + onToggleRestrictNs() { + this.$scope.$evalAsync(() => { + this.formValues.RestrictDefaultNamespace = !this.formValues.RestrictDefaultNamespace; + }); + } + /* #region ON INIT */ async onInit() { this.state = { @@ -292,11 +300,18 @@ class KubernetesConfigureController { IngressAvailabilityPerNamespace: false, }; + // default to true if error is thrown + this.isRBACEnabled = true; + this.isIngressControllersLoading = true; try { this.availableAccessModes = new KubernetesStorageClassAccessPolicies(); - [this.StorageClasses, this.endpoint] = await Promise.all([this.KubernetesStorageService.get(this.state.endpointId), this.EndpointService.endpoint(this.state.endpointId)]); + [this.StorageClasses, this.endpoint, this.isRBACEnabled] = await Promise.all([ + this.KubernetesStorageService.get(this.state.endpointId), + this.EndpointService.endpoint(this.state.endpointId), + getIsRBACEnabled(this.state.endpointId), + ]); this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.state.endpointId }); this.originalIngressControllers = structuredClone(this.ingressControllers) || []; diff --git a/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html b/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html index c318cbace..778db99dd 100644 --- a/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html +++ b/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html @@ -44,6 +44,28 @@
+
+
+ +
+
+

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. +

+
+

diff --git a/app/kubernetes/views/resource-pools/access/resourcePoolAccessController.js b/app/kubernetes/views/resource-pools/access/resourcePoolAccessController.js index c57e2ade7..1ce6ef6b0 100644 --- a/app/kubernetes/views/resource-pools/access/resourcePoolAccessController.js +++ b/app/kubernetes/views/resource-pools/access/resourcePoolAccessController.js @@ -3,13 +3,15 @@ import _ from 'lodash-es'; import { KubernetesPortainerConfigMapConfigName, KubernetesPortainerConfigMapNamespace, KubernetesPortainerConfigMapAccessKey } from 'Kubernetes/models/config-map/models'; import { UserAccessViewModel, TeamAccessViewModel } from 'Portainer/models/access'; import KubernetesConfigMapHelper from 'Kubernetes/helpers/configMapHelper'; +import { getIsRBACEnabled } from '@/react/kubernetes/cluster/service'; class KubernetesResourcePoolAccessController { /* @ngInject */ - constructor($async, $state, $scope, Notifications, KubernetesResourcePoolService, KubernetesConfigMapService, GroupService, AccessService) { + constructor($async, $state, $scope, Notifications, KubernetesResourcePoolService, KubernetesConfigMapService, GroupService, AccessService, EndpointProvider) { this.$async = $async; this.$state = $state; this.$scope = $scope; + this.EndpointProvider = EndpointProvider; this.Notifications = Notifications; this.KubernetesResourcePoolService = KubernetesResourcePoolService; this.KubernetesConfigMapService = KubernetesConfigMapService; @@ -45,12 +47,16 @@ class KubernetesResourcePoolAccessController { multiselectOutput: [], }; + // default to true if error is thrown + this.isRBACEnabled = true; + try { const name = this.$transition$.params().id; let [pool, configMap] = await Promise.all([ this.KubernetesResourcePoolService.get(name), this.KubernetesConfigMapService.getAccess(KubernetesPortainerConfigMapNamespace, KubernetesPortainerConfigMapConfigName), ]); + this.isRBACEnabled = await getIsRBACEnabled(this.EndpointProvider.endpointID()); const group = await this.GroupService.group(endpoint.GroupId); const roles = []; const endpointAccesses = await this.AccessService.accesses(endpoint, group, roles); diff --git a/app/react/kubernetes/cluster/service.ts b/app/react/kubernetes/cluster/service.ts new file mode 100644 index 000000000..7302ca568 --- /dev/null +++ b/app/react/kubernetes/cluster/service.ts @@ -0,0 +1,14 @@ +import PortainerError from '@/portainer/error'; +import axios from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export async function getIsRBACEnabled(environmentId: EnvironmentId) { + try { + const { data } = await axios.get( + `kubernetes/${environmentId}/rbac_enabled` + ); + return data; + } catch (e) { + throw new PortainerError('Unable to check if RBAC is enabled.', e as Error); + } +}