1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 07:49:41 +02:00

feat(rbac): detect if rbac is enabled [EE-4308] (#8139)

This commit is contained in:
Ali 2022-12-07 15:53:06 +13:00 committed by GitHub
parent 8dcc5e4adb
commit c1cc8bad77
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 327 additions and 6 deletions

View file

@ -58,6 +58,7 @@ func NewHandler(bouncer *security.RequestBouncer, authorizationService *authoriz
endpointRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllers)).Methods(http.MethodPut) endpointRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllers)).Methods(http.MethodPut)
endpointRouter.Handle("/ingresses/delete", httperror.LoggerHandler(h.deleteKubernetesIngresses)).Methods(http.MethodPost) endpointRouter.Handle("/ingresses/delete", httperror.LoggerHandler(h.deleteKubernetesIngresses)).Methods(http.MethodPost)
endpointRouter.Handle("/services/delete", httperror.LoggerHandler(h.deleteKubernetesServices)).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.createKubernetesNamespace)).Methods(http.MethodPost)
endpointRouter.Path("/namespaces").Handler(httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut) endpointRouter.Path("/namespaces").Handler(httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut)
endpointRouter.Path("/namespaces").Handler(httperror.LoggerHandler(h.getKubernetesNamespaces)).Methods(http.MethodGet) endpointRouter.Path("/namespaces").Handler(httperror.LoggerHandler(h.getKubernetesNamespaces)).Methods(http.MethodGet)

View file

@ -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)
}

View file

@ -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)
}

166
api/kubernetes/cli/rbac.go Normal file
View file

@ -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
}

View file

@ -1357,6 +1357,7 @@ type (
// KubeClient represents a service used to query a Kubernetes environment(endpoint) // KubeClient represents a service used to query a Kubernetes environment(endpoint)
KubeClient interface { KubeClient interface {
SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error
IsRBACEnabled() (bool, error)
GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, error) GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, error)
GetServiceAccountBearerToken(userID int) (string, error) GetServiceAccountBearerToken(userID int) (string, error)
CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error) CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error)

View file

@ -118,6 +118,29 @@
<!-- #region SECURITY --> <!-- #region SECURITY -->
<div class="col-sm-12 form-section-title"> Security </div> <div class="col-sm-12 form-section-title"> Security </div>
<div
ng-if="!ctrl.isRBACEnabled"
class="mt-1 mb-6 p-4 w-full border border-solid bg-warning-2 border-warning-5 text-warning-8 th-dark:bg-yellow-11 th-dark:text-white th-highcontrast:bg-yellow-11 th-highcontrast:text-white small flex gap-1 rounded-lg"
>
<div class="mt-0.5">
<pr-icon icon="'alert-triangle'" feather="true" class-name="'text-warning-7 th-dark:text-white th-highcontrast:text-white'"></pr-icon>
</div>
<div>
<p> Your cluster does not have Kubernetes role-based access control (RBAC) enabled. </p>
<p> This means you can't use Portainer RBAC functionality to regulate access to environment resources based on user roles. </p>
<p class="mb-0">
To enable RBAC, start the&nbsp;<a
class="th-dark:text-blue-7 th-highcontrast:text-blue-4"
href="https://kubernetes.io/docs/concepts/overview/components/#kube-apiserver"
target="_blank"
>API server</a
>&nbsp;with the&nbsp;<code class="box-decoration-clone bg-gray-4 th-dark:bg-black th-highcontrast:bg-black">--authorization-mode</code>&nbsp;flag set to a
comma-separated list that includes&nbsp;<code class="bg-gray-4 th-dark:bg-black th-highcontrast:bg-black">RBAC</code>, for example:&nbsp;
<code class="box-decoration-clone bg-gray-4 th-dark:bg-black th-highcontrast:bg-black">kube-apiserver --authorization-mode=Example1,RBAC,Example2</code>.
</p>
</div>
</div>
<div class="form-group"> <div class="form-group">
<span class="col-sm-12 text-muted small"> <span class="col-sm-12 text-muted small">
By default, all the users have access to the default namespace. Enable this option to set accesses on the default namespace. 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 @@
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<label class="control-label text-left col-sm-5 col-lg-4 px-0"> Restrict access to the default namespace </label> <por-switch-field
<label class="switch col-sm-8"> checked="ctrl.formValues.RestrictDefaultNamespace"
<input type="checkbox" ng-model="ctrl.formValues.RestrictDefaultNamespace" /><span class="slider round" data-cy="kubeSetup-restrictDefaultNsToggle"></span> name="'restrictDefaultNs'"
</label> label="'Restrict access to the default namespace'"
on-change="(ctrl.onToggleRestrictNs)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8 text-muted'"
data-cy="kubeSetup-restrictDefaultNsToggle"
disabled="!ctrl.isRBACEnabled"
>
</por-switch-field>
</div> </div>
<div class="col-sm-12 mt-5"> <div class="col-sm-12 mt-5">
@ -141,6 +171,7 @@
switch-class="'col-sm-8 text-muted'" switch-class="'col-sm-8 text-muted'"
data-cy="kubeSetup-restrictStandardUserIngressWToggle" data-cy="kubeSetup-restrictStandardUserIngressWToggle"
feature-id="ctrl.limitedFeatureIngressDeploy" feature-id="ctrl.limitedFeatureIngressDeploy"
disabled="!ctrl.isRBACEnabled"
></por-switch-field> ></por-switch-field>
</div> </div>
</div> </div>

