diff --git a/backend/controllers/clusterbinding/clusterbinding_controller.go b/backend/controllers/clusterbinding/clusterbinding_controller.go index abd1c7853..8b0c31471 100644 --- a/backend/controllers/clusterbinding/clusterbinding_controller.go +++ b/backend/controllers/clusterbinding/clusterbinding_controller.go @@ -21,13 +21,9 @@ import ( "fmt" "reflect" - v1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/cluster" "sigs.k8s.io/controller-runtime/pkg/controller" @@ -64,84 +60,6 @@ func NewClusterBindingReconciler( manager: mgr, opts: opts, scope: scope, - reconciler: reconciler{ - scope: scope, - listServiceExports: func(ctx context.Context, cache cache.Cache, ns string) ([]*kubebindv1alpha2.APIServiceExport, error) { - var list kubebindv1alpha2.APIServiceExportList - if err := cache.List(ctx, &list, client.InNamespace(ns)); err != nil { - return nil, err - } - var exports []*kubebindv1alpha2.APIServiceExport - for i := range list.Items { - exports = append(exports, &list.Items[i]) - } - return exports, nil - }, - getBoundSchema: func(ctx context.Context, cache cache.Cache, namespace, name string) (*kubebindv1alpha2.BoundSchema, error) { - result := &kubebindv1alpha2.BoundSchema{} - err := cache.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, result) - if err != nil { - return nil, fmt.Errorf("failed to get BoundSchema %q: %w", name, err) - } - return result, nil - }, - getClusterRole: func(ctx context.Context, cache cache.Cache, name string) (*rbacv1.ClusterRole, error) { - var role rbacv1.ClusterRole - key := types.NamespacedName{Name: name} - if err := cache.Get(ctx, key, &role); err != nil { - return nil, err - } - return &role, nil - }, - createClusterRole: func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRole) error { - return client.Create(ctx, binding) - }, - updateClusterRole: func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRole) error { - return client.Update(ctx, binding) - }, - getClusterRoleBinding: func(ctx context.Context, cache cache.Cache, name string) (*rbacv1.ClusterRoleBinding, error) { - var binding rbacv1.ClusterRoleBinding - key := types.NamespacedName{Name: name} - if err := cache.Get(ctx, key, &binding); err != nil { - return nil, err - } - return &binding, nil - }, - createClusterRoleBinding: func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRoleBinding) error { - return client.Create(ctx, binding) - }, - updateClusterRoleBinding: func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRoleBinding) error { - return client.Update(ctx, binding) - }, - deleteClusterRoleBinding: func(ctx context.Context, client client.Client, name string) error { - binding := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{Name: name}, - } - return client.Delete(ctx, binding) - }, - getNamespace: func(ctx context.Context, cache cache.Cache, name string) (*v1.Namespace, error) { - var ns v1.Namespace - key := types.NamespacedName{Name: name} - if err := cache.Get(ctx, key, &ns); err != nil { - return nil, err - } - return &ns, nil - }, - createRoleBinding: func(ctx context.Context, client client.Client, binding *rbacv1.RoleBinding) error { - return client.Create(ctx, binding) - }, - updateRoleBinding: func(ctx context.Context, client client.Client, binding *rbacv1.RoleBinding) error { - return client.Update(ctx, binding) - }, - getRoleBinding: func(ctx context.Context, cache cache.Cache, ns, name string) (*rbacv1.RoleBinding, error) { - var binding rbacv1.RoleBinding - key := types.NamespacedName{Namespace: ns, Name: name} - if err := cache.Get(ctx, key, &binding); err != nil { - return nil, err - } - return &binding, nil - }, - }, } return r, nil @@ -153,10 +71,6 @@ func NewClusterBindingReconciler( //+kubebuilder:rbac:groups=kube-bind.io,resources=apiserviceexports,verbs=get;list;watch //+kubebuilder:rbac:groups=kube-bind.io,resources=collections,verbs=get;list;watch //+kubebuilder:rbac:groups=kube-bind.io,resources=apiserviceexporttemplates,verbs=get;list;watch -//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterrolebindings,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -210,9 +124,6 @@ func (r *ClusterBindingReconciler) Reconcile(ctx context.Context, req mcreconcil func (r *ClusterBindingReconciler) SetupWithManager(mgr mcmanager.Manager) error { return mcbuilder.ControllerManagedBy(mgr). For(&kubebindv1alpha2.ClusterBinding{}). - Owns(&rbacv1.ClusterRole{}). - Owns(&rbacv1.ClusterRoleBinding{}). - Owns(&rbacv1.RoleBinding{}). Watches( &kubebindv1alpha2.APIServiceExport{}, mapAPIResourceSchema, diff --git a/backend/controllers/clusterbinding/clusterbinding_reconcile.go b/backend/controllers/clusterbinding/clusterbinding_reconcile.go index e0578adfc..0f62bd254 100644 --- a/backend/controllers/clusterbinding/clusterbinding_reconcile.go +++ b/backend/controllers/clusterbinding/clusterbinding_reconcile.go @@ -18,47 +18,18 @@ package clusterbinding import ( "context" - "fmt" - "maps" - "reflect" - "slices" "time" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" utilerrors "k8s.io/apimachinery/pkg/util/errors" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" - kuberesources "github.com/kube-bind/kube-bind/backend/kubernetes/resources" kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" conditionsapi "github.com/kube-bind/kube-bind/sdk/apis/third_party/conditions/apis/conditions/v1alpha1" "github.com/kube-bind/kube-bind/sdk/apis/third_party/conditions/util/conditions" ) -type reconciler struct { - scope kubebindv1alpha2.InformerScope - - listServiceExports func(ctx context.Context, cache cache.Cache, ns string) ([]*kubebindv1alpha2.APIServiceExport, error) - getBoundSchema func(ctx context.Context, cache cache.Cache, namespace, name string) (*kubebindv1alpha2.BoundSchema, error) - getClusterRole func(ctx context.Context, cache cache.Cache, name string) (*rbacv1.ClusterRole, error) - createClusterRole func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRole) error - updateClusterRole func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRole) error - - getClusterRoleBinding func(ctx context.Context, cache cache.Cache, name string) (*rbacv1.ClusterRoleBinding, error) - createClusterRoleBinding func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRoleBinding) error - updateClusterRoleBinding func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRoleBinding) error - deleteClusterRoleBinding func(ctx context.Context, client client.Client, name string) error - - getRoleBinding func(ctx context.Context, cache cache.Cache, ns, name string) (*rbacv1.RoleBinding, error) - createRoleBinding func(ctx context.Context, client client.Client, binding *rbacv1.RoleBinding) error - updateRoleBinding func(ctx context.Context, client client.Client, binding *rbacv1.RoleBinding) error - - getNamespace func(ctx context.Context, cache cache.Cache, name string) (*corev1.Namespace, error) -} +type reconciler struct{} func (r *reconciler) reconcile(ctx context.Context, client client.Client, cache cache.Cache, clusterBinding *kubebindv1alpha2.ClusterBinding) error { var errs []error @@ -66,15 +37,6 @@ func (r *reconciler) reconcile(ctx context.Context, client client.Client, cache if err := r.ensureClusterBindingConditions(ctx, clusterBinding); err != nil { errs = append(errs, err) } - if err := r.ensureRBACRoleBinding(ctx, client, cache, clusterBinding); err != nil { - errs = append(errs, err) - } - if err := r.ensureRBACClusterRole(ctx, client, cache, clusterBinding); err != nil { - errs = append(errs, err) - } - if err := r.ensureRBACClusterRoleBinding(ctx, client, cache, clusterBinding); err != nil { - errs = append(errs, err) - } conditions.SetSummary(clusterBinding) @@ -120,187 +82,3 @@ func (r *reconciler) ensureClusterBindingConditions(_ context.Context, clusterBi return nil } - -func (r *reconciler) ensureRBACClusterRole(ctx context.Context, client client.Client, cache cache.Cache, clusterBinding *kubebindv1alpha2.ClusterBinding) error { - name := "kube-binder-" + clusterBinding.Namespace - role, err := r.getClusterRole(ctx, cache, name) - if err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to get ClusterRole %s: %w", name, err) - } - - ns, err := r.getNamespace(ctx, cache, clusterBinding.Namespace) - if err != nil { - return fmt.Errorf("failed to get Namespace %s: %w", clusterBinding.Namespace, err) - } - - exports, err := r.listServiceExports(ctx, cache, clusterBinding.Namespace) - if err != nil { - return fmt.Errorf("failed to list APIServiceExports: %w", err) - } - expected := &rbacv1.ClusterRole{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - OwnerReferences: []metav1.OwnerReference{ - { - APIVersion: "v1", - Kind: "Namespace", - Name: clusterBinding.Namespace, - Controller: ptr.To(true), - UID: ns.UID, - }, - }, - }, - Rules: []rbacv1.PolicyRule{ - // Always need to be able to get/list/watch the BoundSchemas - // to be able to figure out what to bind. - { - APIGroups: []string{kubebindv1alpha2.GroupName}, - Resources: []string{"boundschemas"}, - Verbs: []string{"get", "list", "watch"}, - }, - { - APIGroups: []string{kubebindv1alpha2.GroupName}, - Resources: []string{"boundschemas/status"}, - Verbs: []string{"get", "update", "patch"}, - }, - }} - for _, export := range exports { - // Collect unique GroupResources and sort for stable rule ordering. - grSet := map[string]kubebindv1alpha2.GroupResource{} - for _, res := range export.Spec.Resources { - key := res.ResourceGroupName() - grSet[key] = kubebindv1alpha2.GroupResource{Group: res.Group, Resource: res.Resource} - } - keys := slices.Collect(maps.Keys(grSet)) - slices.Sort(keys) - for _, k := range keys { - // k is already normalized (e.g., "pods.core" for empty group). - schema, err := r.getBoundSchema(ctx, cache, clusterBinding.Namespace, k) - if err != nil { - return fmt.Errorf("failed to get BoundSchema %q: %w", k, err) - } - expected.Rules = append(expected.Rules, - rbacv1.PolicyRule{ - APIGroups: []string{schema.Spec.Group}, - Resources: []string{schema.Spec.Names.Plural}, - Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, - }, - ) - } - } - - if role == nil { - if err := r.createClusterRole(ctx, client, expected); err != nil { - return fmt.Errorf("failed to create ClusterRole %s: %w", expected.Name, err) - } - } else if !reflect.DeepEqual(role.Rules, expected.Rules) { - role = role.DeepCopy() - role.Rules = expected.Rules - if err := r.updateClusterRole(ctx, client, role); err != nil { - return fmt.Errorf("failed to create ClusterRole %s: %w", role.Name, err) - } - } - - return nil -} - -func (r *reconciler) ensureRBACClusterRoleBinding(ctx context.Context, client client.Client, cache cache.Cache, clusterBinding *kubebindv1alpha2.ClusterBinding) error { - name := "kube-binder-" + clusterBinding.Namespace - binding, err := r.getClusterRoleBinding(ctx, cache, name) - if err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to get ClusterRoleBinding %s: %w", name, err) - } - if r.scope != kubebindv1alpha2.ClusterScope { - if err := r.deleteClusterRoleBinding(ctx, client, name); err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to delete ClusterRoleBinding %s: %w", name, err) - } - } - - ns, err := r.getNamespace(ctx, cache, clusterBinding.Namespace) - if err != nil { - return fmt.Errorf("failed to get Namespace %s: %w", clusterBinding.Namespace, err) - } - - expected := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - OwnerReferences: []metav1.OwnerReference{ - { - APIVersion: "v1", - Kind: "Namespace", - Name: clusterBinding.Namespace, - Controller: ptr.To(true), - UID: ns.UID, - }, - }, - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Namespace: clusterBinding.Namespace, - Name: kuberesources.ServiceAccountName, - }, - }, - RoleRef: rbacv1.RoleRef{ - Kind: "ClusterRole", - Name: name, - APIGroup: "rbac.authorization.k8s.io", - }, - } - - if binding == nil { - if err := r.createClusterRoleBinding(ctx, client, expected); err != nil { - return fmt.Errorf("failed to create ClusterRoleBinding %s: %w", expected.Name, err) - } - } else if !reflect.DeepEqual(binding.Subjects, expected.Subjects) { - binding = binding.DeepCopy() - binding.Subjects = expected.Subjects - // roleRef is immutable - if err := r.updateClusterRoleBinding(ctx, client, binding); err != nil { - return fmt.Errorf("failed to create ClusterRoleBinding %s: %w", expected.Namespace, err) - } - } - - return nil -} - -func (r *reconciler) ensureRBACRoleBinding(ctx context.Context, client client.Client, cache cache.Cache, clusterBinding *kubebindv1alpha2.ClusterBinding) error { - binding, err := r.getRoleBinding(ctx, cache, clusterBinding.Namespace, "kube-binder") - if err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to get RoleBinding \"kube-binder\": %w", err) - } - - expected := &rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: kuberesources.ServiceAccountName, - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: kuberesources.ServiceAccountName, - Namespace: clusterBinding.Namespace, - }, - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: "kube-binder", - }, - } - - if binding == nil { - expected.Namespace = clusterBinding.Namespace - if err := r.createRoleBinding(ctx, client, expected); err != nil { - return fmt.Errorf("failed to create RoleBinding %s: %w", expected.Name, err) - } - } else if !reflect.DeepEqual(binding.Subjects, expected.Subjects) { - binding = binding.DeepCopy() - binding.Subjects = expected.Subjects - // roleRef is immutable - if err := r.updateRoleBinding(ctx, client, binding); err != nil { - return fmt.Errorf("failed to create RoleBinding %s: %w", expected.Namespace, err) - } - } - - return nil -} diff --git a/backend/controllers/serviceexportrbac/rbac_controller.go b/backend/controllers/serviceexportrbac/rbac_controller.go new file mode 100644 index 000000000..ebda2e336 --- /dev/null +++ b/backend/controllers/serviceexportrbac/rbac_controller.go @@ -0,0 +1,218 @@ +/* +Copyright 2025 The Kube Bind Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package serviceexportrbac + +import ( + "context" + "fmt" + + v1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" +) + +const ( + controllerName = "kube-bind-backend-serviceexport-rbac" +) + +type APIServiceExportRBACReconciler struct { + manager mcmanager.Manager + opts controller.TypedOptions[mcreconcile.Request] + reconciler reconciler +} + +func NewAPIServiceExportRBACReconciler( + ctx context.Context, + mgr mcmanager.Manager, + scope kubebindv1alpha2.InformerScope, + opts controller.TypedOptions[mcreconcile.Request], +) (*APIServiceExportRBACReconciler, error) { + return &APIServiceExportRBACReconciler{ + manager: mgr, + opts: opts, + + reconciler: reconciler{ + scope: scope, + // Namespace related. + listServiceNamespaces: func(ctx context.Context, cache cache.Cache, namespace string) ([]*kubebindv1alpha2.APIServiceNamespace, error) { + var list kubebindv1alpha2.APIServiceNamespaceList + if err := cache.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + var serviceNamespaces []*kubebindv1alpha2.APIServiceNamespace + for i := range list.Items { + serviceNamespaces = append(serviceNamespaces, &list.Items[i]) + } + return serviceNamespaces, nil + }, + getNamespace: func(ctx context.Context, cache cache.Cache, name string) (*v1.Namespace, error) { + var ns v1.Namespace + key := types.NamespacedName{Name: name} + if err := cache.Get(ctx, key, &ns); err != nil { + return nil, err + } + return &ns, nil + }, + // ClusterRole. + getClusterRole: func(ctx context.Context, cache cache.Cache, key types.NamespacedName) (*rbacv1.ClusterRole, error) { + var role rbacv1.ClusterRole + if err := cache.Get(ctx, key, &role); err != nil { + return nil, err + } + return &role, nil + }, + createClusterRole: func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRole) error { + return client.Create(ctx, binding) + }, + updateClusterRole: func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRole) error { + return client.Update(ctx, binding) + }, + // ClusterRoleBinding. + getClusterRoleBinding: func(ctx context.Context, cache cache.Cache, key types.NamespacedName) (*rbacv1.ClusterRoleBinding, error) { + var binding rbacv1.ClusterRoleBinding + if err := cache.Get(ctx, key, &binding); err != nil { + return nil, err + } + return &binding, nil + }, + createClusterRoleBinding: func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRoleBinding) error { + return client.Create(ctx, binding) + }, + updateClusterRoleBinding: func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRoleBinding) error { + return client.Update(ctx, binding) + }, + // Role. + getRole: func(ctx context.Context, cache cache.Cache, key types.NamespacedName) (*rbacv1.Role, error) { + var role rbacv1.Role + if err := cache.Get(ctx, key, &role); err != nil { + return nil, err + } + return &role, nil + }, + createRole: func(ctx context.Context, client client.Client, role *rbacv1.Role) error { + return client.Create(ctx, role) + }, + updateRole: func(ctx context.Context, client client.Client, role *rbacv1.Role) error { + return client.Update(ctx, role) + }, + // RoleBinding. + getRoleBinding: func(ctx context.Context, cache cache.Cache, key types.NamespacedName) (*rbacv1.RoleBinding, error) { + var binding rbacv1.RoleBinding + if err := cache.Get(ctx, key, &binding); err != nil { + return nil, err + } + return &binding, nil + }, + createRoleBinding: func(ctx context.Context, client client.Client, binding *rbacv1.RoleBinding) error { + return client.Create(ctx, binding) + }, + updateRoleBinding: func(ctx context.Context, client client.Client, binding *rbacv1.RoleBinding) error { + return client.Update(ctx, binding) + }, + }, + }, nil +} + +func (r *APIServiceExportRBACReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + logger.Info("Reconciling APIServiceExport", "request", req) + + cl, err := r.manager.GetCluster(ctx, req.ClusterName) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get client for cluster %q: %w", req.ClusterName, err) + } + + client := cl.GetClient() + cache := cl.GetCache() + + // Fetch the APIServiceExport instance + export := kubebindv1alpha2.APIServiceExport{} + if err := client.Get(ctx, req.NamespacedName, &export); err != nil { + if errors.IsNotFound(err) { + // Request object not found, could have been deleted after reconcile request. + logger.Info("APIServiceExport not found, ignoring") + return ctrl.Result{}, nil + } + // Error reading the object - requeue the request. + return ctrl.Result{}, fmt.Errorf("failed to get APIServiceExport: %w", err) + } + + // Run the reconciliation logic + if err := r.reconciler.reconcile(ctx, client, cache, &export); err != nil { + logger.Error(err, "Failed to reconcile APIServiceExport") + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *APIServiceExportRBACReconciler) SetupWithManager(mgr mcmanager.Manager) error { + return mcbuilder.ControllerManagedBy(mgr). + For(&kubebindv1alpha2.APIServiceExport{}). + Watches( + &kubebindv1alpha2.APIServiceNamespace{}, + mapAPIServiceNamespace, + ). + WithOptions(r.opts). + Named(controllerName). + Complete(r) +} + +func mapAPIServiceNamespace(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] { + return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []mcreconcile.Request { + serviceNamespace, ok := obj.(*kubebindv1alpha2.APIServiceNamespace) + if !ok { + return nil + } + + c := cl.GetClient() + + var exports kubebindv1alpha2.APIServiceExportList + if err := c.List(ctx, &exports, client.InNamespace(serviceNamespace.Namespace)); err != nil { + return []mcreconcile.Request{} + } + + var results []mcreconcile.Request + for _, export := range exports.Items { + results = append(results, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: export.Namespace, + Name: export.Name, + }, + }, + ClusterName: clusterName, + }) + } + + return results + }) +} diff --git a/backend/controllers/serviceexportrbac/rbac_reconciler.go b/backend/controllers/serviceexportrbac/rbac_reconciler.go new file mode 100644 index 000000000..dcb9ae957 --- /dev/null +++ b/backend/controllers/serviceexportrbac/rbac_reconciler.go @@ -0,0 +1,443 @@ +/* +Copyright 2025 The Kube Bind Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package serviceexportrbac + +import ( + "context" + "fmt" + "maps" + "reflect" + "slices" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + kuberesources "github.com/kube-bind/kube-bind/backend/kubernetes/resources" + kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" +) + +type reconciler struct { + scope kubebindv1alpha2.InformerScope + + listServiceNamespaces func(ctx context.Context, cache cache.Cache, namespace string) ([]*kubebindv1alpha2.APIServiceNamespace, error) + getNamespace func(ctx context.Context, cache cache.Cache, name string) (*corev1.Namespace, error) + + getClusterRole func(ctx context.Context, cache cache.Cache, key types.NamespacedName) (*rbacv1.ClusterRole, error) + createClusterRole func(ctx context.Context, client client.Client, clusterRole *rbacv1.ClusterRole) error + updateClusterRole func(ctx context.Context, client client.Client, clusterRole *rbacv1.ClusterRole) error + + getClusterRoleBinding func(ctx context.Context, cache cache.Cache, key types.NamespacedName) (*rbacv1.ClusterRoleBinding, error) + createClusterRoleBinding func(ctx context.Context, client client.Client, clusterRoleBinding *rbacv1.ClusterRoleBinding) error + updateClusterRoleBinding func(ctx context.Context, client client.Client, clusterRoleBinding *rbacv1.ClusterRoleBinding) error + + getRole func(ctx context.Context, cache cache.Cache, key types.NamespacedName) (*rbacv1.Role, error) + createRole func(ctx context.Context, client client.Client, role *rbacv1.Role) error + updateRole func(ctx context.Context, client client.Client, role *rbacv1.Role) error + + getRoleBinding func(ctx context.Context, cache cache.Cache, key types.NamespacedName) (*rbacv1.RoleBinding, error) + createRoleBinding func(ctx context.Context, client client.Client, roleBinding *rbacv1.RoleBinding) error + updateRoleBinding func(ctx context.Context, client client.Client, roleBinding *rbacv1.RoleBinding) error +} + +const ( + rbacAggregateLabelKey = "rbac.kube-bind.io/aggregate-to-parent" +) + +func (r *reconciler) reconcile(ctx context.Context, client client.Client, cache cache.Cache, export *kubebindv1alpha2.APIServiceExport) error { + logger := log.FromContext(ctx) + + var errs []error + + // Retrieve policy rules for exported and claimed resources. + + // buildPolicyRules transforms the input groupResourceSet + // into a slice of PolicyRules with deterministic ordering. + buildPolicyRules := func(verbs []string, groupResourceSet map[string]metav1.GroupResource) []rbacv1.PolicyRule { + rules := make([]rbacv1.PolicyRule, 0, len(groupResourceSet)) + for _, key := range slices.Sorted(maps.Keys(groupResourceSet)) { + gr := groupResourceSet[key] + rules = append(rules, rbacv1.PolicyRule{ + APIGroups: []string{gr.Group}, + Resources: []string{gr.Resource}, + Verbs: verbs, + }) + } + return rules + } + + resourceGroupResourceSet := make(map[string]metav1.GroupResource) + for _, res := range export.Spec.Resources { + resourceGroupResourceSet[res.GroupResource.String()] = metav1.GroupResource(res.GroupResource) + } + resourceRules := buildPolicyRules( + []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + resourceGroupResourceSet, + ) + + clusterScopedClaimsGroupResourceSet := make(map[string]metav1.GroupResource) + namespaceScopedClaimsGroupResourceSet := make(map[string]metav1.GroupResource) + for _, claim := range export.Spec.PermissionClaims { + var claimScope apiextensionsv1.ResourceScope + for _, claimableAPI := range kubebindv1alpha2.ClaimableAPIs { + if claim.Group == claimableAPI.GroupVersionResource.Group && claim.Resource == claimableAPI.GroupVersionResource.Resource { + claimScope = claimableAPI.ResourceScope + break + } + } + switch claimScope { + case apiextensionsv1.ClusterScoped: + clusterScopedClaimsGroupResourceSet[claim.GroupResource.String()] = metav1.GroupResource(claim.GroupResource) + case apiextensionsv1.NamespaceScoped: + namespaceScopedClaimsGroupResourceSet[claim.GroupResource.String()] = metav1.GroupResource(claim.GroupResource) + default: + // This claimed resource is not in our claimable APIs list. + // There is nothing we can do about it here, just skip it. + logger.V(2).Info("Skipping RBAC reconciliation for claimed resource because it's not a claimable API", + "resource", claim.GroupResource.String()) + continue + } + } + clusterScopedClaimRules := buildPolicyRules( + []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + clusterScopedClaimsGroupResourceSet, + ) + namespaceScopedClaimRules := buildPolicyRules( + []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + namespaceScopedClaimsGroupResourceSet, + ) + + // Here we ensure RBAC objects are in place. + // + // Cluster-scoped RBACs: + // + // - kube-binder-exports ClusterRole and ClusterRoleBinding + // * bound to /kube-binder ServiceAccount + // Aggregating ClusterRole for all subsequent ClusterRoles we create. + // - kube-binder-resources-- ClusterRole + // Access to all resources declared in APIServiceExport.Spec.Resources. + // Aggregates into kube-binder-exports ClusterRole. + // - kube-binder-claims-- ClusterRole + // Access to all cluster-scoped claimed resources declared in APIServiceExport.Spec.PermissionClaims. + // Aggregates into kube-binder-exports ClusterRole. + // + // Namespace-scoped RBACs: + // + // - /kube-binder-claims- Role and RoleBinding + // * bound to /kube-binder ServiceAccount + // Access to namespace-scoped claimed resources declared in APIServiceExport.Spec.PermissionClaims. + + // We use the owner ref below in "kube-binder-{resources,claims}-*" + // ClusterRoles and ClusterRoleBindings for automatic cleanup. + ns, err := r.getNamespace(ctx, cache, export.Namespace) + if err != nil { + return err + } + ownerRefs := []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Namespace", + Name: ns.Name, + UID: ns.UID, + }, + } + + // We always bind to the /kube-binder ServiceAccount. + kubebinderSubject := []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: export.Namespace, + Name: kuberesources.ServiceAccountName, + }, + } + + errs = append(errs, + r.ensureAggregatingClusteRoleAndClusterRoleBinding(ctx, client, cache, kubebinderSubject), + // We always use cluster-scoped RBACs for exported resources. + r.ensureAggregatedClusterRole(ctx, client, cache, + fmt.Sprintf("kube-binder-resources-%s-%s", export.Namespace, export.Name), + ownerRefs, + resourceRules, + ), + ) + + // Now onto claimed resources RBACs. + + switch r.scope { + case kubebindv1alpha2.ClusterScope: + errs = append(errs, + r.ensureAggregatedClusterRole(ctx, client, cache, + fmt.Sprintf("kube-binder-claims-%s-%s", export.Namespace, export.Name), + ownerRefs, + append(clusterScopedClaimRules, namespaceScopedClaimRules...), + ), + ) + case kubebindv1alpha2.NamespacedScope: + errs = append(errs, + r.ensureAggregatedClusterRole(ctx, client, cache, + fmt.Sprintf("kube-binder-claims-%s-%s", export.Namespace, export.Name), + ownerRefs, + clusterScopedClaimRules, + ), + ) + serviceNamespaces, err := r.listServiceNamespaces(ctx, cache, export.Namespace) + if err != nil { + errs = append(errs, err) + return utilerrors.NewAggregate(errs) + } + for _, serviceNamespace := range serviceNamespaces { + if serviceNamespace.Status.Namespace == "" { + continue + } + errs = append(errs, + r.ensureRoleAndRoleBinding(ctx, client, cache, + serviceNamespace.Status.Namespace, + fmt.Sprintf("kube-binder-claims-%s", export.Name), + kubebinderSubject, + namespaceScopedClaimRules, + ), + ) + } + default: + errs = append(errs, fmt.Errorf("unknown informer scope %q", r.scope)) // This should not happen! + } + + return utilerrors.NewAggregate(errs) +} + +func (r *reconciler) ensureRoleAndRoleBinding(ctx context.Context, client client.Client, cache cache.Cache, namespace, name string, subjects []rbacv1.Subject, rules []rbacv1.PolicyRule) error { + // Ensure the Role exists. + + expectedRole := rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Rules: rules, + } + + role, err := getOrCreate(ctx, + cache, + client, + &expectedRole, + r.getRole, + r.createRole, + ) + if err != nil { + return err + } + + if !reflect.DeepEqual(expectedRole.Rules, role.Rules) { + copyRole := role.DeepCopy() + copyRole.Rules = expectedRole.Rules + if err := r.updateRole(ctx, client, copyRole); err != nil { + return err + } + } + + // And also ensure its RoleBinding exists too. + + expectedRoleBinding := rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "Role", + Name: name, + }, + Subjects: subjects, + } + + roleBinding, err := getOrCreate( + ctx, + cache, + client, + &expectedRoleBinding, + r.getRoleBinding, + r.createRoleBinding, + ) + if err != nil { + return err + } + + if !reflect.DeepEqual(expectedRoleBinding.Subjects, roleBinding.Subjects) { + // We don't check .RoleRef because it's immutable. + copyRoleBinding := roleBinding.DeepCopy() + copyRoleBinding.Subjects = expectedRoleBinding.Subjects + return r.updateRoleBinding(ctx, client, copyRoleBinding) + } + + return nil +} + +func (r *reconciler) ensureAggregatedClusterRole(ctx context.Context, client client.Client, cache cache.Cache, name string, owners []metav1.OwnerReference, rules []rbacv1.PolicyRule) error { + expectedClusterRole := rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + rbacAggregateLabelKey: "true", + }, + OwnerReferences: owners, + }, + Rules: rules, + } + + clusterRole, err := getOrCreate(ctx, + cache, + client, + &expectedClusterRole, + r.getClusterRole, + r.createClusterRole, + ) + if err != nil { + return err + } + + if clusterRole.Labels == nil || clusterRole.Labels[rbacAggregateLabelKey] != "true" || + !reflect.DeepEqual(expectedClusterRole.OwnerReferences, clusterRole.OwnerReferences) || + !reflect.DeepEqual(expectedClusterRole.Rules, clusterRole.Rules) { + // We don't check .RoleRef because it's immutable. + copyClusterRole := clusterRole.DeepCopy() + if copyClusterRole.Labels == nil { + copyClusterRole.Labels = make(map[string]string) + } + copyClusterRole.Labels[rbacAggregateLabelKey] = "true" + copyClusterRole.OwnerReferences = expectedClusterRole.OwnerReferences + copyClusterRole.Rules = expectedClusterRole.Rules + return r.updateClusterRole(ctx, client, copyClusterRole) + } + + return nil +} + +func (r *reconciler) ensureAggregatingClusteRoleAndClusterRoleBinding(ctx context.Context, client client.Client, cache cache.Cache, subjects []rbacv1.Subject) error { + const name = "kube-binder-exports" + + // Ensure the "kube-binder-exports" ClusterRole exists. + + expectedClusterRole := rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + AggregationRule: &rbacv1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + { + MatchLabels: map[string]string{ + rbacAggregateLabelKey: "true", + }, + }, + }, + }, + } + + clusterRole, err := getOrCreate(ctx, + cache, + client, + &expectedClusterRole, + r.getClusterRole, + r.createClusterRole, + ) + if err != nil { + return err + } + + if !reflect.DeepEqual(expectedClusterRole.AggregationRule, clusterRole.AggregationRule) { + copyClusterRole := clusterRole.DeepCopy() + copyClusterRole.AggregationRule = expectedClusterRole.AggregationRule + if err := r.updateClusterRole(ctx, client, copyClusterRole); err != nil { + return err + } + } + + // Now ensure its "kube-binder-exports" ClusterRoleBinding exists. + + expectedClusterRoleBinding := rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "ClusterRole", + Name: name, + }, + Subjects: subjects, + } + + clusterRoleBinding, err := getOrCreate(ctx, + cache, + client, + &expectedClusterRoleBinding, + r.getClusterRoleBinding, + r.createClusterRoleBinding, + ) + if err != nil { + return err + } + + if !reflect.DeepEqual(expectedClusterRoleBinding.Subjects, clusterRoleBinding.Subjects) { + // We don't check .RoleRef because it's immutable. + copyClusterRoleBinding := clusterRoleBinding.DeepCopy() + copyClusterRoleBinding.Subjects = expectedClusterRoleBinding.Subjects + return r.updateClusterRoleBinding(ctx, client, copyClusterRoleBinding) + } + + return nil +} + +func getOrCreate[R client.Object]( + ctx context.Context, + cache cache.Cache, + client client.Client, + expectedObj R, + + get func( + ctx context.Context, + cache cache.Cache, + key types.NamespacedName, + ) (R, error), + + create func( + ctx context.Context, + client client.Client, + obj R, + ) error, + +) (R, error) { + var empty R + obj, err := get(ctx, cache, types.NamespacedName{Name: expectedObj.GetName(), Namespace: expectedObj.GetNamespace()}) + + if err != nil { + if !apierrors.IsNotFound(err) { + return empty, err + } + if err = create(ctx, client, expectedObj); err != nil && !apierrors.IsAlreadyExists(err) { + return empty, err + } + return expectedObj, nil + } + + return obj, nil +} diff --git a/backend/controllers/serviceexportrbac/rbac_reconciler_test.go b/backend/controllers/serviceexportrbac/rbac_reconciler_test.go new file mode 100644 index 000000000..d5cc60fb8 --- /dev/null +++ b/backend/controllers/serviceexportrbac/rbac_reconciler_test.go @@ -0,0 +1,857 @@ +/* +Copyright 2025 The Kube Bind Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package serviceexportrbac + +import ( + "context" + "maps" + "slices" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + + kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" +) + +func Test_reconciler_reconcile(t *testing.T) { + type state struct { + clusterRoles map[string]*rbacv1.ClusterRole + clusterRoleBindings map[string]*rbacv1.ClusterRoleBinding + roles map[string]*rbacv1.Role + roleBindings map[string]*rbacv1.RoleBinding + } + normalizeState := func(st *state) { + if st.clusterRoles == nil { + st.clusterRoles = make(map[string]*rbacv1.ClusterRole) + } + if st.clusterRoleBindings == nil { + st.clusterRoleBindings = make(map[string]*rbacv1.ClusterRoleBinding) + } + if st.clusterRoleBindings == nil { + st.clusterRoleBindings = make(map[string]*rbacv1.ClusterRoleBinding) + } + if st.roles == nil { + st.roles = make(map[string]*rbacv1.Role) + } + if st.roleBindings == nil { + st.roleBindings = make(map[string]*rbacv1.RoleBinding) + } + } + + newReconcilerWithState := func(scope kubebindv1alpha2.InformerScope, namespaces map[string]*corev1.Namespace, apiServiceNamespaces map[types.NamespacedName]*kubebindv1alpha2.APIServiceNamespace, st *state) reconciler { + return reconciler{ + scope: scope, + listServiceNamespaces: func(ctx context.Context, _ cache.Cache, namespace string) ([]*kubebindv1alpha2.APIServiceNamespace, error) { + return slices.Collect(maps.Values(apiServiceNamespaces)), nil + }, + getClusterRole: func(ctx context.Context, _ cache.Cache, key types.NamespacedName) (*rbacv1.ClusterRole, error) { + clusterRole, ok := st.clusterRoles[key.Name] + if !ok { + return nil, apierrors.NewNotFound(rbacv1.Resource("clusterroles"), key.Name) + } + return clusterRole, nil + }, + createClusterRole: func(ctx context.Context, _ client.Client, binding *rbacv1.ClusterRole) error { + _, ok := st.clusterRoles[binding.Name] + if ok { + return apierrors.NewAlreadyExists(rbacv1.Resource("clusterroles"), binding.Name) + } + st.clusterRoles[binding.Name] = binding + return nil + }, + updateClusterRole: func(ctx context.Context, _ client.Client, binding *rbacv1.ClusterRole) error { + _, ok := st.clusterRoles[binding.Name] + if !ok { + return apierrors.NewNotFound(rbacv1.Resource("clusterroles"), binding.Name) + } + st.clusterRoles[binding.Name] = binding + return nil + }, + getRole: func(ctx context.Context, _ cache.Cache, key types.NamespacedName) (*rbacv1.Role, error) { + role, ok := st.roles[key.String()] + if !ok { + return nil, apierrors.NewNotFound(rbacv1.Resource("roles"), key.String()) + } + return role, nil + }, + createRole: func(ctx context.Context, _ client.Client, role *rbacv1.Role) error { + key := types.NamespacedName{Namespace: role.Namespace, Name: role.Name}.String() + _, ok := st.roles[key] + if ok { + return apierrors.NewAlreadyExists(rbacv1.Resource("roles"), key) + } + st.roles[key] = role + return nil + }, + updateRole: func(ctx context.Context, _ client.Client, role *rbacv1.Role) error { + key := types.NamespacedName{Namespace: role.Namespace, Name: role.Name}.String() + _, ok := st.roles[key] + if !ok { + return apierrors.NewNotFound(rbacv1.Resource("roles"), key) + } + st.roles[key] = role + return nil + }, + getClusterRoleBinding: func(ctx context.Context, _ cache.Cache, key types.NamespacedName) (*rbacv1.ClusterRoleBinding, error) { + clusterRoleBinding, ok := st.clusterRoleBindings[key.Name] + if !ok { + return nil, apierrors.NewNotFound(rbacv1.Resource("clusterrolebindings"), key.Name) + } + return clusterRoleBinding, nil + }, + createClusterRoleBinding: func(ctx context.Context, _ client.Client, binding *rbacv1.ClusterRoleBinding) error { + _, ok := st.clusterRoleBindings[binding.Name] + if ok { + return apierrors.NewAlreadyExists(rbacv1.Resource("clusterrolebindings"), binding.Name) + } + st.clusterRoleBindings[binding.Name] = binding + return nil + }, + updateClusterRoleBinding: func(ctx context.Context, _ client.Client, binding *rbacv1.ClusterRoleBinding) error { + _, ok := st.clusterRoleBindings[binding.Name] + if !ok { + return apierrors.NewNotFound(rbacv1.Resource("clusterrolebindings"), binding.Name) + } + st.clusterRoleBindings[binding.Name] = binding + return nil + }, + getRoleBinding: func(ctx context.Context, _ cache.Cache, key types.NamespacedName) (*rbacv1.RoleBinding, error) { + roleBinding, ok := st.roleBindings[key.String()] + if !ok { + return nil, apierrors.NewNotFound(rbacv1.Resource("rolebindings"), key.String()) + } + return roleBinding, nil + }, + createRoleBinding: func(ctx context.Context, _ client.Client, binding *rbacv1.RoleBinding) error { + key := types.NamespacedName{Namespace: binding.Namespace, Name: binding.Name}.String() + _, ok := st.roleBindings[key] + if ok { + return apierrors.NewAlreadyExists(rbacv1.Resource("rolebindings"), key) + } + st.roleBindings[key] = binding + return nil + }, + updateRoleBinding: func(ctx context.Context, _ client.Client, binding *rbacv1.RoleBinding) error { + key := types.NamespacedName{Namespace: binding.Namespace, Name: binding.Name}.String() + _, ok := st.roleBindings[key] + if !ok { + return apierrors.NewNotFound(rbacv1.Resource("rolebindings"), key) + } + st.roleBindings[key] = binding + return nil + }, + getNamespace: func(ctx context.Context, _ cache.Cache, name string) (*corev1.Namespace, error) { + namespace, ok := namespaces[name] + if !ok { + return nil, apierrors.NewNotFound(corev1.Resource("namespaces"), name) + } + return namespace, nil + }, + } + } + + export := &kubebindv1alpha2.APIServiceExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-export", + Namespace: "kube-binder-abcd1234", + }, + Spec: kubebindv1alpha2.APIServiceExportSpec{ + Resources: []kubebindv1alpha2.APIServiceExportResource{ + { + GroupResource: kubebindv1alpha2.GroupResource{ + Group: "wildwest.dev", + Resource: "cowboys", + }, + Versions: []string{"v1alpha1"}, + }, + { + GroupResource: kubebindv1alpha2.GroupResource{ + Group: "wildwest.dev", + Resource: "cowgirls", + }, + Versions: []string{"v1alpha1"}, + }, + }, + PermissionClaims: []kubebindv1alpha2.PermissionClaim{ + { + GroupResource: kubebindv1alpha2.GroupResource{ + Group: "", + Resource: "secrets", + }, + }, + { + GroupResource: kubebindv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + }, + }, + }, + } + + tests := map[string]struct { + scope kubebindv1alpha2.InformerScope + namespaces map[string]*corev1.Namespace + apiServiceNamespaces map[types.NamespacedName]*kubebindv1alpha2.APIServiceNamespace + st state + + expectedState state + expectedErr error + }{ + "cluster scoped with no pre-existing RBACs": { + scope: kubebindv1alpha2.ClusterScope, + namespaces: map[string]*corev1.Namespace{ + "kube-binder-abcd1234": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-abcd1234", + UID: "uid-123", + }, + }, + }, + apiServiceNamespaces: map[types.NamespacedName]*kubebindv1alpha2.APIServiceNamespace{ + {Namespace: "kube-binder-abcd1234", Name: "consumer-ns-1"}: { + ObjectMeta: metav1.ObjectMeta{ + Name: "consumer-ns-1", + Namespace: "kube-binder-abcd1234", + }, + Status: kubebindv1alpha2.APIServiceNamespaceStatus{ + Namespace: "kube-binder-abcd1234-consumer-ns-1", + }, + }, + }, + st: state{}, + expectedState: state{ + clusterRoles: map[string]*rbacv1.ClusterRole{ + "kube-binder-exports": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-exports", + }, + AggregationRule: &rbacv1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + { + MatchLabels: map[string]string{ + "rbac.kube-bind.io/aggregate-to-parent": "true", + }, + }, + }, + }, + }, + "kube-binder-resources-kube-binder-abcd1234-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-resources-kube-binder-abcd1234-my-export", + Labels: map[string]string{ + "rbac.kube-bind.io/aggregate-to-parent": "true", + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Namespace", + Name: "kube-binder-abcd1234", + UID: "uid-123", + }, + }, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"wildwest.dev"}, + Resources: []string{"cowboys"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + { + APIGroups: []string{"wildwest.dev"}, + Resources: []string{"cowgirls"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + }, + }, + "kube-binder-claims-kube-binder-abcd1234-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-claims-kube-binder-abcd1234-my-export", + Labels: map[string]string{ + "rbac.kube-bind.io/aggregate-to-parent": "true", + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Namespace", + Name: "kube-binder-abcd1234", + UID: "uid-123", + }, + }, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + }, + }, + }, + clusterRoleBindings: map[string]*rbacv1.ClusterRoleBinding{ + "kube-binder-exports": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-exports", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "ClusterRole", + Name: "kube-binder-exports", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "kube-binder-abcd1234", + Name: "kube-binder", + }, + }, + }, + }, + }, + }, + "cluster scoped with wrong pre-existing RBACs": { + scope: kubebindv1alpha2.ClusterScope, + namespaces: map[string]*corev1.Namespace{ + "kube-binder-abcd1234": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-abcd1234", + UID: "uid-123", + }, + }, + }, + apiServiceNamespaces: map[types.NamespacedName]*kubebindv1alpha2.APIServiceNamespace{ + {Namespace: "kube-binder-abcd1234", Name: "consumer-ns-1"}: { + ObjectMeta: metav1.ObjectMeta{ + Name: "consumer-ns-1", + Namespace: "kube-binder-abcd1234", + }, + Status: kubebindv1alpha2.APIServiceNamespaceStatus{ + Namespace: "kube-binder-abcd1234-consumer-ns-1", + }, + }, + }, + st: state{ + clusterRoles: map[string]*rbacv1.ClusterRole{ + "kube-binder-exports": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-exports", + }, + }, + "kube-binder-resources-kube-binder-abcd1234-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-resources-kube-binder-abcd1234-my-export", + }, + }, + "kube-binder-claims-kube-binder-abcd1234-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-claims-kube-binder-abcd1234-my-export", + }, + }, + }, + clusterRoleBindings: map[string]*rbacv1.ClusterRoleBinding{ + "kube-binder-exports": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-exports", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "ClusterRole", + Name: "kube-binder-exports", + }, + }, + }, + }, + expectedState: state{ + clusterRoles: map[string]*rbacv1.ClusterRole{ + "kube-binder-exports": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-exports", + }, + AggregationRule: &rbacv1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + { + MatchLabels: map[string]string{ + "rbac.kube-bind.io/aggregate-to-parent": "true", + }, + }, + }, + }, + }, + "kube-binder-resources-kube-binder-abcd1234-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-resources-kube-binder-abcd1234-my-export", + Labels: map[string]string{ + "rbac.kube-bind.io/aggregate-to-parent": "true", + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Namespace", + Name: "kube-binder-abcd1234", + UID: "uid-123", + }, + }, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"wildwest.dev"}, + Resources: []string{"cowboys"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + { + APIGroups: []string{"wildwest.dev"}, + Resources: []string{"cowgirls"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + }, + }, + "kube-binder-claims-kube-binder-abcd1234-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-claims-kube-binder-abcd1234-my-export", + Labels: map[string]string{ + "rbac.kube-bind.io/aggregate-to-parent": "true", + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Namespace", + Name: "kube-binder-abcd1234", + UID: "uid-123", + }, + }, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + }, + }, + }, + clusterRoleBindings: map[string]*rbacv1.ClusterRoleBinding{ + "kube-binder-exports": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-exports", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "ClusterRole", + Name: "kube-binder-exports", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "kube-binder-abcd1234", + Name: "kube-binder", + }, + }, + }, + }, + }, + }, + "namespaced scoped with no pre-existing RBACs": { + scope: kubebindv1alpha2.NamespacedScope, + namespaces: map[string]*corev1.Namespace{ + "kube-binder-abcd1234": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-abcd1234", + UID: "uid-123", + }, + }, + }, + apiServiceNamespaces: map[types.NamespacedName]*kubebindv1alpha2.APIServiceNamespace{ + {Namespace: "kube-binder-abcd1234", Name: "consumer-ns-1"}: { + ObjectMeta: metav1.ObjectMeta{ + Name: "consumer-ns-1", + Namespace: "kube-binder-abcd1234", + }, + Status: kubebindv1alpha2.APIServiceNamespaceStatus{ + Namespace: "kube-binder-abcd1234-consumer-ns-1", + }, + }, + }, + st: state{}, + expectedState: state{ + clusterRoles: map[string]*rbacv1.ClusterRole{ + "kube-binder-exports": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-exports", + }, + AggregationRule: &rbacv1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + { + MatchLabels: map[string]string{ + "rbac.kube-bind.io/aggregate-to-parent": "true", + }, + }, + }, + }, + }, + "kube-binder-resources-kube-binder-abcd1234-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-resources-kube-binder-abcd1234-my-export", + Labels: map[string]string{ + "rbac.kube-bind.io/aggregate-to-parent": "true", + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Namespace", + Name: "kube-binder-abcd1234", + UID: "uid-123", + }, + }, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"wildwest.dev"}, + Resources: []string{"cowboys"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + { + APIGroups: []string{"wildwest.dev"}, + Resources: []string{"cowgirls"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + }, + }, + "kube-binder-claims-kube-binder-abcd1234-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-claims-kube-binder-abcd1234-my-export", + Labels: map[string]string{ + "rbac.kube-bind.io/aggregate-to-parent": "true", + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Namespace", + Name: "kube-binder-abcd1234", + UID: "uid-123", + }, + }, + }, + Rules: []rbacv1.PolicyRule{}, + }, + }, + clusterRoleBindings: map[string]*rbacv1.ClusterRoleBinding{ + "kube-binder-exports": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-exports", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "ClusterRole", + Name: "kube-binder-exports", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "kube-binder-abcd1234", + Name: "kube-binder", + }, + }, + }, + }, + roles: map[string]*rbacv1.Role{ + "kube-binder-abcd1234-consumer-ns-1/kube-binder-claims-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-claims-my-export", + Namespace: "kube-binder-abcd1234-consumer-ns-1", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + }, + }, + }, + roleBindings: map[string]*rbacv1.RoleBinding{ + "kube-binder-abcd1234-consumer-ns-1/kube-binder-claims-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-claims-my-export", + Namespace: "kube-binder-abcd1234-consumer-ns-1", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "Role", + Name: "kube-binder-claims-my-export", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "kube-binder-abcd1234", + Name: "kube-binder", + }, + }, + }, + }, + }, + }, + "namespaced scoped with wrong pre-existing RBACs": { + scope: kubebindv1alpha2.NamespacedScope, + namespaces: map[string]*corev1.Namespace{ + "kube-binder-abcd1234": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-abcd1234", + UID: "uid-123", + }, + }, + }, + apiServiceNamespaces: map[types.NamespacedName]*kubebindv1alpha2.APIServiceNamespace{ + {Namespace: "kube-binder-abcd1234", Name: "consumer-ns-1"}: { + ObjectMeta: metav1.ObjectMeta{ + Name: "consumer-ns-1", + Namespace: "kube-binder-abcd1234", + }, + Status: kubebindv1alpha2.APIServiceNamespaceStatus{ + Namespace: "kube-binder-abcd1234-consumer-ns-1", + }, + }, + }, + st: state{ + clusterRoles: map[string]*rbacv1.ClusterRole{ + "kube-binder-exports": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-exports", + }, + }, + "kube-binder-resources-kube-binder-abcd1234-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-resources-kube-binder-abcd1234-my-export", + }, + }, + "kube-binder-claims-kube-binder-abcd1234-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-claims-kube-binder-abcd1234-my-export", + }, + Rules: []rbacv1.PolicyRule{}, + }, + }, + clusterRoleBindings: map[string]*rbacv1.ClusterRoleBinding{ + "kube-binder-exports": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-exports", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "ClusterRole", + Name: "kube-binder-exports", + }, + }, + }, + roles: map[string]*rbacv1.Role{ + "kube-binder-abcd1234-consumer-ns-1/kube-binder-claims-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-claims-my-export", + Namespace: "kube-binder-abcd1234-consumer-ns-1", + }, + }, + }, + roleBindings: map[string]*rbacv1.RoleBinding{ + "kube-binder-abcd1234-consumer-ns-1/kube-binder-claims-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-claims-my-export", + Namespace: "kube-binder-abcd1234-consumer-ns-1", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "Role", + Name: "kube-binder-claims-my-export", + }, + }, + }, + }, + expectedState: state{ + clusterRoles: map[string]*rbacv1.ClusterRole{ + "kube-binder-exports": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-exports", + }, + AggregationRule: &rbacv1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + { + MatchLabels: map[string]string{ + "rbac.kube-bind.io/aggregate-to-parent": "true", + }, + }, + }, + }, + }, + "kube-binder-resources-kube-binder-abcd1234-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-resources-kube-binder-abcd1234-my-export", + Labels: map[string]string{ + "rbac.kube-bind.io/aggregate-to-parent": "true", + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Namespace", + Name: "kube-binder-abcd1234", + UID: "uid-123", + }, + }, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"wildwest.dev"}, + Resources: []string{"cowboys"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + { + APIGroups: []string{"wildwest.dev"}, + Resources: []string{"cowgirls"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + }, + }, + "kube-binder-claims-kube-binder-abcd1234-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-claims-kube-binder-abcd1234-my-export", + Labels: map[string]string{ + "rbac.kube-bind.io/aggregate-to-parent": "true", + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Namespace", + Name: "kube-binder-abcd1234", + UID: "uid-123", + }, + }, + }, + Rules: []rbacv1.PolicyRule{}, + }, + }, + clusterRoleBindings: map[string]*rbacv1.ClusterRoleBinding{ + "kube-binder-exports": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-exports", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "ClusterRole", + Name: "kube-binder-exports", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "kube-binder-abcd1234", + Name: "kube-binder", + }, + }, + }, + }, + roles: map[string]*rbacv1.Role{ + "kube-binder-abcd1234-consumer-ns-1/kube-binder-claims-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-claims-my-export", + Namespace: "kube-binder-abcd1234-consumer-ns-1", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + }, + }, + }, + roleBindings: map[string]*rbacv1.RoleBinding{ + "kube-binder-abcd1234-consumer-ns-1/kube-binder-claims-my-export": { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder-claims-my-export", + Namespace: "kube-binder-abcd1234-consumer-ns-1", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "Role", + Name: "kube-binder-claims-my-export", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "kube-binder-abcd1234", + Name: "kube-binder", + }, + }, + }, + }, + }, + }, + } + + for tname, tt := range tests { + t.Run(tname, func(t *testing.T) { + r := newReconcilerWithState(tt.scope, tt.namespaces, tt.apiServiceNamespaces, &tt.st) + + normalizeState(&tt.st) + normalizeState(&tt.expectedState) + + err := r.reconcile(context.Background(), nil, nil, export) + + if tt.expectedErr != nil { + require.EqualError(t, err, tt.expectedErr.Error(), "reconcile should have failed") + } else { + require.NoError(t, err, "reconcile should have succeeded") + } + + compareMap(t, "ClusterRole", tt.expectedState.clusterRoles, tt.st.clusterRoles) + compareMap(t, "ClusterRoleBinding", tt.expectedState.clusterRoleBindings, tt.st.clusterRoleBindings) + compareMap(t, "Role", tt.expectedState.roles, tt.st.roles) + compareMap(t, "RoleBinding", tt.expectedState.roleBindings, tt.st.roleBindings) + }) + } +} + +func compareMap[V any](t *testing.T, kind string, a, b map[string]V) { + t.Helper() + require.Equal(t, slices.Sorted(maps.Keys(a)), slices.Sorted(maps.Keys(b)), "got unexpected entries after %s reconciliation", kind) + for k := range a { + require.Equal(t, a[k], b[k], "got unexpected content in %s %s", k, kind) + } +} diff --git a/backend/controllers/servicenamespace/servicenamespace_reconcile.go b/backend/controllers/servicenamespace/servicenamespace_reconcile.go index aab88b066..b5b058720 100644 --- a/backend/controllers/servicenamespace/servicenamespace_reconcile.go +++ b/backend/controllers/servicenamespace/servicenamespace_reconcile.go @@ -19,22 +19,14 @@ package servicenamespace import ( "context" "fmt" - "reflect" - "strings" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/sets" - "k8s.io/klog/v2" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" - kuberesources "github.com/kube-bind/kube-bind/backend/kubernetes/resources" kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" ) @@ -81,327 +73,9 @@ func (c *reconciler) reconcile(ctx context.Context, client client.Client, cache } } - if c.scope == kubebindv1alpha2.NamespacedScope { - if err := c.ensureRBACRoleBinding(ctx, client, cache, nsName, sns); err != nil { - return fmt.Errorf("failed to ensure RBAC: %w", err) - } - } - if sns.Status.Namespace != nsName { sns.Status.Namespace = nsName } - // List APIServiceExports in the namespace - apiServiceExports, err := c.listAPIServiceExports(ctx, cache, sns.Namespace) - if err != nil { - return fmt.Errorf("failed to list APIServiceExports: %w", err) - } - - for _, export := range apiServiceExports.Items { - name := fmt.Sprintf("kube-binder-export-%s-%s", sns.Name, export.Name) // per-sns unique name - permissions := []rbacv1.PolicyRule{} - for _, claim := range export.Spec.PermissionClaims { - permissions = append(permissions, rbacv1.PolicyRule{ - APIGroups: []string{claim.Group}, - Resources: []string{claim.Resource}, - // We need list and watch for informers to be able to start. And create to create initial object. - Verbs: []string{"*"}, - }) - } - if c.scope == kubebindv1alpha2.ClusterScope { - role, err := c.getPermissionClaimsClusterRole(ctx, cache, name) - if err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to get ClusterRole %s: %w", name, err) - } - if role == nil { - role = &rbacv1.ClusterRole{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - }, - Rules: permissions, - } - // Create new ClusterRole - if err := client.Create(ctx, role); err != nil && !errors.IsAlreadyExists(err) { - return fmt.Errorf("failed to create ClusterRole %s: %w", name, err) - } - } else { - role.Rules = permissions - if err := client.Update(ctx, role); err != nil { - return fmt.Errorf("failed to update ClusterRole %s: %w", name, err) - } - } - - clusterBinding, err := c.getPermissionClaimsClusterRoleBinding(ctx, cache, name) - if err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to get ClusterRoleBinding %s: %w", name, err) - } - if clusterBinding == nil { - clusterBinding = &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Namespace: sns.Namespace, - Name: kuberesources.ServiceAccountName, - }, - }, - RoleRef: rbacv1.RoleRef{ - Kind: "ClusterRole", - Name: name, - APIGroup: "rbac.authorization.k8s.io", - }, - } - if err := client.Create(ctx, clusterBinding); err != nil { - return fmt.Errorf("failed to create ClusterRoleBinding %s: %w", name, err) - } - } else { - expectedSubjects := []rbacv1.Subject{{ - Kind: "ServiceAccount", - Namespace: sns.Namespace, - Name: kuberesources.ServiceAccountName, - }} - expectedRef := rbacv1.RoleRef{Kind: "ClusterRole", Name: name, APIGroup: "rbac.authorization.k8s.io"} - if !reflect.DeepEqual(clusterBinding.Subjects, expectedSubjects) || !reflect.DeepEqual(clusterBinding.RoleRef, expectedRef) { - rb := clusterBinding.DeepCopy() - rb.Subjects = expectedSubjects - rb.RoleRef = expectedRef - if err := client.Update(ctx, rb); err != nil { - return fmt.Errorf("failed to update ClusterRoleBinding %s: %w", name, err) - } - } - } - } else { - role, err := c.getPermissionClaimsRole(ctx, cache, sns.Status.Namespace, name) - if err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to get Role %s: %w", name, err) - } - if role == nil { - role := &rbacv1.Role{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: sns.Status.Namespace, - }, - Rules: permissions, - } - // Create new Role - if err := client.Create(ctx, role); err != nil { - return fmt.Errorf("failed to create Role %s: %w", name, err) - } - } else { - role.Rules = permissions - if err := client.Update(ctx, role); err != nil { - return fmt.Errorf("failed to update Role %s: %w", name, err) - } - } - - rolebinding, err := c.getPermissionClaimsRoleBinding(ctx, cache, sns.Status.Namespace, name) - if err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to get RoleBinding %s: %w", name, err) - } - - if rolebinding == nil { - rolebinding := &rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: sns.Status.Namespace, - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Namespace: sns.Namespace, - Name: kuberesources.ServiceAccountName, - }, - }, - RoleRef: rbacv1.RoleRef{ - Kind: "Role", - Name: name, - APIGroup: "rbac.authorization.k8s.io", - }, - } - if err := client.Create(ctx, rolebinding); err != nil { - return fmt.Errorf("failed to create RoleBinding %s: %w", name, err) - } - } else { - expectedSubjects := []rbacv1.Subject{{ - Kind: "ServiceAccount", - Namespace: sns.Namespace, - Name: kuberesources.ServiceAccountName, - }} - if !reflect.DeepEqual(rolebinding.Subjects, expectedSubjects) || rolebinding.RoleRef.Kind != "Role" || rolebinding.RoleRef.Name != name || rolebinding.RoleRef.APIGroup != "rbac.authorization.k8s.io" { - rb := rolebinding.DeepCopy() - rb.Subjects = expectedSubjects - rb.RoleRef = rbacv1.RoleRef{Kind: "Role", Name: name, APIGroup: "rbac.authorization.k8s.io"} - if err := client.Update(ctx, rb); err != nil { - return fmt.Errorf("failed to update RoleBinding %s: %w", name, err) - } - } - } - } - } - - if err := c.cleanupRBACResources(ctx, client, cache, sns, apiServiceExports); err != nil { - return fmt.Errorf("failed to cleanup RBAC resources: %w", err) - } - - return nil -} - -func (c *reconciler) ensureRBACRoleBinding(ctx context.Context, client client.Client, cache cache.Cache, ns string, sns *kubebindv1alpha2.APIServiceNamespace) error { - objName := "kube-binder" - binding, err := c.getRoleBinding(ctx, cache, ns, objName) - if err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("failed to get role binding %s/%s: %w", ns, objName, err) - } - - expected := &rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: objName, - Namespace: ns, - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Namespace: sns.Namespace, - Name: kuberesources.ServiceAccountName, - }, - }, - RoleRef: rbacv1.RoleRef{ - Kind: "ClusterRole", - Name: "kube-binder-" + sns.Namespace, - APIGroup: "rbac.authorization.k8s.io", - }, - } - - if binding == nil { - if err := c.createRoleBinding(ctx, client, expected); err != nil { - return fmt.Errorf("failed to create role binding %s/%s: %w", ns, objName, err) - } - } else if !reflect.DeepEqual(binding.Subjects, expected.Subjects) || !reflect.DeepEqual(binding.RoleRef, expected.RoleRef) { - binding = binding.DeepCopy() - binding.Subjects = expected.Subjects - binding.RoleRef = expected.RoleRef - if err := c.updateRoleBinding(ctx, client, binding); err != nil { - return fmt.Errorf("failed to create role binding %s/%s: %w", ns, objName, err) - } - } - - return nil -} - -func (c *reconciler) getPermissionClaimsClusterRole(ctx context.Context, cache cache.Cache, name string) (*rbacv1.ClusterRole, error) { - var role rbacv1.ClusterRole - key := types.NamespacedName{Name: name} - if err := cache.Get(ctx, key, &role); err != nil { - return nil, err - } - return &role, nil -} - -func (c *reconciler) getPermissionClaimsRole(ctx context.Context, cache cache.Cache, namespace, name string) (*rbacv1.Role, error) { - var role rbacv1.Role - key := types.NamespacedName{Namespace: namespace, Name: name} - if err := cache.Get(ctx, key, &role); err != nil { - return nil, err - } - return &role, nil -} - -func (c *reconciler) getPermissionClaimsClusterRoleBinding(ctx context.Context, cache cache.Cache, name string) (*rbacv1.ClusterRoleBinding, error) { - var roleBinding rbacv1.ClusterRoleBinding - key := types.NamespacedName{Name: name} - if err := cache.Get(ctx, key, &roleBinding); err != nil { - return nil, err - } - return &roleBinding, nil -} - -func (c *reconciler) getPermissionClaimsRoleBinding(ctx context.Context, cache cache.Cache, namespace, name string) (*rbacv1.RoleBinding, error) { - var roleBinding rbacv1.RoleBinding - key := types.NamespacedName{Name: name, Namespace: namespace} - if err := cache.Get(ctx, key, &roleBinding); err != nil { - return nil, err - } - return &roleBinding, nil -} - -func (c *reconciler) listAPIServiceExports(ctx context.Context, cache cache.Cache, namespace string) (*kubebindv1alpha2.APIServiceExportList, error) { - exports := &kubebindv1alpha2.APIServiceExportList{} - if err := cache.List(ctx, exports, client.InNamespace(namespace)); err != nil { - return nil, err - } - return exports, nil -} - -func (c *reconciler) cleanupRBACResources( - ctx context.Context, - client client.Client, - cache cache.Cache, - sns *kubebindv1alpha2.APIServiceNamespace, - apiServiceExports *kubebindv1alpha2.APIServiceExportList, -) error { - expected := sets.New[string]() - - prefix := fmt.Sprintf("kube-binder-export-%s-", sns.Name) - for _, export := range apiServiceExports.Items { - name := prefix + export.Name - expected.Insert(name) - } - - if c.scope == kubebindv1alpha2.ClusterScope { - if err := cleanup(ctx, cache, client, &rbacv1.ClusterRoleList{}, "ClusterRole", "", expected, prefix); err != nil { - return err - } - - return cleanup(ctx, cache, client, &rbacv1.ClusterRoleBindingList{}, "ClusterRoleBinding", "", expected, prefix) - } - - if sns.Status.Namespace != "" { - ns := sns.Status.Namespace - if err := cleanup(ctx, cache, client, &rbacv1.RoleList{}, "Role", ns, expected, prefix); err != nil { - return err - } - if err := cleanup(ctx, cache, client, &rbacv1.RoleBindingList{}, "RoleBinding", ns, expected, prefix); err != nil { - return err - } - } - return nil } - -func cleanup( - ctx context.Context, - cache cache.Cache, - cl client.Client, - list client.ObjectList, - kind, ns string, - expected sets.Set[string], - prefix string, -) error { - logger := klog.FromContext(ctx) - opts := []client.ListOption{} - if ns != "" { - opts = append(opts, client.InNamespace(ns)) - } - - if err := cache.List(ctx, list, opts...); err != nil { - logger.V(1).Info(fmt.Sprintf("Failed to list %s for cleanup", kind), "namespace", ns, "error", err) - return err - } - - return meta.EachListItem(list, func(obj runtime.Object) error { - accessor, _ := meta.Accessor(obj) - name := accessor.GetName() - if strings.HasPrefix(name, prefix) && !expected.Has(name) { - logger.V(1).Info(fmt.Sprintf("Deleting orphaned %s", kind), - "name", name, "namespace", ns) - if err := cl.Delete(ctx, obj.(client.Object)); err != nil { - if !errors.IsNotFound(err) { - return fmt.Errorf("failed to delete %s %s/%s: %w", kind, ns, name, err) - } - } - } - return nil - }) -} diff --git a/backend/kubernetes/manager.go b/backend/kubernetes/manager.go index a60d7ccc3..5a4e79770 100644 --- a/backend/kubernetes/manager.go +++ b/backend/kubernetes/manager.go @@ -150,6 +150,10 @@ func (m *Manager) HandleResources( return nil, err } + if err = kuberesources.EnsureBinderRoleBinding(ctx, c, ns, sa.Name); err != nil { + return nil, err + } + kfgSecret, err := kuberesources.GenerateKubeconfig(ctx, c, cl.GetConfig(), m.externalAddressGenerator, m.externalCA, m.externalTLSServerName, saSecret.Name, ns, kubeconfigSecretName) if err != nil { return nil, err diff --git a/backend/kubernetes/resources/rbac.go b/backend/kubernetes/resources/rbac.go index 6e140fe63..9778e7172 100644 --- a/backend/kubernetes/resources/rbac.go +++ b/backend/kubernetes/resources/rbac.go @@ -18,6 +18,7 @@ package resources import ( "context" + "reflect" "slices" corev1 "k8s.io/api/core/v1" @@ -138,6 +139,52 @@ func EnsureBinderClusterRole(ctx context.Context, client client.Client) error { return nil } +// EnsureBinderRoleBinding ensures that the binder cluster role is bound in the cluster. This runs multiple times on bind. +func EnsureBinderRoleBinding(ctx context.Context, client client.Client, namespace, saName string) error { + logger := klog.FromContext(ctx) + + expectedRoleBinding := rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-binder", + Namespace: namespace, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "ClusterRole", + Name: "kube-binder", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: namespace, + Name: saName, + }, + }, + } + + var roleBinding rbacv1.RoleBinding + err := client.Get(ctx, types.NamespacedName{Namespace: expectedRoleBinding.Namespace, Name: expectedRoleBinding.Name}, &roleBinding) + if err != nil { + if !errors.IsNotFound(err) { + return err + } + logger.Info("Creating kube-binder RoleBinding", "namespace", namespace) + err = client.Create(ctx, &expectedRoleBinding) + if !errors.IsAlreadyExists(err) { + return err + } + roleBinding = expectedRoleBinding + } + + if !reflect.DeepEqual(expectedRoleBinding.Subjects, roleBinding.Subjects) { + roleBinding.Subjects = expectedRoleBinding.Subjects + return client.Update(ctx, &roleBinding) + } + + logger.V(2).Info("kube-binder RoleBinding already exists and is up to date", "namespace", namespace) + return nil +} + // rulesEqual compares two PolicyRule slices for equality. func rulesEqual(a, b []rbacv1.PolicyRule) bool { if len(a) != len(b) { diff --git a/backend/server.go b/backend/server.go index 02f3353e1..0525be2fa 100644 --- a/backend/server.go +++ b/backend/server.go @@ -33,6 +33,7 @@ import ( "github.com/kube-bind/kube-bind/backend/controllers/cluster" "github.com/kube-bind/kube-bind/backend/controllers/clusterbinding" "github.com/kube-bind/kube-bind/backend/controllers/serviceexport" + "github.com/kube-bind/kube-bind/backend/controllers/serviceexportrbac" "github.com/kube-bind/kube-bind/backend/controllers/serviceexportrequest" "github.com/kube-bind/kube-bind/backend/controllers/servicenamespace" http "github.com/kube-bind/kube-bind/backend/http" @@ -53,6 +54,7 @@ type Server struct { } type Controllers struct { + ServiceExportRBAC *serviceexportrbac.APIServiceExportRBACReconciler ClusterBinding *clusterbinding.ClusterBindingReconciler ServiceExport *serviceexport.APIServiceExportReconciler ServiceExportRequest *serviceexportrequest.APIServiceExportRequestReconciler @@ -166,6 +168,20 @@ func NewServer(ctx context.Context, c *Config) (*Server, error) { return nil, fmt.Errorf("error setting up ClusterBinding controller with manager: %w", err) } + s.ServiceExportRBAC, err = serviceexportrbac.NewAPIServiceExportRBACReconciler( + ctx, + s.Config.Manager, + kubebindv1alpha2.InformerScope(c.Options.ConsumerScope), + opts, + ) + if err != nil { + return nil, fmt.Errorf("error setting up RBAC Controller: %w", err) + } + // Register the APIServiceExport RBAC controller with the manager + if err := s.ServiceExportRBAC.SetupWithManager(s.Config.Manager); err != nil { + return nil, fmt.Errorf("error setting up RBAC controller with manager: %w", err) + } + // construct APIServiceExport controller with multicluster-runtime s.ServiceExport, err = serviceexport.NewAPIServiceExportReconciler( ctx, diff --git a/docs/content/developers/architecture.md b/docs/content/developers/architecture.md index 550259ea0..dc9c62ea0 100644 --- a/docs/content/developers/architecture.md +++ b/docs/content/developers/architecture.md @@ -278,6 +278,41 @@ Running with 2 consumers validates: - For cluster-scoped resources with IsolationPrefixed, resources are name-prefixed - For cluster-scoped resources with IsolationNamespaced, provider CRD is toggled to NamespaceScoped +## RBAC Infrastructure + +### Exported resources + +Exported resources always use cluster-scoped RBACs: + +- Aggregating `kube-binder-exports` ClusterRole + - Bound with `kube-binder-exports` ClusterRoleBinding to `/kube-binder` ServiceAccount + +This aggregating ClusterRole includes all subsequent ClusterRoles that are created for each APIServiceExport. + +- `kube-binder--` ClusterRole for each APIServiceExport + - Each resource in the APIServiceExport is granted `"get", "list", "watch", "create", "update", "patch", "delete"` set of verbs. + - Aggregates into `kube-binder-exports` ClusterRole. + +### Claimed resources + +Claimed resources use namespace- or cluster-scoped RBACs depending on the resource scope of the claimed resource itself, as well as backend's informer scope. + +For cluster-scoped informers: + +**Scope of the claimed resource** | **Informer scope** | **What happens** +--- | --- | --- +cluster | cluster | Creates ClusterRole & ClusterRoleBinding +namespace | cluster | Creates ClusterRole & ClusterRoleBinding + +For namespace-scoped informers: + +**Scope of the claimed resource** | **Informer scope** | **What happens** +--- | --- | --- +cluster | namespace | Creates ClusterRole & ClusterRoleBinding +namespace | namespace | Creates Role & RoleBinding + +Regardless of the configuration, claimed resources are granted `"get", "list", "watch", "create", "update", "patch", "delete"` set of verbs. + ## Implementation Details ### Code Structure diff --git a/test/e2e/bind/happy-case_test.go b/test/e2e/bind/happy-case_test.go index 5be8dcc03..64106f3a2 100644 --- a/test/e2e/bind/happy-case_test.go +++ b/test/e2e/bind/happy-case_test.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "path" + "slices" "strings" "testing" "time" @@ -477,89 +478,104 @@ func testHappyCase( _, err := providerCoreClient.Namespaces().Get(ctx, actualProviderNamespace, metav1.GetOptions{}) require.NoError(t, err, "Actual provider side namespace object should exist") + expectedVerbs := []string{"get", "list", "watch", "create", "update", "patch", "delete"} + slices.Sort(expectedVerbs) + switch informerScope { case kubebindv1alpha2.ClusterScope: t.Logf("Verifying RBAC resources were created for secret management in cluster scope") rbacClient := framework.KubeClient(t, providerConfig).RbacV1() - clusterRoles, err := rbacClient.ClusterRoles().List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - - var foundSecretClusterRole bool - for _, cr := range clusterRoles.Items { - if strings.Contains(cr.Name, "kube-binder-export") { - for _, rule := range cr.Rules { - for _, resource := range rule.Resources { - if resource == "secrets" { - foundSecretClusterRole = true - require.Contains(t, rule.Verbs, "*", "ClusterRole should have * permissions for secrets") - require.Contains(t, rule.APIGroups, "", "ClusterRole should target core API group") - break - } + require.Eventually(t, func() bool { + clusterRoles, err := rbacClient.ClusterRoles().List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + for _, clusterRole := range clusterRoles.Items { + if !strings.HasPrefix(clusterRole.Name, "kube-binder-claims-") { + continue + } + for _, rule := range clusterRole.Rules { + if !slices.Contains(rule.Resources, "secrets") || !slices.Contains(rule.APIGroups, "") { + continue + } + slices.Sort(rule.Verbs) + if slices.Equal(expectedVerbs, rule.Verbs) { + return true } } } - } - require.True(t, foundSecretClusterRole, "ClusterRole for secrets should be created") + return false + }, wait.ForeverTestTimeout, time.Millisecond*100, "waiting for ClusterRole for claimed secrets resource to be ready on provider side") - t.Logf("Verifying ClusterRoleBinding was created for pre-seeded namespace secret access") - clusterRoleBindings, err := rbacClient.ClusterRoleBindings().List(ctx, metav1.ListOptions{}) - require.NoError(t, err) + t.Logf("Verifying ClusterRole for secrets claim has been aggregated into kube-binder-exports ClusterRole") - var foundSecretClusterRoleBinding bool - for _, crb := range clusterRoleBindings.Items { - if strings.Contains(crb.Name, "kube-binder-export") { - for _, subject := range crb.Subjects { - if subject.Kind == "ServiceAccount" && subject.Name == kuberesources.ServiceAccountName { - foundSecretClusterRoleBinding = true - require.Equal(t, "ClusterRole", crb.RoleRef.Kind, "Should reference ClusterRole") - break - } + require.Eventually(t, func() bool { + aggregatingClusterRole, err := rbacClient.ClusterRoles().Get(ctx, "kube-binder-exports", metav1.GetOptions{}) + require.NoError(t, err) // kube-binder-exports must already exist: it is created before kube-binder-claims-* ClusterRole. + for _, rule := range aggregatingClusterRole.Rules { + if !slices.Contains(rule.Resources, "secrets") || !slices.Contains(rule.APIGroups, "") { + continue + } + slices.Sort(rule.Verbs) + if slices.Equal(expectedVerbs, rule.Verbs) { + return true } } - } - require.True(t, foundSecretClusterRoleBinding, "ClusterRoleBinding for ServiceAccount should be created") + return false + }, wait.ForeverTestTimeout, time.Millisecond*100, "waiting for ClusterRoleBinding to be ready on provider side") + + t.Logf("Verifying ClusterRoleBinding for kube-binder-exports ClusterRole was created") + + require.Eventually(t, func() bool { + aggregatingCRB, err := rbacClient.ClusterRoleBindings().Get(ctx, "kube-binder-exports", metav1.GetOptions{}) + require.NoError(t, err) + for _, subject := range aggregatingCRB.Subjects { + if subject.Kind == "ServiceAccount" && subject.Name == kuberesources.ServiceAccountName { + return true + } + } + return false + }, wait.ForeverTestTimeout, time.Millisecond*100, "waiting for ClusterRoleBinding kube-binder-exports to be ready on provider side") case kubebindv1alpha2.NamespacedScope: t.Logf("Verifying RBAC resources were created for secret management in namespace scope") rbacClient := framework.KubeClient(t, providerConfig).RbacV1() - roles, err := rbacClient.Roles(consumer.providerObjectNamespace).List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - - var foundSecretRole bool - for _, cr := range roles.Items { - if strings.Contains(cr.Name, "kube-binder-export") { + require.Eventually(t, func() bool { + roles, err := rbacClient.Roles(consumer.providerObjectNamespace).List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + for _, cr := range roles.Items { + if !strings.HasPrefix(cr.Name, "kube-binder-claims-") { + continue + } for _, rule := range cr.Rules { - for _, resource := range rule.Resources { - if resource == "secrets" { - foundSecretRole = true - require.Contains(t, rule.Verbs, "*", "Role should have * permissions for secrets") - require.Contains(t, rule.APIGroups, "", "Role should target core API group") - break - } + if !slices.Contains(rule.Resources, "secrets") || !slices.Contains(rule.APIGroups, "") { + continue + } + slices.Sort(rule.Verbs) + if slices.Equal(expectedVerbs, rule.Verbs) { + return true } } } - } - require.True(t, foundSecretRole, "Role for secrets should be created") + return false + }, wait.ForeverTestTimeout, time.Millisecond*100, "waiting for RoleBinding for claimed secrets resource to be ready on provider side") t.Logf("Verifying RoleBinding was created for pre-seeded namespace secret access") - roleBindings, err := rbacClient.RoleBindings(consumer.providerObjectNamespace).List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - var foundSecretRoleBinding bool - for _, crb := range roleBindings.Items { - if strings.Contains(crb.Name, "kube-binder-") && strings.Contains(crb.Name, "-export-") { - for _, subject := range crb.Subjects { + require.Eventually(t, func() bool { + roleBindings, err := rbacClient.RoleBindings(consumer.providerObjectNamespace).List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + for _, rb := range roleBindings.Items { + if !strings.HasPrefix(rb.Name, "kube-binder-claims-") { + continue + } + for _, subject := range rb.Subjects { if subject.Kind == "ServiceAccount" && subject.Name == kuberesources.ServiceAccountName { - foundSecretRoleBinding = true - require.Equal(t, "Role", crb.RoleRef.Kind, "Should reference Role") - break + return true } } } - } - require.True(t, foundSecretRoleBinding, "RoleBinding for ServiceAccount should be created") + return false + }, wait.ForeverTestTimeout, time.Millisecond*100, "waiting for RoleBinding for claimed secrets resource to be ready on provider side") } t.Logf("Provider side namespace pre-seeding and secret management RBAC verified successfully")