View file

@ -7,6 +7,7 @@ import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils'; import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
import { getIsRBACEnabled } from '@/react/kubernetes/cluster/service';
class KubernetesConfigureController { class KubernetesConfigureController {
/* #region CONSTRUCTOR */ /* #region CONSTRUCTOR */
@ -54,6 +55,7 @@ class KubernetesConfigureController {
this.onToggleIngressAvailabilityPerNamespace = this.onToggleIngressAvailabilityPerNamespace.bind(this); this.onToggleIngressAvailabilityPerNamespace = this.onToggleIngressAvailabilityPerNamespace.bind(this);
this.onToggleAllowNoneIngressClass = this.onToggleAllowNoneIngressClass.bind(this); this.onToggleAllowNoneIngressClass = this.onToggleAllowNoneIngressClass.bind(this);
this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this); this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this);
this.onToggleRestrictNs = this.onToggleRestrictNs.bind(this);
} }
/* #endregion */ /* #endregion */
@ -261,6 +263,12 @@ class KubernetesConfigureController {
}); });
} }
onToggleRestrictNs() {
this.$scope.$evalAsync(() => {
this.formValues.RestrictDefaultNamespace = !this.formValues.RestrictDefaultNamespace;
});
}
/* #region ON INIT */ /* #region ON INIT */
async onInit() { async onInit() {
this.state = { this.state = {
@ -292,11 +300,18 @@ class KubernetesConfigureController {
IngressAvailabilityPerNamespace: false, IngressAvailabilityPerNamespace: false,
}; };
// default to true if error is thrown
this.isRBACEnabled = true;
this.isIngressControllersLoading = true; this.isIngressControllersLoading = true;
try { try {
this.availableAccessModes = new KubernetesStorageClassAccessPolicies(); 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.ingressControllers = await getIngressControllerClassMap({ environmentId: this.state.endpointId });
this.originalIngressControllers = structuredClone(this.ingressControllers) || []; this.originalIngressControllers = structuredClone(this.ingressControllers) || [];

View file

@ -44,6 +44,28 @@
<rd-widget-body> <rd-widget-body>
<form class="form-horizontal"> <form class="form-horizontal">
<div class="form-group"> <div class="form-group">
<div
ng-if="!ctrl.isRBACEnabled"
class="mb-6 mx-[15px] p-4 border border-solid bg-warning-2 border-warning-5 text-warning-8 th-dark:bg-yellow-11 th-dark:text-white th-highcontrast:bg-yellow-11 th-highcontrast:text-white small flex gap-1 rounded-lg"
>
<div class="mt-0.5">
<pr-icon icon="'alert-triangle'" feather="true" class-name="'text-warning-7 th-dark:text-white th-highcontrast:text-white'"></pr-icon>
</div>
<div>
<p> Your cluster does not have Kubernetes role-based access control (RBAC) enabled. </p>
<p> This means you can't use Portainer RBAC functionality to regulate access to environment resources based on user roles. </p>
<p class="mb-0">
To enable RBAC, start the&nbsp;<a
class="th-dark:text-blue-7 th-highcontrast:text-blue-4"
href="https://kubernetes.io/docs/concepts/overview/components/#kube-apiserver"
target="_blank"
>API server</a
>&nbsp;with the&nbsp;<code class="box-decoration-clone bg-gray-4 th-dark:bg-black th-highcontrast:bg-black">--authorization-mode</code>&nbsp;flag set to a
comma-separated list that includes&nbsp;<code class="bg-gray-4 th-dark:bg-black th-highcontrast:bg-black">RBAC</code>, for example:&nbsp;
<code class="box-decoration-clone bg-gray-4 th-dark:bg-black th-highcontrast:bg-black">kube-apiserver --authorization-mode=Example1,RBAC,Example2</code>.
</p>
</div>
</div>
<span class="col-sm-12 small text-warning"> <span class="col-sm-12 small text-warning">
<p class="vertical-center"> <p class="vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>

View file

@ -3,13 +3,15 @@ import _ from 'lodash-es';
import { KubernetesPortainerConfigMapConfigName, KubernetesPortainerConfigMapNamespace, KubernetesPortainerConfigMapAccessKey } from 'Kubernetes/models/config-map/models'; import { KubernetesPortainerConfigMapConfigName, KubernetesPortainerConfigMapNamespace, KubernetesPortainerConfigMapAccessKey } from 'Kubernetes/models/config-map/models';
import { UserAccessViewModel, TeamAccessViewModel } from 'Portainer/models/access'; import { UserAccessViewModel, TeamAccessViewModel } from 'Portainer/models/access';
import KubernetesConfigMapHelper from 'Kubernetes/helpers/configMapHelper'; import KubernetesConfigMapHelper from 'Kubernetes/helpers/configMapHelper';
import { getIsRBACEnabled } from '@/react/kubernetes/cluster/service';
class KubernetesResourcePoolAccessController { class KubernetesResourcePoolAccessController {
/* @ngInject */ /* @ngInject */
constructor($async, $state, $scope, Notifications, KubernetesResourcePoolService, KubernetesConfigMapService, GroupService, AccessService) { constructor($async, $state, $scope, Notifications, KubernetesResourcePoolService, KubernetesConfigMapService, GroupService, AccessService, EndpointProvider) {
this.$async = $async; this.$async = $async;
this.$state = $state; this.$state = $state;
this.$scope = $scope; this.$scope = $scope;
this.EndpointProvider = EndpointProvider;
this.Notifications = Notifications; this.Notifications = Notifications;
this.KubernetesResourcePoolService = KubernetesResourcePoolService; this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.KubernetesConfigMapService = KubernetesConfigMapService; this.KubernetesConfigMapService = KubernetesConfigMapService;
@ -45,12 +47,16 @@ class KubernetesResourcePoolAccessController {
multiselectOutput: [], multiselectOutput: [],
}; };
// default to true if error is thrown
this.isRBACEnabled = true;
try { try {
const name = this.$transition$.params().id; const name = this.$transition$.params().id;
let [pool, configMap] = await Promise.all([ let [pool, configMap] = await Promise.all([
this.KubernetesResourcePoolService.get(name), this.KubernetesResourcePoolService.get(name),
this.KubernetesConfigMapService.getAccess(KubernetesPortainerConfigMapNamespace, KubernetesPortainerConfigMapConfigName), this.KubernetesConfigMapService.getAccess(KubernetesPortainerConfigMapNamespace, KubernetesPortainerConfigMapConfigName),
]); ]);
this.isRBACEnabled = await getIsRBACEnabled(this.EndpointProvider.endpointID());
const group = await this.GroupService.group(endpoint.GroupId); const group = await this.GroupService.group(endpoint.GroupId);
const roles = []; const roles = [];
const endpointAccesses = await this.AccessService.accesses(endpoint, group, roles); const endpointAccesses = await this.AccessService.accesses(endpoint, group, roles);

View file

@ -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);
}
}