Repository: Kong/gateway-operator
Pull request #1410: feat(controlplane): split ClusterRole into ClusterRole and Role to separate permissions when watching a list of namespaces
Author: login: pmalek
Labels: name: area/controlplane
name: release/required
Created: 2025-03-27T15:42:45Z | Merged: 2025-04-24T15:28:46Z

**What this PR does / why we need it**:

This PRs adds splitting of `ControlPlane`'s `ClusterRole` into `Role`s and a `ClusterRole` when `spec.watchNamespaces` is specified and `type` is `list` or `own`.

Along with the `Role`s, the operator will also create appropriate `RoleBinding`s.

**Which issue this PR fixes**

Fixes #1347

**Special notes for your reviewer**:

Adding integration tests is tracked in #1482 to prevent this PR getting out of hand and becoming too big to review.

Chart changes (granting `Role` and `RoleBinding` policy rules) are done in https://github.com/Kong/charts/pull/1295.

Updated API (`WatchNamespaceGrant`): https://github.com/Kong/kubernetes-configuration/pull/403

**PR Readiness Checklist**:

Complete these before marking the PR as `ready to review`:

- [x] the `CHANGELOG.md` release notes have been updated to reflect significant changes


Linked issue #1347: Allow a ControlPlane to watch all namespaces or only specific ones
## Problem Statement

#SEC-1325
Currently a ControlPlane can watch all namespaces in the cluster, creating a ClusterRole and CRBinding that allows the ControlPlane's service account to read configmaps and secrets cluster wide. 

## Proposed Solution

As KIC supports an option to watch only specific namespaces, the ControlPlane should have in the spec an optional list field (empty by default - works clusterwide) where user can specify which namespaces the KIC deployment is going to watch.
Accordingly, if a namespaces list has been provided, the ControlPlane controller must create a Role and RoleBinding pointing to KIC service account in the specified namespaces and go with ClusterRole and CRBinding only if the namespaces list is empty

## Additional Information


## Acceptance Criteria
- [x] ControlPlane controller reconciles KIC deployment against an optional watched namespaces list received in the specs
- [x] ControlPlane controller reconciles, if a watched namespaces list has been provided, Roles and RoleBindings in each namespace; with an empty list of watched namespaces it will reconciler ClusterRole and ClusterRoleBinding (as today)

Linked issue comments: user: login: sandromodarelli
created_at: 2025-03-17T13:46:03Z
body: design doc: https://docs.google.com/document/d/1Xr0U5yxRWt0aJf310vmxOsrUx2uLfg3SLMkEASgZIXg/edit?tab=t.0 
author_association: CONTRIBUTOR
user: login: pmalek
created_at: 2025-03-25T10:09:51Z
body: https://github.com/Kong/kubernetes-configuration/pull/358 introduces the changes in API.


author_association: MEMBER
user: login: pmalek
created_at: 2025-03-26T12:03:23Z
body: Reopening to cover the split of permissions for namespaces and cluster wide permissions from the generated `ClusterRole`s.
author_association: MEMBER
user: login: pmalek
created_at: 2025-04-22T10:58:50Z
body: New API changes are proposed in https://github.com/Kong/kubernetes-configuration/pull/403

cc: @mlavacca 
author_association: MEMBER
user: login: pmalek
created_at: 2025-04-27T15:42:54Z
body: Reopening as there are still sub issues to tackle here.
author_association: MEMBER
user: login: pmalek
created_at: 2025-05-05T11:40:12Z
body: Docs PR - https://github.com/Kong/docs.konghq.com/pull/8741 - has been merged. Closing.
author_association: MEMBER

Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0d8586b537..f1bd8cc093 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -57,9 +57,13 @@
 - Added `spec.watchNamespace` field to `ControlPlane` CRD to allow watching resources
   only in the specified namespace.
   When `spec.watchNamespace.type=list` is used, each specified namespace requires
-  a `ReferenceGrant` in the namespace of the `ControlPlane` that allows the `ControlPlane`
-  to watch resources in the specified namespace.
+  a `WatchNamespaceGrant` that allows the `ControlPlane` to watch resources in the specified namespace.
+  Aforementioned list is extended with `ControlPlane`'s own namespace which doesn't
+  require said `WatchNamespaceGrant`.
   [#1388](https://github.com/Kong/gateway-operator/pull/1388)
+  [#1410](https://github.com/Kong/gateway-operator/pull/1410)
+  <!-- TODO: https://github.com/Kong/gateway-operator/issues/1501 add link to guide from documentation. -->
+  For more information on this please see: https://docs.konghq.com/gateway-operator/latest/
 - Deduce `KonnectCloudGatewayDataPlaneGroupConfiguration` region based on the attached
   `KonnectAPIAuthConfiguration` instead of using a hardcoded `eu` value.
   [#1409](https://github.com/Kong/gateway-operator/pull/1409)
diff --git a/config/rbac/role/role.yaml b/config/rbac/role/role.yaml
index 83d9151515..60da9a448d 100644
--- a/config/rbac/role/role.yaml
+++ b/config/rbac/role/role.yaml
@@ -306,6 +306,12 @@ rules:
   - patch
   - update
   - watch
+- apiGroups:
+  - gateway-operator.konghq.com
+  resources:
+  - watchnamespacegrants
+  verbs:
+  - list
 - apiGroups:
   - gateway.networking.k8s.io
   resources:
@@ -467,6 +473,8 @@ rules:
   resources:
   - clusterrolebindings
   - clusterroles
+  - rolebindings
+  - roles
   verbs:
   - create
   - delete
diff --git a/config/samples/controlplane-dataplane-watchnamespaces.yaml b/config/samples/controlplane-dataplane-watchnamespaces.yaml
index ff8e12975e..f973502272 100644
--- a/config/samples/controlplane-dataplane-watchnamespaces.yaml
+++ b/config/samples/controlplane-dataplane-watchnamespaces.yaml
@@ -2,6 +2,7 @@ apiVersion: gateway-operator.konghq.com/v1beta1
 kind: DataPlane
 metadata:
   name: dataplane-cp-watchnamespace
+  namespace: default
 spec:
   deployment:
     podTemplateSpec:
@@ -27,34 +28,52 @@ spec:
 apiVersion: v1
 kind: Namespace
 metadata:
-  name: kong-system
+  name: namespace-a
 ---
-apiVersion: gateway.networking.k8s.io/v1beta1
-kind: ReferenceGrant
+apiVersion: gateway-operator.konghq.com/v1alpha1
+kind: WatchNamespaceGrant
 metadata:
-  name: ref
-  namespace: kong-system
+  name: watchnamespacegrant
+  namespace: namespace-a
+spec:
+  from:
+  - group: gateway-operator.konghq.com
+    kind: ControlPlane
+    namespace: default
+---
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: namespace-b
+---
+apiVersion: gateway-operator.konghq.com/v1alpha1
+kind: WatchNamespaceGrant
+metadata:
+  name: watchnamespacegrant
+  namespace: namespace-b
 spec:
   from:
   - group: gateway-operator.konghq.com
     kind: ControlPlane
     namespace: default
-  to:
-  - group: ""
-    kind: Namespace
-    name: kong-system
 ---
 apiVersion: gateway-operator.konghq.com/v1beta1
 kind: ControlPlane
 metadata:
   name: controlplane-example
+  namespace: default
 spec:
   dataplane: dataplane-cp-watchnamespace
   gatewayClass: kong
   watchNamespaces:
+    # Using "list" also adds ControlPlane's own namespace to the list
+    # of watched namespaces because that's that KIC does. The reason
+    # for this is the publish service (DataPlane's Service exposed by Kong) by default
+    # would exist in the same namespace as ControlPlane.
     type: list
     list:
-    - kong-system
+    - namespace-a
+    - namespace-b
   deployment:
     podTemplateSpec:
       metadata:
@@ -68,9 +87,6 @@ spec:
           readinessProbe:
             initialDelaySeconds: 1
             periodSeconds: 3
-          env:
-          - name: CONTROLLER_DUMP_CONFIG
-            value: "true"
           resources:
             requests:
               memory: "64Mi"
diff --git a/controller/controlplane/controller.go b/controller/controlplane/controller.go
index 37e17791d6..fca9ab5881 100644
--- a/controller/controlplane/controller.go
+++ b/controller/controlplane/controller.go
@@ -50,6 +50,7 @@ import (
 // Reconciler reconciles a ControlPlane object
 type Reconciler struct {
 	client.Client
+	DiscoveryClient           *CachedDiscoveryClient
 	Scheme                    *runtime.Scheme
 	ClusterCASecretName       string
 	ClusterCASecretNamespace  string
@@ -129,7 +130,17 @@ func (r *Reconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) err
 		// are referenced in a "from" instance.
 		Watches(
 			&gatewayv1beta1.ReferenceGrant{},
-			handler.EnqueueRequestsFromMapFunc(r.listControlPlanesForReferenceGrants))
+			handler.EnqueueRequestsFromMapFunc(r.listControlPlanesForReferenceGrants)).
+		// Watch for events on Roles, if any Role event happens, enqueue
+		// reconciliation for all supported ControlPlane objects which use this Role.
+		Watches(
+			&rbacv1.Role{},
+			handler.EnqueueRequestsFromMapFunc(listControlPlanesFor[*rbacv1.Role])).
+		// Watch for events on RoleBindings, if any RoleBinding event happens, enqueue
+		// reconciliation for all supported ControlPlane objects which use this RoleBinding.
+		Watches(
+			&rbacv1.RoleBinding{},
+			handler.EnqueueRequestsFromMapFunc(listControlPlanesFor[*rbacv1.RoleBinding]))
 
 	if r.KonnectEnabled {
 		// Watch for changes in KonnectExtension objects that are referenced by ControlPlane objects.
@@ -351,25 +362,38 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 		log.Debug(logger, "DataPlane not set, deployment for ControlPlane will remain dormant")
 	}
 
-	log.Trace(logger, "validating ReferenceGrants exist for the ControlPlane")
-	validatedWatchNamespaces, err := r.validateReferenceGrants(ctx, cp)
+	const (
+		ConditionTypeWatchNamespaceGrantsValid   = "WatchNamespaceGrantsValid"
+		ConditionReasonWatchNamespaceGrantsValid = "WatchNamespaceGrantsValid"
+	)
+	log.Trace(logger, "validating WatchNamespaceGrants exist for the ControlPlane")
+	validatedWatchNamespaces, err := r.validateWatchNamespaceGrants(ctx, cp)
 	if err != nil {
 		k8sutils.SetCondition(
-			k8sutils.NewCondition(
-				kcfgdataplane.ReadyType,
+			k8sutils.NewConditionWithGeneration(
+				ConditionTypeWatchNamespaceGrantsValid,
 				metav1.ConditionFalse,
 				kcfgcontrolplane.ConditionReasonMissingReferenceGrant,
-				fmt.Sprintf("ReferenceGrant(s) are missing for the ControlPlane: %v", err),
+				fmt.Sprintf("WatchNamespaceGrant(s) are missing for the ControlPlane: %v", err),
+				cp.GetGeneration(),
 			),
 			cp,
 		)
-		res, err := r.patchStatus(ctx, logger, cp)
-		if err != nil || !res.IsZero() {
-			return res, err
-		}
 		// We do not return here as we want to proceed with reconciling the Deployment.
 		// This will prevent users using the ControlPlane Deployment with previous
 		// WatchNamespaces spec.
+		// We do not patch the status here either because that's done below.
+	} else {
+		k8sutils.SetCondition(
+			k8sutils.NewConditionWithGeneration(
+				ConditionTypeWatchNamespaceGrantsValid,
+				metav1.ConditionTrue,
+				ConditionReasonWatchNamespaceGrantsValid,
+				"WatchNamespaceGrant(s) are present and valid",
+				cp.GetGeneration(),
+			),
+			cp,
+		)
 	}
 
 	log.Trace(logger, "ensuring ServiceAccount for ControlPlane deployment exists")
@@ -382,7 +406,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 		return ctrl.Result{}, nil // requeue will be triggered by the creation or update of the owned object
 	}
 
-	res, err := r.ensureRoles(ctx, logger, cp, controlplaneServiceAccount)
+	res, err := r.ensureRolesAndClusterRoles(ctx, logger, cp, controlplaneServiceAccount, validatedWatchNamespaces)
 	if err != nil {
 		return ctrl.Result{}, fmt.Errorf("failed to ensure roles or cluster roles: %w", err)
 	} else if res != op.Noop {
diff --git a/controller/controlplane/controller_rbac.go b/controller/controlplane/controller_rbac.go
index d51ce5665b..67f987d7a4 100644
--- a/controller/controlplane/controller_rbac.go
+++ b/controller/controlplane/controller_rbac.go
@@ -9,6 +9,8 @@ package controlplane
 // +kubebuilder:rbac:groups=gateway-operator.konghq.com,resources=controlplanes/finalizers,verbs=update
 // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles,verbs=create;get;list;watch;update;patch;delete
 // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterrolebindings,verbs=create;get;list;watch;update;patch;delete
+// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=create;get;list;watch;update;patch;delete
+// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=create;get;list;watch;update;patch;delete
 // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=create;get;list;watch;update;patch;delete
 // +kubebuilder:rbac:groups=core,resources=services,verbs=create;get;list;watch;update;patch;delete
 // +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=create;get;list;watch;update;patch;delete
diff --git a/controller/controlplane/controller_reconciler_utils.go b/controller/controlplane/controller_reconciler_utils.go
index 3d639d9868..37f8ac41bd 100644
--- a/controller/controlplane/controller_reconciler_utils.go
+++ b/controller/controlplane/controller_reconciler_utils.go
@@ -14,9 +14,10 @@ import (
 	corev1 "k8s.io/api/core/v1"
 	rbacv1 "k8s.io/api/rbac/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	k8stypes "k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/sets"
 	"sigs.k8s.io/controller-runtime/pkg/client"
-	gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1"
 
 	"github.com/kong/gateway-operator/controller/pkg/controlplane"
 	"github.com/kong/gateway-operator/controller/pkg/log"
@@ -31,6 +32,7 @@ import (
 	k8sresources "github.com/kong/gateway-operator/pkg/utils/kubernetes/resources"
 
 	kcfgcontrolplane "github.com/kong/kubernetes-configuration/api/gateway-operator/controlplane"
+	operatorv1alpha1 "github.com/kong/kubernetes-configuration/api/gateway-operator/v1alpha1"
 	operatorv1beta1 "github.com/kong/kubernetes-configuration/api/gateway-operator/v1beta1"
 )
 
@@ -322,14 +324,20 @@ func (r *Reconciler) ensureServiceAccount(
 	return true, generatedServiceAccount, r.Create(ctx, generatedServiceAccount)
 }
 
-func (r *Reconciler) ensureRoles(
+func (r *Reconciler) ensureRolesAndClusterRoles(
 	ctx context.Context,
 	logger logr.Logger,
 	cp *operatorv1beta1.ControlPlane,
 	controlplaneServiceAccount *corev1.ServiceAccount,
+	validatedWatchNamespaces []string,
 ) (op.Result, error) {
-	log.Trace(logger, "ensuring ClusterRoles for ControlPlane deployment exist")
-	createdOrUpdated, controlplaneClusterRole, err := r.ensureClusterRole(ctx, cp)
+	generatedRoles, generatedClusterRole, err := r.generateRoleAndClusterRole(cp, validatedWatchNamespaces)
+	if err != nil {
+		return op.Noop, err
+	}
+
+	log.Trace(logger, "ensuring ClusterRoles for ControlPlane Deployment exist")
+	createdOrUpdated, controlplaneClusterRole, err := r.ensureClusterRole(ctx, cp, generatedClusterRole)
 	if err != nil {
 		return op.Noop, err
 	}
@@ -348,12 +356,306 @@ func (r *Reconciler) ensureRoles(
 		return op.Updated, nil // requeue will be triggered by the creation or update of the owned object
 	}
 
+	// If watchNamespaces is not empty then we need to generate roles and role bindings for each namespace.
+	if len(generatedRoles) > 0 {
+		log.Trace(logger, "ensuring Roles for ControlPlane Deployment exist")
+		createdOrUpdated, controlplaneRoles, err := r.ensureRoles(ctx, cp, generatedRoles)
+		if err != nil {
+			return op.Noop, err
+		}
+		if createdOrUpdated {
+			log.Debug(logger, "Roles created/updated")
+			return op.Updated, nil // requeue will be triggered by the creation or update of Roles.
+		}
+
+		res, err := r.ensureRoleBindings(ctx, cp, controlplaneServiceAccount, controlplaneRoles)
+		if err != nil {
+			return op.Noop, err
+		}
+		if res != op.Noop {
+			log.Debug(logger, "RoleBindings created/updated")
+			return res, nil
+		}
+
+		deleted, err := r.pruneOutdatedRoles(ctx, logger, cp, validatedWatchNamespaces)
+		if err != nil {
+			return op.Noop, err
+		}
+		if deleted {
+			log.Debug(logger, "not needed Roles deleted")
+		}
+
+		deleted, err = r.pruneOutdatedRoleBindings(ctx, logger, cp, validatedWatchNamespaces)
+		if err != nil {
+			return op.Noop, err
+		}
+		if deleted {
+			log.Debug(logger, "not needed RoleBindings deleted")
+		}
+	}
+
 	return op.Noop, nil
 }
 
+func (r *Reconciler) pruneOutdatedRoles(
+	ctx context.Context,
+	logger logr.Logger,
+	cp *operatorv1beta1.ControlPlane,
+	validatedWatchNamespaces []string,
+) (deleted bool, err error) {
+	namespaces := sets.New(validatedWatchNamespaces...)
+
+	roles, err := k8sutils.ListRoles(
+		ctx,
+		r.Client,
+		client.MatchingLabels(k8sutils.GetManagedByLabelSet(cp)),
+	)
+	if err != nil {
+		return false, err
+	}
+
+	for _, role := range roles {
+		objDeleted, err := deleteIfNotInNamespaceSet(ctx, r.Client, &role, namespaces, "ControlPlane's Role")
+		if err != nil {
+			return false, err
+		}
+		if !objDeleted {
+			continue
+		}
+
+		deleted = true
+		log.Debug(logger, "Role deleted", "role", role)
+	}
+
+	return deleted, nil
+}
+
+func (r *Reconciler) pruneOutdatedRoleBindings(
+	ctx context.Context,
+	logger logr.Logger,
+	cp *operatorv1beta1.ControlPlane,
+	validatedWatchNamespaces []string,
+) (deleted bool, err error) {
+	namespaces := sets.New(validatedWatchNamespaces...)
+
+	roleBindings, err := k8sutils.ListRoleBindings(
+		ctx,
+		r.Client,
+		client.MatchingLabels(k8sutils.GetManagedByLabelSet(cp)),
+	)
+	if err != nil {
+		return false, err
+	}
+
+	for _, rb := range roleBindings {
+		objDeleted, err := deleteIfNotInNamespaceSet(ctx, r.Client, &rb, namespaces, "ControlPlane's RoleBinding")
+		if err != nil {
+			return false, err
+		}
+		if !objDeleted {
+			continue
+		}
+
+		deleted = true
+		log.Debug(logger, "RoleBinding deleted", "role_binding", rb)
+	}
+
+	return deleted, nil
+}
+
+func deleteIfNotInNamespaceSet(
+	ctx context.Context, cl client.Client, obj client.Object, namespaces sets.Set[string], msg string,
+) (bool, error) {
+	if namespaces.Has(obj.GetNamespace()) {
+		return false, nil
+	}
+
+	if err := cl.Delete(ctx, obj); err != nil {
+		return false, fmt.Errorf("failed deleting %s %s: %w", msg, client.ObjectKeyFromObject(obj), err)
+	}
+	return true, nil
+}
+
+func (r *Reconciler) ensureRoles(
+	ctx context.Context,
+	cp *operatorv1beta1.ControlPlane,
+	generatedRoles []*rbacv1.Role,
+) (createdOrUpdated bool, roles []*rbacv1.Role, err error) {
+rolesLoop:
+	for _, generatedRole := range generatedRoles {
+		existingRoles, err := k8sutils.ListRoles(
+			ctx,
+			r.Client,
+			client.MatchingLabels(k8sutils.GetManagedByLabelSet(cp)),
+			client.InNamespace(generatedRole.Namespace),
+		)
+		if err != nil {
+			return false, nil, err
+		}
+
+		switch count := len(existingRoles); count {
+		case 0:
+			if err := r.Create(ctx, generatedRole); err != nil {
+				return false, nil, err
+			}
+			roles = append(roles, generatedRole)
+			createdOrUpdated = true
+		case 1:
+			var (
+				updated  bool
+				existing = &existingRoles[0]
+				old      = existing.DeepCopy()
+			)
+
+			updated, existing.ObjectMeta = k8sutils.EnsureObjectMetaIsUpdated(existing.ObjectMeta, generatedRole.ObjectMeta)
+			if updated ||
+				!cmp.Equal(existing.Rules, generatedRole.Rules) {
+				existing.Rules = generatedRole.Rules
+				if err := r.Patch(ctx, existing, client.MergeFrom(old)); err != nil {
+					return false, nil, fmt.Errorf("failed patching ControlPlane's Role %s: %w", existing.Name, err)
+				}
+				roles = append(roles, existing)
+				createdOrUpdated = true
+				continue rolesLoop
+			}
+			roles = append(roles, existing)
+		default:
+			if err := k8sreduce.ReduceRoles(ctx, r.Client, existingRoles); err != nil {
+				return false, nil, err
+			}
+			return false, nil, fmt.Errorf("number of Roles reduced from: %d to 1", count)
+		}
+
+	}
+
+	return createdOrUpdated, roles, nil
+}
+
+func (r *Reconciler) ensureRoleBindings(
+	ctx context.Context,
+	cp *operatorv1beta1.ControlPlane,
+	controlplaneServiceAccount *corev1.ServiceAccount,
+	roles []*rbacv1.Role,
+) (op.Result, error) {
+	res := op.Noop
+	for _, role := range roles {
+		resEnsure, _, err := r.ensureRoleBinding(ctx, cp, controlplaneServiceAccount.Name, client.ObjectKeyFromObject(role))
+		if err != nil {
+			return op.Noop, err
+		}
+
+		if resEnsure != op.Noop {
+			res = resEnsure
+		}
+	}
+
+	return res, nil
+}
+
+func (r *Reconciler) ensureRoleBinding(
+	ctx context.Context,
+	cp *operatorv1beta1.ControlPlane,
+	serviceAccountName string,
+	roleNN k8stypes.NamespacedName,
+) (op.Result, *rbacv1.RoleBinding, error) {
+	logger := log.GetLogger(ctx, "controlplane.ensureRoleBinding", r.LoggingMode)
+
+	roleBindings, err := k8sutils.ListRoleBindings(
+		ctx,
+		r.Client,
+		client.MatchingLabels(k8sutils.GetManagedByLabelSet(cp)),
+		client.InNamespace(roleNN.Namespace),
+	)
+	if err != nil {
+		return op.Noop, nil, err
+	}
+
+	count := len(roleBindings)
+	if count > 1 {
+		if err := k8sreduce.ReduceRoleBindings(ctx, r.Client, roleBindings); err != nil {
+			return op.Noop, nil, err
+		}
+		return op.Noop, nil, errors.New("number of roleBindings reduced")
+	}
+
+	generated := k8sresources.GenerateNewRoleBindingForControlPlane(cp, serviceAccountName, roleNN)
+	k8sutils.SetOwnerForObjectThroughLabels(generated, cp)
+
+	if count == 1 {
+		existing := &roleBindings[0]
+		// Delete and re-create RoleBinding if name of Role changed because RoleRef is immutable.
+		if !k8sresources.CompareRoleName(existing, roleNN.Name) {
+			log.Debug(logger, "Role name changed, delete and re-create a RoleBinding",
+				"old_role", existing.RoleRef.Name,
+				"new_role", roleNN,
+			)
+			if err := r.Delete(ctx, existing); err != nil {
+				return op.Noop, nil, err
+			}
+			return op.Noop, nil, errors.New("name of Role changed, out of date RoleBinding deleted")
+		}
+
+		var (
+			old                   = existing.DeepCopy()
+			updated               bool
+			updatedServiceAccount bool
+		)
+		updated, existing.ObjectMeta = k8sutils.EnsureObjectMetaIsUpdated(existing.ObjectMeta, generated.ObjectMeta)
+
+		if !k8sresources.RoleBindingContainsServiceAccount(existing, cp.Namespace, serviceAccountName) {
+			existing.Subjects = generated.Subjects
+			updatedServiceAccount = true
+		}
+
+		if updated || updatedServiceAccount {
+			if err := r.Patch(ctx, existing, client.MergeFrom(old)); err != nil {
+				return op.Noop, existing, fmt.Errorf("failed patching ControlPlane's RoleBinding %s: %w", existing.Name, err)
+			}
+			return op.Updated, existing, nil
+		}
+		return op.Noop, existing, nil
+
+	}
+
+	return op.Created, generated, r.Create(ctx, generated)
+}
+
+func (r *Reconciler) generateRoleAndClusterRole(
+	cp *operatorv1beta1.ControlPlane,
+	verifiedWatchNamespaces []string,
+) ([]*rbacv1.Role, *rbacv1.ClusterRole, error) {
+	controlplaneContainer := k8sutils.GetPodContainerByName(&cp.Spec.Deployment.PodTemplateSpec.Spec, consts.ControlPlaneControllerContainerName)
+	clusterRole, err := k8sresources.GenerateNewClusterRoleForControlPlane(cp.Name, controlplaneContainer.Image, r.ValidateControlPlaneImage)
+	if err != nil {
+		return nil, nil, err
+	}
+	k8sutils.SetOwnerForObjectThroughLabels(clusterRole, cp)
+
+	// If watchNamespaces is not empty, we're generating Roles and a ClusterRole.
+	if len(verifiedWatchNamespaces) > 0 {
+		m, err := r.DiscoveryClient.GetAPIResourceListMapping()
+		if err != nil {
+			return nil, nil, err
+		}
+		roleRules, clusterRolesRules := processClusterRole(clusterRole, m)
+		clusterRole.Rules = clusterRolesRules
+
+		roles := make([]*rbacv1.Role, 0, len(verifiedWatchNamespaces))
+		for _, namespace := range verifiedWatchNamespaces {
+			role := k8sresources.GenerateNewRoleForControlPlane(cp, namespace, roleRules)
+			k8sutils.SetOwnerForObjectThroughLabels(role, cp)
+			roles = append(roles, role)
+		}
+		return roles, clusterRole, nil
+	}
+
+	return nil, clusterRole, nil
+}
+
 func (r *Reconciler) ensureClusterRole(
 	ctx context.Context,
 	cp *operatorv1beta1.ControlPlane,
+	generatedClusterRole *rbacv1.ClusterRole,
 ) (createdOrUpdated bool, cr *rbacv1.ClusterRole, err error) {
 	clusterRoles, err := k8sutils.ListClusterRoles(
 		ctx,
@@ -372,13 +674,6 @@ func (r *Reconciler) ensureClusterRole(
 		return false, nil, errors.New("number of clusterRoles reduced")
 	}
 
-	controlplaneContainer := k8sutils.GetPodContainerByName(&cp.Spec.Deployment.PodTemplateSpec.Spec, consts.ControlPlaneControllerContainerName)
-	generated, err := k8sresources.GenerateNewClusterRoleForControlPlane(cp.Name, controlplaneContainer.Image, r.ValidateControlPlaneImage)
-	if err != nil {
-		return false, nil, err
-	}
-	k8sutils.SetOwnerForObjectThroughLabels(generated, cp)
-
 	if count == 1 {
 		var (
 			updated  bool
@@ -386,12 +681,12 @@ func (r *Reconciler) ensureClusterRole(
 			old      = existing.DeepCopy()
 		)
 
-		updated, existing.ObjectMeta = k8sutils.EnsureObjectMetaIsUpdated(existing.ObjectMeta, generated.ObjectMeta)
+		updated, existing.ObjectMeta = k8sutils.EnsureObjectMetaIsUpdated(existing.ObjectMeta, generatedClusterRole.ObjectMeta)
 		if updated ||
-			!cmp.Equal(existing.Rules, generated.Rules) ||
-			!cmp.Equal(existing.AggregationRule, generated.AggregationRule) {
-			existing.Rules = generated.Rules
-			existing.AggregationRule = generated.AggregationRule
+			!cmp.Equal(existing.Rules, generatedClusterRole.Rules) ||
+			!cmp.Equal(existing.AggregationRule, generatedClusterRole.AggregationRule) {
+			existing.Rules = generatedClusterRole.Rules
+			existing.AggregationRule = generatedClusterRole.AggregationRule
 			if err := r.Patch(ctx, existing, client.MergeFrom(old)); err != nil {
 				return false, existing, fmt.Errorf("failed patching ControlPlane's ClusterRole %s: %w", existing.Name, err)
 			}
@@ -400,7 +695,78 @@ func (r *Reconciler) ensureClusterRole(
 		return false, existing, nil
 	}
 
-	return true, generated, r.Create(ctx, generated)
+	return true, generatedClusterRole, r.Create(ctx, generatedClusterRole)
+}
+
+// processClusterRole processes the generated ClusterRole and splits its policy rules
+// into two slices: one for the Role and one for the ClusterRole.
+// The split is based on the availability of the resources in the cluster.
+//
+// For example assuming the following rules on the ClusterRole:
+// ```
+// rules:
+// - apiGroups:
+//   - ""
+//     resources:
+//   - configmaps
+//   - namespaces
+//     verbs:
+//   - create
+//
+// ```
+//
+// If the cluster has the `configmaps` resource available in the cluster,
+// the rule will be added to the RolePolicyRules as it's a namespaced resource.
+// If the cluster has the `namespaces` resource available in the cluster,
+// the rule will be added to the ClusterRolePolicyRules as it's a cluster-scoped resource.
+func processClusterRole(
+	generated *rbacv1.ClusterRole,
+	gvl map[schema.GroupVersion]*metav1.APIResourceList,
+) (rolePolicyRules []rbacv1.PolicyRule, clusterRolePolicyRules []rbacv1.PolicyRule) {
+	for _, rule := range generated.Rules {
+		// There's typically just 1 APIGroup in the rule, but we loop over all of them
+		// to ensure that we're only adding rules for the APIGroups that are available.
+		for _, apiGroup := range rule.APIGroups {
+			for _, resource := range rule.Resources {
+				var found bool
+				for gv, apiresl := range gvl {
+					if gv.Group != apiGroup {
+						continue
+					}
+
+					apires, ok := lo.Find(apiresl.APIResources,
+						func(apires metav1.APIResource) bool {
+							return apires.Group == apiGroup && apires.Name == resource
+						})
+					if !ok {
+						continue
+					}
+
+					found = true
+
+					// There can be more than one resource in policy rule so we need to
+					// create a new rule for each resource separately.
+					rule.Resources = []string{resource}
+
+					if apires.Namespaced {
+						rolePolicyRules = append(rolePolicyRules, rule)
+					} else {
+						clusterRolePolicyRules = append(clusterRolePolicyRules, rule)
+					}
+					break
+				}
+
+				// Just in case the resource is not found in the cluster, we add
+				// it to ClusterRole as it's the default behavior and we better
+				// have the rule set rather than not and break the configuration.
+				if !found {
+					clusterRolePolicyRules = append(clusterRolePolicyRules, rule)
+				}
+			}
+		}
+	}
+
+	return rolePolicyRules, clusterRolePolicyRules
 }
 
 func (r *Reconciler) ensureClusterRoleBinding(
@@ -822,7 +1188,7 @@ func (r *Reconciler) ensureValidatingWebhookConfiguration(
 	return op.Created, r.Create(ctx, generatedWebhookConfiguration)
 }
 
-func (r *Reconciler) validateReferenceGrants(
+func (r *Reconciler) validateWatchNamespaceGrants(
 	ctx context.Context,
 	cp *operatorv1beta1.ControlPlane,
 ) ([]string, error) {
@@ -841,11 +1207,16 @@ func (r *Reconciler) validateReferenceGrants(
 	case operatorv1beta1.WatchNamespacesTypeList:
 		var nsList []string
 		for _, ns := range cp.Spec.WatchNamespaces.List {
-			if err := ensureReferenceGrantsForNamespace(ctx, r.Client, cp, ns); err != nil {
+			if err := ensureWatchNamespaceGrantsForNamespace(ctx, r.Client, cp, ns); err != nil {
 				return nsList, err
 			}
 			nsList = append(nsList, ns)
 		}
+		// Add ControlPlane's own namespace as it will add it anyway because
+		// that's where the default "publish service" exists.
+		// We add it here as we do not require a ReferenceGrant for own namespace
+		// so there's no validation whether a grant exists.
+		nsList = append(nsList, cp.Namespace)
 
 		return nsList, nil
 	default:
@@ -853,40 +1224,31 @@ func (r *Reconciler) validateReferenceGrants(
 	}
 }
 
-// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=referencegrants,verbs=list
+// +kubebuilder:rbac:groups=gateway-operator.konghq.com,resources=watchnamespacegrants,verbs=list
 
-// ensureReferenceGrantsForNamespace ensures that a ReferenceGrant exists for the
+// ensureWatchNamespaceGrantsForNamespace ensures that a WatchNamespaceGrant exists for the
 // given namespace and ControlPlane.
-// It returns an error if a ReferenceGrant is missing.
-func ensureReferenceGrantsForNamespace(
+// It returns an error if a WatchNamespaceGrant is missing.
+func ensureWatchNamespaceGrantsForNamespace(
 	ctx context.Context,
 	cl client.Client,
 	cp *operatorv1beta1.ControlPlane,
 	ns string,
 ) error {
-	var refGrants gatewayv1beta1.ReferenceGrantList
-	if err := cl.List(ctx, &refGrants, client.InNamespace(ns)); err != nil {
-		return fmt.Errorf("failed listing ReferenceGrants in namespace %s: %w", ns, err)
+	var grants operatorv1alpha1.WatchNamespaceGrantList
+	if err := cl.List(ctx, &grants, client.InNamespace(ns)); err != nil {
+		return fmt.Errorf("failed listing WatchNamespaceGrants in namespace %s: %w", ns, err)
 	}
-	for _, refGrant := range refGrants.Items {
-		if !lo.ContainsBy(refGrant.Spec.From, func(from gatewayv1beta1.ReferenceGrantFrom) bool {
-			return string(from.Group) == operatorv1beta1.SchemeGroupVersion.Group &&
+	for _, refGrant := range grants.Items {
+		if !lo.ContainsBy(refGrant.Spec.From, func(from operatorv1alpha1.WatchNamespaceGrantFrom) bool {
+			return from.Group == operatorv1beta1.SchemeGroupVersion.Group &&
 				from.Kind == "ControlPlane" &&
-				string(from.Namespace) == cp.Namespace
-		}) {
-			continue
-		}
-
-		if !lo.ContainsBy(refGrant.Spec.To, func(to gatewayv1beta1.ReferenceGrantTo) bool {
-			return to.Group == "" &&
-				to.Kind == "Namespace" &&
-				to.Name != nil &&
-				string(*to.Name) == ns
+				from.Namespace == cp.Namespace
 		}) {
 			continue
 		}
 
 		return nil
 	}
-	return fmt.Errorf("ReferenceGrant in Namespace %s to ControlPlane in Namespace %s not found", ns, cp.Namespace)
+	return fmt.Errorf("WatchNamespaceGrant in Namespace %s to ControlPlane in Namespace %s not found", ns, cp.Namespace)
 }
diff --git a/controller/controlplane/controller_reconciler_utils_test.go b/controller/controlplane/controller_reconciler_utils_test.go
index 2fc0ce55a5..6ddaf1a383 100644
--- a/controller/controlplane/controller_reconciler_utils_test.go
+++ b/controller/controlplane/controller_reconciler_utils_test.go
@@ -7,16 +7,18 @@ import (
 	"github.com/stretchr/testify/require"
 	admregv1 "k8s.io/api/admissionregistration/v1"
 	corev1 "k8s.io/api/core/v1"
+	rbacv1 "k8s.io/api/rbac/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	fakectrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
-	gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1"
 
 	"github.com/kong/gateway-operator/controller/pkg/op"
 	"github.com/kong/gateway-operator/modules/manager/scheme"
 	"github.com/kong/gateway-operator/pkg/consts"
 	k8sresources "github.com/kong/gateway-operator/pkg/utils/kubernetes/resources"
 
+	operatorv1alpha1 "github.com/kong/kubernetes-configuration/api/gateway-operator/v1alpha1"
 	operatorv1beta1 "github.com/kong/kubernetes-configuration/api/gateway-operator/v1beta1"
 )
 
@@ -210,11 +212,11 @@ func TestEnsureReferenceGrantsForNamespace(t *testing.T) {
 		name      string
 		namespace string
 		cp        *operatorv1beta1.ControlPlane
-		refGrants []client.Object
+		grants    []client.Object
 		wantErr   bool
 	}{
 		{
-			name:      "reference grant exists and matches",
+			name:      "watch namespace grant exists and matches",
 			namespace: "test-ns",
 			cp: &operatorv1beta1.ControlPlane{
 				ObjectMeta: metav1.ObjectMeta{
@@ -222,34 +224,27 @@ func TestEnsureReferenceGrantsForNamespace(t *testing.T) {
 					Namespace: "cp-ns",
 				},
 			},
-			refGrants: []client.Object{
-				&gatewayv1beta1.ReferenceGrant{
+			grants: []client.Object{
+				&operatorv1alpha1.WatchNamespaceGrant{
 					ObjectMeta: metav1.ObjectMeta{
 						Name:      "valid-grant",
 						Namespace: "test-ns",
 					},
-					Spec: gatewayv1beta1.ReferenceGrantSpec{
-						From: []gatewayv1beta1.ReferenceGrantFrom{
+					Spec: operatorv1alpha1.WatchNamespaceGrantSpec{
+						From: []operatorv1alpha1.WatchNamespaceGrantFrom{
 							{
 								Group:     "gateway-operator.konghq.com",
 								Kind:      "ControlPlane",
 								Namespace: "cp-ns",
 							},
 						},
-						To: []gatewayv1beta1.ReferenceGrantTo{
-							{
-								Group: "",
-								Kind:  "Namespace",
-								Name:  lo.ToPtr(gatewayv1beta1.ObjectName("test-ns")),
-							},
-						},
 					},
 				},
 			},
 			wantErr: false,
 		},
 		{
-			name:      "reference grant doesn't exist",
+			name:      "watch namespace grant doesn't exist",
 			namespace: "test-ns",
 			cp: &operatorv1beta1.ControlPlane{
 				ObjectMeta: metav1.ObjectMeta{
@@ -257,11 +252,11 @@ func TestEnsureReferenceGrantsForNamespace(t *testing.T) {
 					Namespace: "cp-ns",
 				},
 			},
-			refGrants: []client.Object{},
-			wantErr:   true,
+			grants:  []client.Object{},
+			wantErr: true,
 		},
 		{
-			name:      "reference grant exists but from namespace doesn't match",
+			name:      "watch namespace grant exists but from namespace doesn't match",
 			namespace: "test-ns",
 			cp: &operatorv1beta1.ControlPlane{
 				ObjectMeta: metav1.ObjectMeta{
@@ -269,62 +264,20 @@ func TestEnsureReferenceGrantsForNamespace(t *testing.T) {
 					Namespace: "cp-ns",
 				},
 			},
-			refGrants: []client.Object{
-				&gatewayv1beta1.ReferenceGrant{
+			grants: []client.Object{
+				&operatorv1alpha1.WatchNamespaceGrant{
 					ObjectMeta: metav1.ObjectMeta{
 						Name:      "invalid-from-namespace",
 						Namespace: "test-ns",
 					},
-					Spec: gatewayv1beta1.ReferenceGrantSpec{
-						From: []gatewayv1beta1.ReferenceGrantFrom{
+					Spec: operatorv1alpha1.WatchNamespaceGrantSpec{
+						From: []operatorv1alpha1.WatchNamespaceGrantFrom{
 							{
 								Group:     "gateway-operator.konghq.com",
 								Kind:      "ControlPlane",
 								Namespace: "wrong-namespace",
 							},
 						},
-						To: []gatewayv1beta1.ReferenceGrantTo{
-							{
-								Group: "",
-								Kind:  "Namespace",
-								Name:  lo.ToPtr(gatewayv1beta1.ObjectName("test-ns")),
-							},
-						},
-					},
-				},
-			},
-			wantErr: true,
-		},
-		{
-			name:      "reference grant exists but to name doesn't match",
-			namespace: "test-ns",
-			cp: &operatorv1beta1.ControlPlane{
-				ObjectMeta: metav1.ObjectMeta{
-					Name:      "test-cp",
-					Namespace: "cp-ns",
-				},
-			},
-			refGrants: []client.Object{
-				&gatewayv1beta1.ReferenceGrant{
-					ObjectMeta: metav1.ObjectMeta{
-						Name:      "invalid-to-name",
-						Namespace: "test-ns",
-					},
-					Spec: gatewayv1beta1.ReferenceGrantSpec{
-						From: []gatewayv1beta1.ReferenceGrantFrom{
-							{
-								Group:     "gateway-operator.konghq.com",
-								Kind:      "ControlPlane",
-								Namespace: "cp-ns",
-							},
-						},
-						To: []gatewayv1beta1.ReferenceGrantTo{
-							{
-								Group: "",
-								Kind:  "Namespace",
-								Name:  lo.ToPtr(gatewayv1beta1.ObjectName("wrong-name")),
-							},
-						},
 					},
 				},
 			},
@@ -339,49 +292,35 @@ func TestEnsureReferenceGrantsForNamespace(t *testing.T) {
 					Namespace: "cp-ns",
 				},
 			},
-			refGrants: []client.Object{
-				&gatewayv1beta1.ReferenceGrant{
+			grants: []client.Object{
+				&operatorv1alpha1.WatchNamespaceGrant{
 					ObjectMeta: metav1.ObjectMeta{
 						Name:      "invalid-grant",
 						Namespace: "test-ns",
 					},
-					Spec: gatewayv1beta1.ReferenceGrantSpec{
-						From: []gatewayv1beta1.ReferenceGrantFrom{
+					Spec: operatorv1alpha1.WatchNamespaceGrantSpec{
+						From: []operatorv1alpha1.WatchNamespaceGrantFrom{
 							{
 								Group:     "wrong.group",
 								Kind:      "WrongKind",
 								Namespace: "wrong-ns",
 							},
 						},
-						To: []gatewayv1beta1.ReferenceGrantTo{
-							{
-								Group: "",
-								Kind:  "Namespace",
-								Name:  lo.ToPtr(gatewayv1beta1.ObjectName("wrong-name")),
-							},
-						},
 					},
 				},
-				&gatewayv1beta1.ReferenceGrant{
+				&operatorv1alpha1.WatchNamespaceGrant{
 					ObjectMeta: metav1.ObjectMeta{
 						Name:      "valid-grant",
 						Namespace: "test-ns",
 					},
-					Spec: gatewayv1beta1.ReferenceGrantSpec{
-						From: []gatewayv1beta1.ReferenceGrantFrom{
+					Spec: operatorv1alpha1.WatchNamespaceGrantSpec{
+						From: []operatorv1alpha1.WatchNamespaceGrantFrom{
 							{
 								Group:     "gateway-operator.konghq.com",
 								Kind:      "ControlPlane",
 								Namespace: "cp-ns",
 							},
 						},
-						To: []gatewayv1beta1.ReferenceGrantTo{
-							{
-								Group: "",
-								Kind:  "Namespace",
-								Name:  lo.ToPtr(gatewayv1beta1.ObjectName("test-ns")),
-							},
-						},
 					},
 				},
 			},
@@ -394,10 +333,10 @@ func TestEnsureReferenceGrantsForNamespace(t *testing.T) {
 			fakeClient := fakectrlruntimeclient.
 				NewClientBuilder().
 				WithScheme(scheme.Get()).
-				WithObjects(tt.refGrants...).
+				WithObjects(tt.grants...).
 				Build()
 
-			err := ensureReferenceGrantsForNamespace(t.Context(), fakeClient, tt.cp, tt.namespace)
+			err := ensureWatchNamespaceGrantsForNamespace(t.Context(), fakeClient, tt.cp, tt.namespace)
 			if tt.wantErr {
 				require.Error(t, err)
 			} else {
@@ -406,3 +345,308 @@ func TestEnsureReferenceGrantsForNamespace(t *testing.T) {
 		})
 	}
 }
+
+func TestProcessClusterRole(t *testing.T) {
+	testCases := []struct {
+		name                 string
+		rules                []rbacv1.PolicyRule
+		gvl                  map[schema.GroupVersion]*metav1.APIResourceList
+		expectedRoleRules    []rbacv1.PolicyRule
+		expectedClusterRules []rbacv1.PolicyRule
+	}{
+		{
+			name: "namespaced resource",
+			rules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{""},
+					Resources: []string{"configmaps"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+			gvl: map[schema.GroupVersion]*metav1.APIResourceList{
+				{Group: "", Version: "v1"}: {
+					APIResources: []metav1.APIResource{
+						{
+							Name:       "configmaps",
+							Group:      "",
+							Namespaced: true,
+						},
+					},
+				},
+			},
+			expectedRoleRules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{""},
+					Resources: []string{"configmaps"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+			expectedClusterRules: nil,
+		},
+		{
+			name: "cluster-scoped resource",
+			rules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{""},
+					Resources: []string{"namespaces"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+			gvl: map[schema.GroupVersion]*metav1.APIResourceList{
+				{Group: "", Version: "v1"}: {
+					APIResources: []metav1.APIResource{
+						{
+							Name:       "namespaces",
+							Group:      "",
+							Namespaced: false,
+						},
+					},
+				},
+			},
+			expectedRoleRules: nil,
+			expectedClusterRules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{""},
+					Resources: []string{"namespaces"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+		},
+		{
+			name: "mixed resources",
+			rules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{""},
+					Resources: []string{"configmaps", "namespaces"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+			gvl: map[schema.GroupVersion]*metav1.APIResourceList{
+				{Group: "", Version: "v1"}: {
+					APIResources: []metav1.APIResource{
+						{
+							Name:       "configmaps",
+							Group:      "",
+							Namespaced: true,
+						},
+						{
+							Name:       "namespaces",
+							Group:      "",
+							Namespaced: false,
+						},
+					},
+				},
+			},
+			expectedRoleRules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{""},
+					Resources: []string{"configmaps"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+			expectedClusterRules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{""},
+					Resources: []string{"namespaces"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+		},
+		{
+			name: "unknown resource falls back to cluster role",
+			rules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{"unknown"},
+					Resources: []string{"custom-resource"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+			gvl: map[schema.GroupVersion]*metav1.APIResourceList{
+				{Group: "", Version: "v1"}: {
+					APIResources: []metav1.APIResource{
+						{
+							Name:       "configmaps",
+							Group:      "",
+							Namespaced: true,
+						},
+					},
+				},
+			},
+			expectedRoleRules: nil,
+			expectedClusterRules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{"unknown"},
+					Resources: []string{"custom-resource"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+		},
+		{
+			name: "multiple group versions in gvl",
+			rules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{"apps"},
+					Resources: []string{"deployments"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+			gvl: map[schema.GroupVersion]*metav1.APIResourceList{
+				{Group: "apps", Version: "v1"}: {
+					APIResources: []metav1.APIResource{
+						{
+							Name:       "deployments",
+							Group:      "apps",
+							Namespaced: true,
+						},
+					},
+				},
+				{Group: "apps", Version: "v1beta1"}: {
+					APIResources: []metav1.APIResource{
+						{
+							Name:       "deployments",
+							Group:      "apps",
+							Namespaced: true,
+						},
+					},
+				},
+			},
+			expectedRoleRules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{"apps"},
+					Resources: []string{"deployments"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+			expectedClusterRules: nil,
+		},
+		{
+			name: "resource exists in multiple API groups",
+			rules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{"networking.k8s.io"},
+					Resources: []string{"ingresses"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+			gvl: map[schema.GroupVersion]*metav1.APIResourceList{
+				{Group: "networking.k8s.io", Version: "v1"}: {
+					APIResources: []metav1.APIResource{
+						{
+							Name:       "ingresses",
+							Group:      "networking.k8s.io",
+							Namespaced: true,
+						},
+					},
+				},
+				{Group: "extensions", Version: "v1beta1"}: {
+					APIResources: []metav1.APIResource{
+						{
+							Name:       "ingresses",
+							Group:      "extensions",
+							Namespaced: true,
+						},
+					},
+				},
+			},
+			expectedRoleRules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{"networking.k8s.io"},
+					Resources: []string{"ingresses"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+			expectedClusterRules: nil,
+		},
+		{
+			name: "multiple resources with different scopes",
+			rules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{"rbac.authorization.k8s.io"},
+					Resources: []string{"roles", "clusterroles"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+			gvl: map[schema.GroupVersion]*metav1.APIResourceList{
+				{Group: "rbac.authorization.k8s.io", Version: "v1"}: {
+					APIResources: []metav1.APIResource{
+						{
+							Name:       "roles",
+							Group:      "rbac.authorization.k8s.io",
+							Namespaced: true,
+						},
+						{
+							Name:       "clusterroles",
+							Group:      "rbac.authorization.k8s.io",
+							Namespaced: false,
+						},
+					},
+				},
+			},
+			expectedRoleRules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{"rbac.authorization.k8s.io"},
+					Resources: []string{"roles"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+			expectedClusterRules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{"rbac.authorization.k8s.io"},
+					Resources: []string{"clusterroles"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+		},
+		{
+			name: "not found in gvl",
+			rules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{"rbac.authorization.k8s.io"},
+					Resources: []string{"clusterroles"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+			gvl: map[schema.GroupVersion]*metav1.APIResourceList{
+				{Group: "rbac.authorization.k8s.io", Version: "v1"}: {
+					APIResources: []metav1.APIResource{
+						{
+							Name:       "roles",
+							Group:      "rbac.authorization.k8s.io",
+							Namespaced: true,
+						},
+					},
+				},
+			},
+			expectedClusterRules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{"rbac.authorization.k8s.io"},
+					Resources: []string{"clusterroles"},
+					Verbs:     []string{"get", "list"},
+				},
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			clusterRole := &rbacv1.ClusterRole{
+				Rules: tc.rules,
+			}
+			roleRules, clusterRules := processClusterRole(clusterRole, tc.gvl)
+
+			// Compare role rules
+			if len(tc.expectedRoleRules) == 0 {
+				require.Empty(t, roleRules)
+			} else {
+				require.Equal(t, tc.expectedRoleRules, roleRules)
+			}
+
+			// Compare cluster rules
+			if len(tc.expectedClusterRules) == 0 {
+				require.Empty(t, clusterRules)
+			} else {
+				require.Equal(t, tc.expectedClusterRules, clusterRules)
+			}
+		})
+	}
+}
diff --git a/controller/controlplane/controller_watch.go b/controller/controlplane/controller_watch.go
index ef8da0320a..ba6e6e847a 100644
--- a/controller/controlplane/controller_watch.go
+++ b/controller/controlplane/controller_watch.go
@@ -2,6 +2,7 @@ package controlplane
 
 import (
 	"context"
+	"fmt"
 	"reflect"
 
 	"github.com/samber/lo"
@@ -288,3 +289,57 @@ func (r *Reconciler) listControlPlanesForReferenceGrants(
 
 	return recs
 }
+
+func listControlPlanesFor[
+	T *rbacv1.Role | *rbacv1.RoleBinding,
+](
+	ctx context.Context,
+	obj client.Object,
+) []reconcile.Request {
+	if _, ok := obj.(T); !ok {
+		var t T
+		ctrllog.FromContext(ctx).Error(
+			operatorerrors.ErrUnexpectedObject,
+			fmt.Sprintf("failed to map %T on ControlPlane", t),
+			"expected", reflect.TypeOf(t), "found", reflect.TypeOf(obj),
+		)
+		return nil
+	}
+
+	nn, ok := isManagedByControlPlane(obj)
+	if !ok {
+		return nil
+	}
+
+	return []reconcile.Request{
+		{
+			NamespacedName: nn,
+		},
+	}
+}
+
+func isManagedByControlPlane(
+	obj client.Object,
+) (types.NamespacedName, bool) {
+	labels := obj.GetLabels()
+	if len(labels) == 0 {
+		return types.NamespacedName{}, false
+	}
+	managedBy, ok := labels[consts.GatewayOperatorManagedByLabel]
+	if !ok || managedBy != consts.ControlPlaneManagedLabelValue {
+		return types.NamespacedName{}, false
+	}
+
+	managedByName, ok := labels[consts.GatewayOperatorManagedByNameLabel]
+	if !ok {
+		return types.NamespacedName{}, false
+	}
+	managedByNamespace, ok := labels[consts.GatewayOperatorManagedByNamespaceLabel]
+	if !ok {
+		return types.NamespacedName{}, false
+	}
+	return types.NamespacedName{
+		Namespace: managedByNamespace,
+		Name:      managedByName,
+	}, true
+}
diff --git a/controller/controlplane/controlplane_controller_reconciler_utils_test.go b/controller/controlplane/controlplane_controller_reconciler_utils_test.go
index cac3713823..5cb2c1e7b5 100644
--- a/controller/controlplane/controlplane_controller_reconciler_utils_test.go
+++ b/controller/controlplane/controlplane_controller_reconciler_utils_test.go
@@ -125,7 +125,8 @@ func TestEnsureClusterRole(t *testing.T) {
 		}
 
 		t.Run(tc.Name, func(t *testing.T) {
-			createdOrUpdated, generatedClusterRole, err := r.ensureClusterRole(t.Context(), &tc.controlplane)
+			// NOTE: we pass in nil as we don't test watch namespaces here.
+			createdOrUpdated, generatedClusterRole, err := r.ensureClusterRole(t.Context(), &tc.controlplane, clusterRole)
 			require.Equal(t, tc.err, err)
 			require.Equal(t, tc.createdorUpdated, createdOrUpdated)
 			require.Equal(t, tc.expectedClusterRole.Rules, generatedClusterRole.Rules)
diff --git a/controller/controlplane/discovery_client.go b/controller/controlplane/discovery_client.go
new file mode 100644
index 0000000000..e7fb4d6242
--- /dev/null
+++ b/controller/controlplane/discovery_client.go
@@ -0,0 +1,56 @@
+package controlplane
+
+import (
+	"sync"
+	"time"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/discovery"
+	"k8s.io/client-go/rest"
+)
+
+// CachedDiscoveryClient is a wrapper around the discovery client that caches the API resources
+// for a period of time.
+type CachedDiscoveryClient struct {
+	cl *discovery.DiscoveryClient
+
+	lastLookupTime time.Time
+	period         time.Duration
+	lock           sync.RWMutex
+	apiResourceMap map[schema.GroupVersion]*metav1.APIResourceList
+}
+
+// NewDiscoveryClient creates a new CachedDiscoveryClient.
+func NewDiscoveryClient(cfg *rest.Config, period time.Duration) *CachedDiscoveryClient {
+	return &CachedDiscoveryClient{
+		period: period,
+		cl:     discovery.NewDiscoveryClientForConfigOrDie(cfg),
+	}
+}
+
+// GetAPIResourceListMapping returns the GroupVersion to API resources map.
+func (c *CachedDiscoveryClient) GetAPIResourceListMapping() (map[schema.GroupVersion]*metav1.APIResourceList, error) {
+	c.lock.RLock()
+	isNil := c.apiResourceMap == nil
+	c.lock.RUnlock()
+
+	if isNil || time.Since(c.lastLookupTime) > c.period {
+		if err := c.refresh(); err != nil {
+			return nil, err
+		}
+	}
+	return c.apiResourceMap, nil
+}
+
+func (c *CachedDiscoveryClient) refresh() error {
+	_, s, _, err := c.cl.GroupsAndMaybeResources()
+	if err != nil {
+		return err
+	}
+	c.lock.Lock()
+	c.apiResourceMap = s
+	c.lock.Unlock()
+	c.lastLookupTime = time.Now()
+	return nil
+}
diff --git a/modules/manager/controller_setup.go b/modules/manager/controller_setup.go
index 50bd9816f4..48404e239c 100644
--- a/modules/manager/controller_setup.go
+++ b/modules/manager/controller_setup.go
@@ -453,6 +453,7 @@ func SetupControllers(mgr manager.Manager, c *Config) (map[string]ControllerDef,
 		ControlPlaneControllerName: {
 			Enabled: c.GatewayControllerEnabled || c.ControlPlaneControllerEnabled,
 			Controller: &controlplane.Reconciler{
+				DiscoveryClient:           controlplane.NewDiscoveryClient(mgr.GetConfig(), time.Minute),
 				Client:                    mgr.GetClient(),
 				Scheme:                    mgr.GetScheme(),
 				ClusterCASecretName:       c.ClusterCASecretName,
diff --git a/pkg/consts/consts.go b/pkg/consts/consts.go
index 45fa991753..1b0a335894 100644
--- a/pkg/consts/consts.go
+++ b/pkg/consts/consts.go
@@ -40,6 +40,12 @@ const (
 	// Kubernetes API.
 	GatewayOperatorManagedByNamespaceLabel = OperatorLabelPrefix + "managed-by-namespace"
 
+	// GatewayOperatorOwnerUIDControlPlane is the label that is used for objects
+	// to indicate a ControlPlane resource is the owner of the object.
+	// The value set for this label is the UID of the ControlPlane resource that
+	// owns the object.
+	GatewayOperatorOwnerUIDControlPlane = OperatorLabelPrefix + "controlplane-owner-uid"
+
 	// GatewayManagedLabelValue indicates that the object's lifecycle is managed by
 	// the gateway controller.
 	GatewayManagedLabelValue = "gateway"
diff --git a/pkg/utils/kubernetes/lists.go b/pkg/utils/kubernetes/lists.go
index cb2eb4c51f..53882e61f2 100644
--- a/pkg/utils/kubernetes/lists.go
+++ b/pkg/utils/kubernetes/lists.go
@@ -184,6 +184,27 @@ func ListServiceAccountsForOwner(
 	return serviceAccounts, nil
 }
 
+// ListRoles is a helper function which gets a list of Roles
+// using the provided list options.
+func ListRoles(
+	ctx context.Context,
+	c client.Client,
+	listOpts ...client.ListOption,
+) ([]rbacv1.Role, error) {
+	roleList := &rbacv1.RoleList{}
+
+	err := c.List(
+		ctx,
+		roleList,
+		listOpts...,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	return roleList.Items, nil
+}
+
 // ListClusterRoles is a helper function which gets a list of ClusterRoles
 // using the provided list options.
 func ListClusterRoles(
@@ -226,6 +247,27 @@ func ListClusterRoleBindings(
 	return clusterRoleBindingList.Items, nil
 }
 
+// ListRoleBindings is a helper function which gets a list of RoleBindings
+// using the provided list options.
+func ListRoleBindings(
+	ctx context.Context,
+	c client.Client,
+	listOpts ...client.ListOption,
+) ([]rbacv1.RoleBinding, error) {
+	roleBindingList := &rbacv1.RoleBindingList{}
+
+	err := c.List(
+		ctx,
+		roleBindingList,
+		listOpts...,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	return roleBindingList.Items, nil
+}
+
 // ListConfigMapsForOwner is a helper function which gets a list of ConfigMaps
 // using the provided list options and reduce by OwnerReference UID to efficiently
 // list only the objects owned by the provided UID.
diff --git a/pkg/utils/kubernetes/reduce/filters.go b/pkg/utils/kubernetes/reduce/filters.go
index 6478a613df..aea25723b1 100644
--- a/pkg/utils/kubernetes/reduce/filters.go
+++ b/pkg/utils/kubernetes/reduce/filters.go
@@ -1,6 +1,8 @@
 package reduce
 
 import (
+	"slices"
+
 	"github.com/samber/lo"
 	admregv1 "k8s.io/api/admissionregistration/v1"
 	appsv1 "k8s.io/api/apps/v1"
@@ -94,24 +96,56 @@ func filterClusterRoles(clusterRoles []rbacv1.ClusterRole) []rbacv1.ClusterRole
 	return append(clusterRoles[:best], clusterRoles[best+1:]...)
 }
 
+// -----------------------------------------------------------------------------
+// Filter functions - Roles
+// -----------------------------------------------------------------------------
+
+// filterRoles filters out the Role to be kept and returns
+// all the Roles to be deleted.
+// The filtered-out Role is decided as follows:
+//  1. creationTimestamp (newer is better, because newer Roles can contain new policy rules)
+func filterRoles(roles []rbacv1.Role) []rbacv1.Role {
+	if len(roles) == 1 {
+		return []rbacv1.Role{}
+	}
+
+	best := 0
+	for i, cr := range roles {
+		if cr.CreationTimestamp.After(roles[best].CreationTimestamp.Time) {
+			best = i
+		}
+	}
+
+	return slices.Delete(roles, best, best+1)
+}
+
 // -----------------------------------------------------------------------------
 // Filter functions - ClusterRoleBindings
 // -----------------------------------------------------------------------------
 
-// filterClusterRoleBindings filters out the ClusterRoleBinding to be kept and returns
-// all the ClusterRoleBindings to be deleted.
-// The filtered-out ClusterRoleBinding is decided as follows:
-// 1. creationTimestamp (older is better)
-func filterClusterRoleBindings(clusterRoleBindings []rbacv1.ClusterRoleBinding) []rbacv1.ClusterRoleBinding {
+// filterRoleBindings filters out the ClusterRoleBinding or RoleBindings to be kept and returns
+// all the objects to be deleted.
+func filterRoleBindings[
+	T interface {
+		rbacv1.ClusterRoleBinding | rbacv1.RoleBinding
+	},
+	TPtr interface {
+		*T
+		GetLabels() map[string]string
+		GetCreationTimestamp() metav1.Time
+	},
+](clusterRoleBindings []T) []T {
 	if len(clusterRoleBindings) < 2 {
-		return []rbacv1.ClusterRoleBinding{}
+		return []T{}
 	}
 
 	oldestWithManagedByLabels := -1
 	oldestLegacy := -1
 	for i, clusterRoleBinding := range clusterRoleBindings {
-		labels := clusterRoleBinding.GetLabels()
+		ptr := TPtr(&clusterRoleBinding)
+		creationTS := ptr.GetCreationTimestamp()
 
+		labels := ptr.GetLabels()
 		_, okManagedBy := labels[consts.GatewayOperatorManagedByLabel]
 		_, okManagedByNs := labels[consts.GatewayOperatorManagedByNamespaceLabel]
 		_, okManagedByName := labels[consts.GatewayOperatorManagedByNameLabel]
@@ -121,7 +155,10 @@ func filterClusterRoleBindings(clusterRoleBindings []rbacv1.ClusterRoleBinding)
 				continue
 			}
 
-			if clusterRoleBinding.CreationTimestamp.Before(&clusterRoleBindings[oldestWithManagedByLabels].CreationTimestamp) {
+			ptrOldest := TPtr(&clusterRoleBindings[oldestWithManagedByLabels])
+			creationTSOldest := ptrOldest.GetCreationTimestamp()
+
+			if creationTS.Before(&creationTSOldest) {
 				oldestWithManagedByLabels = i
 			}
 			continue
@@ -132,7 +169,9 @@ func filterClusterRoleBindings(clusterRoleBindings []rbacv1.ClusterRoleBinding)
 			continue
 		}
 
-		if clusterRoleBinding.CreationTimestamp.Before(&clusterRoleBindings[oldestLegacy].CreationTimestamp) {
+		ptrOldestLegacy := TPtr(&clusterRoleBindings[oldestLegacy])
+		creationTSOldestLegacy := ptrOldestLegacy.GetCreationTimestamp()
+		if creationTS.Before(&creationTSOldestLegacy) {
 			oldestLegacy = i
 		}
 		continue
diff --git a/pkg/utils/kubernetes/reduce/reduce.go b/pkg/utils/kubernetes/reduce/reduce.go
index 749e93d273..451a04e0f7 100644
--- a/pkg/utils/kubernetes/reduce/reduce.go
+++ b/pkg/utils/kubernetes/reduce/reduce.go
@@ -60,12 +60,28 @@ func ReduceClusterRoles(ctx context.Context, k8sClient client.Client, clusterRol
 	return clientops.DeleteAll(ctx, k8sClient, filteredClusterRoles)
 }
 
+// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=delete
+
+// ReduceRoles detects the best Role in the set and deletes all the others.
+func ReduceRoles(ctx context.Context, k8sClient client.Client, roles []rbacv1.Role) error {
+	filteredRoles := filterRoles(roles)
+	return clientops.DeleteAll(ctx, k8sClient, filteredRoles)
+}
+
 // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterrolebindings,verbs=delete
 
 // ReduceClusterRoleBindings detects the best ClusterRoleBinding in the set and deletes all the others.
 func ReduceClusterRoleBindings(ctx context.Context, k8sClient client.Client, clusterRoleBindings []rbacv1.ClusterRoleBinding) error {
-	filteredCLusterRoleBindings := filterClusterRoleBindings(clusterRoleBindings)
-	return clientops.DeleteAll(ctx, k8sClient, filteredCLusterRoleBindings)
+	filteredClusterRoleBindings := filterRoleBindings(clusterRoleBindings)
+	return clientops.DeleteAll(ctx, k8sClient, filteredClusterRoleBindings)
+}
+
+// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=delete
+
+// ReduceRoleBindings detects the best RoleBinding in the set and deletes all the others.
+func ReduceRoleBindings(ctx context.Context, k8sClient client.Client, roleBindings []rbacv1.RoleBinding) error {
+	filteredRoleBindings := filterRoleBindings(roleBindings)
+	return clientops.DeleteAll(ctx, k8sClient, filteredRoleBindings)
 }
 
 // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=delete
diff --git a/pkg/utils/kubernetes/reduce/reduce_test.go b/pkg/utils/kubernetes/reduce/reduce_test.go
new file mode 100644
index 0000000000..25a3773118
--- /dev/null
+++ b/pkg/utils/kubernetes/reduce/reduce_test.go
@@ -0,0 +1,159 @@
+package reduce_test
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	rbacv1 "k8s.io/api/rbac/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	"github.com/kong/gateway-operator/pkg/consts"
+	"github.com/kong/gateway-operator/pkg/utils/kubernetes/reduce"
+)
+
+func TestReduceClusterRoleBindings(t *testing.T) {
+	testCases := []struct {
+		name                   string
+		clusterRoleBindings    []rbacv1.ClusterRoleBinding
+		expectedDeletedCount   int
+		expectedRemainingNames []string
+	}{
+		{
+			name:                   "empty list returns no error",
+			clusterRoleBindings:    []rbacv1.ClusterRoleBinding{},
+			expectedDeletedCount:   0,
+			expectedRemainingNames: []string{},
+		},
+		{
+			name: "single ClusterRoleBinding is preserved",
+			clusterRoleBindings: []rbacv1.ClusterRoleBinding{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "crb-1",
+					},
+				},
+			},
+			expectedDeletedCount:   0,
+			expectedRemainingNames: []string{"crb-1"},
+		},
+		{
+			name: "multiple ClusterRoleBindings with different creation timestamps - oldest is preserved",
+			clusterRoleBindings: []rbacv1.ClusterRoleBinding{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:              "crb-1",
+						CreationTimestamp: metav1.NewTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)),
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:              "crb-2",
+						CreationTimestamp: metav1.NewTime(time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC)),
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:              "crb-3",
+						CreationTimestamp: metav1.NewTime(time.Date(2022, 12, 30, 0, 0, 0, 0, time.UTC)),
+					},
+				},
+			},
+			expectedDeletedCount:   2,
+			expectedRemainingNames: []string{"crb-3"},
+		},
+		{
+			name: "multiple ClusterRoleBindings, one with managed by label remains",
+			clusterRoleBindings: []rbacv1.ClusterRoleBinding{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:              "crb-1",
+						CreationTimestamp: metav1.NewTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)),
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:              "crb-2",
+						CreationTimestamp: metav1.NewTime(time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC)),
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:              "crb-3",
+						CreationTimestamp: metav1.NewTime(time.Date(2022, 12, 30, 0, 0, 0, 0, time.UTC)),
+						Labels: map[string]string{
+							consts.GatewayOperatorManagedByLabel: consts.ControlPlaneManagedLabelValue,
+						},
+					},
+				},
+			},
+			expectedDeletedCount:   2,
+			expectedRemainingNames: []string{"crb-3"},
+		},
+		{
+			name: "multiple ClusterRoleBindings, one with managed by label remains",
+			clusterRoleBindings: []rbacv1.ClusterRoleBinding{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:              "crb-1",
+						CreationTimestamp: metav1.NewTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)),
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:              "crb-2",
+						CreationTimestamp: metav1.NewTime(time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC)),
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:              "crb-3",
+						CreationTimestamp: metav1.NewTime(time.Date(2022, 12, 30, 0, 0, 0, 0, time.UTC)),
+						Labels: map[string]string{
+							consts.GatewayOperatorManagedByLabel: consts.ControlPlaneManagedLabelValue,
+						},
+					},
+				},
+			},
+			expectedDeletedCount:   2,
+			expectedRemainingNames: []string{"crb-3"},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			objs := []client.Object{}
+			for i := range tc.clusterRoleBindings {
+				objs = append(objs, &tc.clusterRoleBindings[i])
+			}
+
+			cl := fake.NewClientBuilder().WithObjects(objs...).Build()
+			err := reduce.ReduceClusterRoleBindings(t.Context(), cl, tc.clusterRoleBindings)
+			require.NoError(t, err)
+
+			// Verify which ClusterRoleBindings remain
+			var remainingBindings rbacv1.ClusterRoleBindingList
+			require.NoError(t, cl.List(t.Context(), &remainingBindings))
+
+			require.Len(t, remainingBindings.Items, len(tc.expectedRemainingNames))
+
+			// Check the expected ClusterRoleBinding(s) remain
+			for _, name := range tc.expectedRemainingNames {
+				remaining := false
+				for _, crb := range remainingBindings.Items {
+					if crb.Name == name {
+						remaining = true
+						break
+					}
+				}
+				assert.True(t, remaining, "Expected ClusterRoleBinding %s to remain but it was deleted", name)
+			}
+
+			// Also verify expected number of deleted ClusterRoleBindings
+			assert.Equal(t, tc.expectedDeletedCount, len(tc.clusterRoleBindings)-len(remainingBindings.Items))
+		})
+	}
+}
diff --git a/pkg/utils/kubernetes/resources/controlplane_role.go b/pkg/utils/kubernetes/resources/controlplane_role.go
new file mode 100644
index 0000000000..f51b752694
--- /dev/null
+++ b/pkg/utils/kubernetes/resources/controlplane_role.go
@@ -0,0 +1,32 @@
+package resources
+
+import (
+	"fmt"
+
+	rbacv1 "k8s.io/api/rbac/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	k8sutils "github.com/kong/gateway-operator/pkg/utils/kubernetes"
+
+	operatorv1beta1 "github.com/kong/kubernetes-configuration/api/gateway-operator/v1beta1"
+)
+
+// GenerateNewRoleForControlPlane generates a new Role in provided namespace for
+// provided ControlPlane.
+func GenerateNewRoleForControlPlane(
+	cp *operatorv1beta1.ControlPlane, namespace string, rules []rbacv1.PolicyRule,
+) *rbacv1.Role {
+	role := &rbacv1.Role{
+		ObjectMeta: metav1.ObjectMeta{
+			GenerateName: k8sutils.TrimGenerateName(fmt.Sprintf("%s-", cp.GetName())),
+			Namespace:    namespace,
+			Labels: map[string]string{
+				"app": cp.GetName(),
+			},
+		},
+		Rules: rules,
+	}
+
+	LabelObjectAsControlPlaneManaged(role)
+	return role
+}
diff --git a/pkg/utils/kubernetes/resources/controlplane_role_test.go b/pkg/utils/kubernetes/resources/controlplane_role_test.go
new file mode 100644
index 0000000000..0bbcb90e70
--- /dev/null
+++ b/pkg/utils/kubernetes/resources/controlplane_role_test.go
@@ -0,0 +1,95 @@
+package resources
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	rbacv1 "k8s.io/api/rbac/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	k8sutils "github.com/kong/gateway-operator/pkg/utils/kubernetes"
+
+	operatorv1beta1 "github.com/kong/kubernetes-configuration/api/gateway-operator/v1beta1"
+)
+
+func TestGenerateNewRoleForControlPlane(t *testing.T) {
+	cp := &operatorv1beta1.ControlPlane{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "test-controlplane",
+			UID:  "12345",
+		},
+	}
+
+	testCases := []struct {
+		name             string
+		controlplaneName string
+		namespace        string
+		rules            []rbacv1.PolicyRule
+		expectedRole     *rbacv1.Role
+	}{
+		{
+			name:             "generates role with empty rules",
+			controlplaneName: "test-controlplane",
+			namespace:        "test-namespace",
+			rules:            []rbacv1.PolicyRule{},
+			expectedRole: &rbacv1.Role{
+				ObjectMeta: metav1.ObjectMeta{
+					GenerateName: k8sutils.TrimGenerateName(fmt.Sprintf("%s-", "test-controlplane")),
+					Namespace:    "test-namespace",
+					Labels: map[string]string{
+						"app":                                    "test-controlplane",
+						"gateway-operator.konghq.com/managed-by": "controlplane",
+					},
+				},
+				Rules: []rbacv1.PolicyRule{},
+			},
+		},
+		{
+			name:             "generates role with multiple rules",
+			controlplaneName: "cp-with-rules",
+			namespace:        "default",
+			rules: []rbacv1.PolicyRule{
+				{
+					APIGroups: []string{""},
+					Resources: []string{"pods"},
+					Verbs:     []string{"get", "list", "watch"},
+				},
+				{
+					APIGroups: []string{"apps"},
+					Resources: []string{"deployments"},
+					Verbs:     []string{"get", "update"},
+				},
+			},
+			expectedRole: &rbacv1.Role{
+				ObjectMeta: metav1.ObjectMeta{
+					GenerateName: k8sutils.TrimGenerateName(fmt.Sprintf("%s-", "test-controlplane")),
+					Namespace:    "default",
+					Labels: map[string]string{
+						"app":                                    "test-controlplane",
+						"gateway-operator.konghq.com/managed-by": "controlplane",
+					},
+				},
+				Rules: []rbacv1.PolicyRule{
+					{
+						APIGroups: []string{""},
+						Resources: []string{"pods"},
+						Verbs:     []string{"get", "list", "watch"},
+					},
+					{
+						APIGroups: []string{"apps"},
+						Resources: []string{"deployments"},
+						Verbs:     []string{"get", "update"},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			role := GenerateNewRoleForControlPlane(cp, tc.namespace, tc.rules)
+			require.Equal(t, tc.expectedRole, role)
+		})
+	}
+}
diff --git a/pkg/utils/kubernetes/resources/labels.go b/pkg/utils/kubernetes/resources/labels.go
index 0098bd1931..d80c1383c3 100644
--- a/pkg/utils/kubernetes/resources/labels.go
+++ b/pkg/utils/kubernetes/resources/labels.go
@@ -14,47 +14,36 @@ import (
 // provided object to signal that it's owned by a DataPlane resource
 // and that its lifecycle is managed by this operator.
 func LabelObjectAsDataPlaneManaged(obj metav1.Object) {
-	labels := obj.GetLabels()
-	if labels == nil {
-		labels = make(map[string]string)
-	}
-	labels[consts.GatewayOperatorManagedByLabel] = consts.DataPlaneManagedLabelValue
-	obj.SetLabels(labels)
+	setLabel(obj, consts.GatewayOperatorManagedByLabel, consts.DataPlaneManagedLabelValue)
 }
 
 // LabelObjectAsKongPluginInstallationManaged ensures that labels are set on the
 // provided object to signal that it's owned by a KongPluginInstallation
 // resource and that its lifecycle is managed by this operator.
 func LabelObjectAsKongPluginInstallationManaged(obj metav1.Object) {
-	labels := obj.GetLabels()
-	if labels == nil {
-		labels = make(map[string]string)
-	}
-	labels[consts.GatewayOperatorManagedByLabel] = consts.KongPluginInstallationManagedLabelValue
-	obj.SetLabels(labels)
+	setLabel(obj, consts.GatewayOperatorManagedByLabel, consts.KongPluginInstallationManagedLabelValue)
 }
 
 // LabelObjectAsKonnectExtensionManaged ensures that labels are set on the
 // provided object to signal that it's owned by a KonnectExtension resource
 // and that its lifecycle is managed by this operator.
 func LabelObjectAsKonnectExtensionManaged(obj metav1.Object) {
-	labels := obj.GetLabels()
-	if labels == nil {
-		labels = make(map[string]string)
-	}
-	labels[consts.GatewayOperatorManagedByLabel] = consts.KonnectExtensionManagedByLabelValue
-	obj.SetLabels(labels)
+	setLabel(obj, consts.GatewayOperatorManagedByLabel, consts.KonnectExtensionManagedByLabelValue)
 }
 
 // LabelObjectAsControlPlaneManaged ensures that labels are set on the
 // provided object to signal that it's owned by a ControlPlane resource and that its
 // lifecycle is managed by this operator.
 func LabelObjectAsControlPlaneManaged(obj metav1.Object) {
+	setLabel(obj, consts.GatewayOperatorManagedByLabel, consts.ControlPlaneManagedLabelValue)
+}
+
+func setLabel(obj metav1.Object, key string, value string) { //nolint:unparam
 	labels := obj.GetLabels()
 	if labels == nil {
 		labels = make(map[string]string)
 	}
-	labels[consts.GatewayOperatorManagedByLabel] = consts.ControlPlaneManagedLabelValue
+	labels[key] = value
 	obj.SetLabels(labels)
 }
 
diff --git a/pkg/utils/kubernetes/resources/rolebindings.go b/pkg/utils/kubernetes/resources/rolebindings.go
new file mode 100644
index 0000000000..e13efcc355
--- /dev/null
+++ b/pkg/utils/kubernetes/resources/rolebindings.go
@@ -0,0 +1,66 @@
+package resources
+
+import (
+	"fmt"
+
+	"github.com/samber/lo"
+	rbacv1 "k8s.io/api/rbac/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	k8stypes "k8s.io/apimachinery/pkg/types"
+
+	"github.com/kong/gateway-operator/pkg/consts"
+	k8sutils "github.com/kong/gateway-operator/pkg/utils/kubernetes"
+
+	operatorv1beta1 "github.com/kong/kubernetes-configuration/api/gateway-operator/v1beta1"
+)
+
+// -----------------------------------------------------------------------------
+// RoleBinding generators
+// -----------------------------------------------------------------------------
+
+// GenerateNewRoleBindingForControlPlane is a helper to generate a RoleBinding
+// resource to bind roles to the service account used by the controlplane deployment.
+func GenerateNewRoleBindingForControlPlane(
+	cp *operatorv1beta1.ControlPlane,
+	serviceAccountName string,
+	roleNN k8stypes.NamespacedName,
+) *rbacv1.RoleBinding {
+	crb := &rbacv1.RoleBinding{
+		ObjectMeta: metav1.ObjectMeta{
+			GenerateName: k8sutils.TrimGenerateName(fmt.Sprintf("%s-%s-", consts.ControlPlanePrefix, cp.GetName())),
+			Namespace:    roleNN.Namespace,
+			Labels: map[string]string{
+				"app": cp.GetName(),
+			},
+		},
+		RoleRef: rbacv1.RoleRef{
+			APIGroup: "rbac.authorization.k8s.io",
+			Kind:     "Role",
+			Name:     roleNN.Name,
+		},
+		Subjects: []rbacv1.Subject{
+			{
+				Kind:      "ServiceAccount",
+				Name:      serviceAccountName,
+				Namespace: cp.GetNamespace(),
+			},
+		},
+	}
+	LabelObjectAsControlPlaneManaged(crb)
+	return crb
+}
+
+// CompareRoleName compares RoleRef in RoleBinding with given cluster role name.
+// It returns true if the referenced role is the cluster role with the given name.
+func CompareRoleName(existingRoleBinding *rbacv1.RoleBinding, roleName string) bool {
+	return existingRoleBinding.RoleRef.APIGroup == "rbac.authorization.k8s.io" &&
+		existingRoleBinding.RoleRef.Kind == "Role" &&
+		existingRoleBinding.RoleRef.Name == roleName
+}
+
+// RoleBindingContainsServiceAccount returns true if the subjects of the RoleBinding contains given service account.
+func RoleBindingContainsServiceAccount(existingRoleBinding *rbacv1.RoleBinding, namespace string, serviceAccountName string) bool {
+	return lo.ContainsBy(existingRoleBinding.Subjects, func(s rbacv1.Subject) bool {
+		return s.Kind == "ServiceAccount" && s.Namespace == namespace && s.Name == serviceAccountName
+	})
+}
diff --git a/test/e2e/test_helm_install_upgrade.go b/test/e2e/test_helm_install_upgrade.go
index 6812cde220..6be50056c1 100644
--- a/test/e2e/test_helm_install_upgrade.go
+++ b/test/e2e/test_helm_install_upgrade.go
@@ -141,9 +141,9 @@ func TestHelmUpgrade(t *testing.T) {
 			name:             "upgrade from latest minor to current",
 			fromVersion:      "1.5.1", // renovate: datasource=docker packageName=kong/gateway-operator-oss depName=kong/gateway-operator-oss
 			upgradeToCurrent: true,
-			// This is the effective semver of a next release. It's needed for the chart to properly render
-			// semver-conditional templates.
-			upgradeToEffectiveSemver: "1.5.0",
+			// This is the effective semver of a next release.
+			// It's needed for the chart to properly render semver-conditional templates.
+			upgradeToEffectiveSemver: "1.6.0",
 			objectsToDeploy: []client.Object{
 				&operatorv1beta1.GatewayConfiguration{
 					ObjectMeta: metav1.ObjectMeta{
@@ -215,6 +215,9 @@ func TestHelmUpgrade(t *testing.T) {
 			name:             "upgrade from nightly to current",
 			fromVersion:      "nightly",
 			upgradeToCurrent: true,
+			// This is the effective semver of a next release.
+			// It's needed for the chart to properly render semver-conditional templates.
+			upgradeToEffectiveSemver: "1.6.0",
 			objectsToDeploy: []client.Object{
 				&operatorv1beta1.GatewayConfiguration{
 					ObjectMeta: metav1.ObjectMeta{


Reviews:
user: login: mlavacca
body: What I am getting from this implementation is the following use of the `ReferenceGrant` resource: we are "abstracting" the whole set of resources (services, routes, ingress, etc.) that have to be reconciled by KGO with the Namespace kind. This means that if I want to cross the namespace boundaries with the CP, I need to create a RG very similar to the one you posted in the CHANGELOG, and that will act as a "allow-all-kinds" for the namespace where the RG lives.

Is that correct? 
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T09:51:04Z
user: login: pmalek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T10:17:41Z
user: login: pmalek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T10:19:42Z
user: login: mlavacca
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T11:12:03Z
user: login: programmer04
body: LGTM some minor suggestions
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T12:33:08Z
user: login: programmer04
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-24T12:33:21Z
user: login: pmalek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T12:43:16Z
user: login: programmer04
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-24T13:00:48Z
user: login: pmalek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T13:09:50Z
user: login: programmer04
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-24T13:12:27Z

Review comments:
diff_hunk: @@ -47,9 +47,31 @@
 - Added `spec.watchNamespace` field to `ControlPlane` CRD to allow watching resources
   only in the specified namespace.
   When `spec.watchNamespace.type=list` is used, each specified namespace requires
-  a `ReferenceGrant` in the namespace of the `ControlPlane` that allows the `ControlPlane`
-  to watch resources in the specified namespace.
+  a `ReferenceGrant` that allows the `ControlPlane` to watch resources in the specified namespace.
+  For example:
+
+  ```yaml
+  apiVersion: gateway.networking.k8s.io/v1beta1
+  kind: ReferenceGrant
+  metadata:
+    name: ref
+    namespace: namespace-a
+  spec:
+    from:
+    - group: gateway-operator.konghq.com
+      kind: ControlPlane
+      namespace: controlplane-namespace
+    to:
+    - group: ""
+      kind: Namespace
+      name: namespace-a
+  ```
path: CHANGELOG.md
user: login: mlavacca
body: I am not sure about having this example directly in the CHANGELOG, as it can become more challenging to navigate. We could add it to the documentation and link the docs page here instead. WDYT?
created_at: 2025-04-15T09:36:29Z
author_association: MEMBER
diff_hunk: @@ -94,24 +94,56 @@ func filterClusterRoles(clusterRoles []rbacv1.ClusterRole) []rbacv1.ClusterRole
 	return append(clusterRoles[:best], clusterRoles[best+1:]...)
 }
 
+// -----------------------------------------------------------------------------
+// Filter functions - Roles
+// -----------------------------------------------------------------------------
+
+// filterRoles filters out the Role to be kept and returns
+// all the Roles to be deleted.
+// The filtered-out Role is decided as follows:
+//  1. creationTimestamp (newer is better, because newer Roles can contain new policy rules)
+func filterRoles(roles []rbacv1.Role) []rbacv1.Role {
+	if len(roles) == 1 {
+		return []rbacv1.Role{}
+	}
+
+	best := 0
+	for i, cr := range roles {
+		if cr.CreationTimestamp.After(roles[best].CreationTimestamp.Time) {
+			best = i
+		}
+	}
+
+	return append(roles[:best], roles[best+1:]...)
+}
+
 // -----------------------------------------------------------------------------
 // Filter functions - ClusterRoleBindings
 // -----------------------------------------------------------------------------
 
-// filterClusterRoleBindings filters out the ClusterRoleBinding to be kept and returns
-// all the ClusterRoleBindings to be deleted.
-// The filtered-out ClusterRoleBinding is decided as follows:
-// 1. creationTimestamp (older is better)
-func filterClusterRoleBindings(clusterRoleBindings []rbacv1.ClusterRoleBinding) []rbacv1.ClusterRoleBinding {
+// filterBindings filters out the ClusterRoleBinding or RoleBindings to be kept and returns
+// all the objects to be deleted.
+func filterBindings[
path: pkg/utils/kubernetes/reduce/filters.go
user: login: mlavacca
body: ```suggestion
func filterRoleBindings[
```

I think that explicitly stating we are talking about roles can help to quickly understand what this function is about. 
created_at: 2025-04-15T09:42:05Z
author_association: MEMBER
diff_hunk: @@ -47,9 +47,31 @@
 - Added `spec.watchNamespace` field to `ControlPlane` CRD to allow watching resources
   only in the specified namespace.
   When `spec.watchNamespace.type=list` is used, each specified namespace requires
-  a `ReferenceGrant` in the namespace of the `ControlPlane` that allows the `ControlPlane`
-  to watch resources in the specified namespace.
+  a `ReferenceGrant` that allows the `ControlPlane` to watch resources in the specified namespace.
+  For example:
+
+  ```yaml
+  apiVersion: gateway.networking.k8s.io/v1beta1
+  kind: ReferenceGrant
+  metadata:
+    name: ref
+    namespace: namespace-a
+  spec:
+    from:
+    - group: gateway-operator.konghq.com
+      kind: ControlPlane
+      namespace: controlplane-namespace
+    to:
+    - group: ""
+      kind: Namespace
+      name: namespace-a
+  ```
path: CHANGELOG.md
user: login: pmalek
body: We'll certainly add a docs section (as a guide for this feature) but I thought that adding it here would help readers understand what sort of `ReferenceGrant` would be needed.

If you feel that's too much I can remove it and add a placeholder for the future docs section link. WDYT?
created_at: 2025-04-15T10:17:41Z
author_association: MEMBER
diff_hunk: @@ -94,24 +94,56 @@ func filterClusterRoles(clusterRoles []rbacv1.ClusterRole) []rbacv1.ClusterRole
 	return append(clusterRoles[:best], clusterRoles[best+1:]...)
 }
 
+// -----------------------------------------------------------------------------
+// Filter functions - Roles
+// -----------------------------------------------------------------------------
+
+// filterRoles filters out the Role to be kept and returns
+// all the Roles to be deleted.
+// The filtered-out Role is decided as follows:
+//  1. creationTimestamp (newer is better, because newer Roles can contain new policy rules)
+func filterRoles(roles []rbacv1.Role) []rbacv1.Role {
+	if len(roles) == 1 {
+		return []rbacv1.Role{}
+	}
+
+	best := 0
+	for i, cr := range roles {
+		if cr.CreationTimestamp.After(roles[best].CreationTimestamp.Time) {
+			best = i
+		}
+	}
+
+	return append(roles[:best], roles[best+1:]...)
+}
+
 // -----------------------------------------------------------------------------
 // Filter functions - ClusterRoleBindings
 // -----------------------------------------------------------------------------
 
-// filterClusterRoleBindings filters out the ClusterRoleBinding to be kept and returns
-// all the ClusterRoleBindings to be deleted.
-// The filtered-out ClusterRoleBinding is decided as follows:
-// 1. creationTimestamp (older is better)
-func filterClusterRoleBindings(clusterRoleBindings []rbacv1.ClusterRoleBinding) []rbacv1.ClusterRoleBinding {
+// filterBindings filters out the ClusterRoleBinding or RoleBindings to be kept and returns
+// all the objects to be deleted.
+func filterBindings[
path: pkg/utils/kubernetes/reduce/filters.go
user: login: pmalek
body: 95c8d45a6d6fecad44a59ffea5990f475b4c38f6

created_at: 2025-04-15T10:19:42Z
author_association: MEMBER
diff_hunk: @@ -47,9 +47,31 @@
 - Added `spec.watchNamespace` field to `ControlPlane` CRD to allow watching resources
   only in the specified namespace.
   When `spec.watchNamespace.type=list` is used, each specified namespace requires
-  a `ReferenceGrant` in the namespace of the `ControlPlane` that allows the `ControlPlane`
-  to watch resources in the specified namespace.
+  a `ReferenceGrant` that allows the `ControlPlane` to watch resources in the specified namespace.
+  For example:
+
+  ```yaml
+  apiVersion: gateway.networking.k8s.io/v1beta1
+  kind: ReferenceGrant
+  metadata:
+    name: ref
+    namespace: namespace-a
+  spec:
+    from:
+    - group: gateway-operator.konghq.com
+      kind: ControlPlane
+      namespace: controlplane-namespace
+    to:
+    - group: ""
+      kind: Namespace
+      name: namespace-a
+  ```
path: CHANGELOG.md
user: login: mlavacca
body: yep, if we can add a placeholder and set the docs link here afterward, it would be great 👍🏻 
created_at: 2025-04-15T11:12:03Z
author_association: MEMBER
diff_hunk: @@ -288,3 +288,81 @@ func (r *Reconciler) listControlPlanesForReferenceGrants(
 
 	return recs
 }
+
+func (r *Reconciler) listControlPlanesForRoles(
+	ctx context.Context,
+	obj client.Object,
+) []reconcile.Request {
+	role, ok := obj.(*rbacv1.Role)
+	if !ok {
+		ctrllog.FromContext(ctx).Error(
+			operatorerrors.ErrUnexpectedObject,
+			"failed to map Role on ControlPlane",
+			"expected", "Role", "found", reflect.TypeOf(obj),
+		)
+		return nil
+	}
+
+	nn, ok := isManagedByControlPlane(role)
+	if !ok {
+		return nil
+	}
+
+	return []reconcile.Request{
+		{
+			NamespacedName: nn,
+		},
+	}
+}
+
+func (r *Reconciler) listControlPlanesForRoleBindings(
+	ctx context.Context,
+	obj client.Object,
+) []reconcile.Request {
+	roleBinding, ok := obj.(*rbacv1.RoleBinding)
+	if !ok {
+		ctrllog.FromContext(ctx).Error(
+			operatorerrors.ErrUnexpectedObject,
+			"failed to map RoleBinding on ControlPlane",
+			"expected", "RoleBinding", "found", reflect.TypeOf(obj),
+		)
+		return nil
+	}
+
+	nn, ok := isManagedByControlPlane(roleBinding)
+	if !ok {
+		return nil
+	}
+
+	return []reconcile.Request{
+		{
+			NamespacedName: nn,
+		},
+	}
+}
path: controller/controlplane/controller_watch.go
user: login: programmer04
body: ```suggestion
func listControlPlanesFor[T *rbacv1.Role | *rbacv1.RoleBinding](
	ctx context.Context,
	obj client.Object,
) []reconcile.Request {
	if _, ok := obj.(T); !ok {
		ctrllog.FromContext(ctx).Error(
			operatorerrors.ErrUnexpectedObject,
			fmt.Sprintf("failed to map %T on ControlPlane", T(nil)),
			"expected", "RoleBinding", "found", reflect.TypeOf(obj),
		)
		return nil
	}

	nn, ok := isManagedByControlPlane(obj)
	if !ok {
		return nil
	}

	return []reconcile.Request{
		{
			NamespacedName: nn,
		},
	}
}
```

Instead of having two specific method helpers, have one generic function helper. WDYT? 
On the caller side, it'll look like that 

```go
Watches(
			&rbacv1.RoleBinding{},
			handler.EnqueueRequestsFromMapFunc(listControlPlanesFor[*rbacv1.RoleBinding]))
```


created_at: 2025-04-24T10:59:12Z
author_association: MEMBER
diff_hunk: @@ -94,24 +94,56 @@ func filterClusterRoles(clusterRoles []rbacv1.ClusterRole) []rbacv1.ClusterRole
 	return append(clusterRoles[:best], clusterRoles[best+1:]...)
 }
 
+// -----------------------------------------------------------------------------
+// Filter functions - Roles
+// -----------------------------------------------------------------------------
+
+// filterRoles filters out the Role to be kept and returns
+// all the Roles to be deleted.
+// The filtered-out Role is decided as follows:
+//  1. creationTimestamp (newer is better, because newer Roles can contain new policy rules)
+func filterRoles(roles []rbacv1.Role) []rbacv1.Role {
+	if len(roles) == 1 {
+		return []rbacv1.Role{}
+	}
+
+	best := 0
+	for i, cr := range roles {
+		if cr.CreationTimestamp.After(roles[best].CreationTimestamp.Time) {
+			best = i
+		}
+	}
+
+	return append(roles[:best], roles[best+1:]...)
path: pkg/utils/kubernetes/reduce/filters.go
user: login: programmer04
body: There is a stdlib helper for this
```suggestion
	return slices.Delete(roles, best, best+1)
```
But, still modifying the slice passed as an argument may lead to side effects that manifest in the code when the argument and/or returned slice is reused later and modified. 

So the bullet-proof solution is to use 

```go 
return slices.Concat(roles[:best], roles[best+1:])
```

which will allocate a new slice, but we have a dedicated issue to maybe enhance everywhere
- https://github.com/Kong/gateway-operator/issues/146 

> This comment is rather to acknowledge a fact than a call to action 
created_at: 2025-04-24T11:09:03Z
author_association: MEMBER
diff_hunk: @@ -129,7 +130,17 @@ func (r *Reconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) err
 		// are referenced in a "from" instance.
 		Watches(
 			&gatewayv1beta1.ReferenceGrant{},
-			handler.EnqueueRequestsFromMapFunc(r.listControlPlanesForReferenceGrants))
+			handler.EnqueueRequestsFromMapFunc(r.listControlPlanesForReferenceGrants)).
+		// watch for events on Roles, if any Role event happen, enqueue
path: controller/controlplane/controller.go
user: login: programmer04
body: ```suggestion
		// Watch for events on Roles, if any Role event happens, enqueue
```
created_at: 2025-04-24T12:04:49Z
author_association: MEMBER
diff_hunk: @@ -348,12 +356,306 @@ func (r *Reconciler) ensureRoles(
 		return op.Updated, nil // requeue will be triggered by the creation or update of the owned object
 	}
 
+	// If watchNamespaces is not empty then we need to generate roles and role bindings for each namespace.
+	if len(generatedRoles) > 0 {
+		log.Trace(logger, "ensuring Roles for ControlPlane Deployment exist")
+		createdOrUpdated, controlplaneRoles, err := r.ensureRoles(ctx, cp, generatedRoles)
+		if err != nil {
+			return op.Noop, err
+		}
+		if createdOrUpdated {
+			log.Debug(logger, "Roles created/updated")
+			return op.Updated, nil // requeue will be triggered by the creation or update of Roles.
+		}
+
+		res, err := r.ensureRoleBindings(ctx, cp, controlplaneServiceAccount, controlplaneRoles)
+		if err != nil {
+			return op.Noop, err
+		} else if res != op.Noop {
+			log.Debug(logger, "RoleBindings created/updated")
+			return res, nil
+		}
+
+		deleted, err := r.pruneOutdatedRoles(ctx, logger, cp, validatedWatchNamespaces)
+		if err != nil {
+			return op.Noop, err
+		}
+		if deleted {
+			log.Debug(logger, "not needed Roles deleted")
+		}
+
+		deleted, err = r.pruneOutdatedRoleBindings(ctx, logger, cp, validatedWatchNamespaces)
+		if err != nil {
+			return op.Noop, err
+		}
+		if deleted {
+			log.Debug(logger, "not needed RoleBindings deleted")
+		}
+	}
+
 	return op.Noop, nil
 }
 
+func (r *Reconciler) pruneOutdatedRoles(
+	ctx context.Context,
+	logger logr.Logger,
+	cp *operatorv1beta1.ControlPlane,
+	validatedWatchNamespaces []string,
+) (deleted bool, err error) {
+	namespaces := sets.New(validatedWatchNamespaces...)
+
+	roles, err := k8sutils.ListRoles(
+		ctx,
+		r.Client,
+		client.MatchingLabels(k8sutils.GetManagedByLabelSet(cp)),
+	)
+	if err != nil {
+		return false, err
+	}
+
+	for _, role := range roles {
+		objDeleted, err := deleteIfNotInNamespaceSet(ctx, r.Client, &role, namespaces, "ControlPlane's Role")
+		if err != nil {
+			return false, err
+		}
+		if !objDeleted {
+			continue
+		}
+
+		deleted = true
+		log.Debug(logger, "Role deleted", "role", role)
+	}
+
+	return deleted, nil
+}
+
+func (r *Reconciler) pruneOutdatedRoleBindings(
+	ctx context.Context,
+	logger logr.Logger,
+	cp *operatorv1beta1.ControlPlane,
+	validatedWatchNamespaces []string,
+) (deleted bool, err error) {
+	namespaces := sets.New(validatedWatchNamespaces...)
+
+	roleBindings, err := k8sutils.ListRoleBindings(
+		ctx,
+		r.Client,
+		client.MatchingLabels(k8sutils.GetManagedByLabelSet(cp)),
+	)
+	if err != nil {
+		return false, err
+	}
+
+	for _, rb := range roleBindings {
+		objDeleted, err := deleteIfNotInNamespaceSet(ctx, r.Client, &rb, namespaces, "ControlPlane's RoleBinding")
+		if err != nil {
+			return false, err
+		}
+		if !objDeleted {
+			continue
+		}
+
+		deleted = true
+		log.Debug(logger, "RoleBinding deleted", "role_binding", rb)
+	}
+
+	return deleted, nil
+}
+
+func deleteIfNotInNamespaceSet(
+	ctx context.Context, cl client.Client, obj client.Object, namespaces sets.Set[string], msg string,
+) (bool, error) {
+	if namespaces.Has(obj.GetNamespace()) {
+		return false, nil
+	}
+
+	if err := cl.Delete(ctx, obj); err != nil {
+		return false, fmt.Errorf("failed deleting %s %s: %w", msg, client.ObjectKeyFromObject(obj), err)
+	}
+	return true, nil
+}
+
+func (r *Reconciler) ensureRoles(
+	ctx context.Context,
+	cp *operatorv1beta1.ControlPlane,
+	generatedRoles []*rbacv1.Role,
+) (createdOrUpdated bool, roles []*rbacv1.Role, err error) {
+rolesLoop:
+	for _, generatedRole := range generatedRoles {
+		existingRoles, err := k8sutils.ListRoles(
+			ctx,
+			r.Client,
+			client.MatchingLabels(k8sutils.GetManagedByLabelSet(cp)),
+			client.InNamespace(generatedRole.Namespace),
+		)
+		if err != nil {
+			return false, nil, err
+		}
+
+		count := len(existingRoles)
+		switch count {
+		case 0:
+			if err := r.Create(ctx, generatedRole); err != nil {
+				return false, nil, err
+			}
+			roles = append(roles, generatedRole)
+			createdOrUpdated = true
+		case 1:
+			var (
+				updated  bool
+				existing = &existingRoles[0]
+				old      = existing.DeepCopy()
+			)
+
+			updated, existing.ObjectMeta = k8sutils.EnsureObjectMetaIsUpdated(existing.ObjectMeta, generatedRole.ObjectMeta)
+			if updated ||
+				!cmp.Equal(existing.Rules, generatedRole.Rules) {
+				existing.Rules = generatedRole.Rules
+				if err := r.Patch(ctx, existing, client.MergeFrom(old)); err != nil {
+					return false, nil, fmt.Errorf("failed patching ControlPlane's Role %s: %w", existing.Name, err)
+				}
+				roles = append(roles, existing)
+				createdOrUpdated = true
+				continue rolesLoop
+			}
+			roles = append(roles, existing)
+		default:
+			if err := k8sreduce.ReduceRoles(ctx, r.Client, existingRoles); err != nil {
+				return false, nil, err
+			}
+			return false, nil, errors.New("number of Roles reduced")
path: controller/controlplane/controller_reconciler_utils.go
user: login: programmer04
body: ```suggestion
			return false, nil, fmt.Errorf("number of Roles reduced from: %d to 1", count)
```

a little bit more of extra context may be beneficial I think
created_at: 2025-04-24T12:08:33Z
author_association: MEMBER
diff_hunk: @@ -348,12 +356,306 @@ func (r *Reconciler) ensureRoles(
 		return op.Updated, nil // requeue will be triggered by the creation or update of the owned object
 	}
 
+	// If watchNamespaces is not empty then we need to generate roles and role bindings for each namespace.
+	if len(generatedRoles) > 0 {
+		log.Trace(logger, "ensuring Roles for ControlPlane Deployment exist")
+		createdOrUpdated, controlplaneRoles, err := r.ensureRoles(ctx, cp, generatedRoles)
+		if err != nil {
+			return op.Noop, err
+		}
+		if createdOrUpdated {
+			log.Debug(logger, "Roles created/updated")
+			return op.Updated, nil // requeue will be triggered by the creation or update of Roles.
+		}
+
+		res, err := r.ensureRoleBindings(ctx, cp, controlplaneServiceAccount, controlplaneRoles)
+		if err != nil {
+			return op.Noop, err
+		} else if res != op.Noop {
+			log.Debug(logger, "RoleBindings created/updated")
+			return res, nil
+		}
+
+		deleted, err := r.pruneOutdatedRoles(ctx, logger, cp, validatedWatchNamespaces)
+		if err != nil {
+			return op.Noop, err
+		}
+		if deleted {
+			log.Debug(logger, "not needed Roles deleted")
+		}
+
+		deleted, err = r.pruneOutdatedRoleBindings(ctx, logger, cp, validatedWatchNamespaces)
+		if err != nil {
+			return op.Noop, err
+		}
+		if deleted {
+			log.Debug(logger, "not needed RoleBindings deleted")
+		}
+	}
+
 	return op.Noop, nil
 }
 
+func (r *Reconciler) pruneOutdatedRoles(
+	ctx context.Context,
+	logger logr.Logger,
+	cp *operatorv1beta1.ControlPlane,
+	validatedWatchNamespaces []string,
+) (deleted bool, err error) {
+	namespaces := sets.New(validatedWatchNamespaces...)
+
+	roles, err := k8sutils.ListRoles(
+		ctx,
+		r.Client,
+		client.MatchingLabels(k8sutils.GetManagedByLabelSet(cp)),
+	)
+	if err != nil {
+		return false, err
+	}
+
+	for _, role := range roles {
+		objDeleted, err := deleteIfNotInNamespaceSet(ctx, r.Client, &role, namespaces, "ControlPlane's Role")
+		if err != nil {
+			return false, err
+		}
+		if !objDeleted {
+			continue
+		}
+
+		deleted = true
+		log.Debug(logger, "Role deleted", "role", role)
+	}
+
+	return deleted, nil
+}
+
+func (r *Reconciler) pruneOutdatedRoleBindings(
+	ctx context.Context,
+	logger logr.Logger,
+	cp *operatorv1beta1.ControlPlane,
+	validatedWatchNamespaces []string,
+) (deleted bool, err error) {
+	namespaces := sets.New(validatedWatchNamespaces...)
+
+	roleBindings, err := k8sutils.ListRoleBindings(
+		ctx,
+		r.Client,
+		client.MatchingLabels(k8sutils.GetManagedByLabelSet(cp)),
+	)
+	if err != nil {
+		return false, err
+	}
+
+	for _, rb := range roleBindings {
+		objDeleted, err := deleteIfNotInNamespaceSet(ctx, r.Client, &rb, namespaces, "ControlPlane's RoleBinding")
+		if err != nil {
+			return false, err
+		}
+		if !objDeleted {
+			continue
+		}
+
+		deleted = true
+		log.Debug(logger, "RoleBinding deleted", "role_binding", rb)
+	}
+
+	return deleted, nil
+}
+
+func deleteIfNotInNamespaceSet(
+	ctx context.Context, cl client.Client, obj client.Object, namespaces sets.Set[string], msg string,
+) (bool, error) {
+	if namespaces.Has(obj.GetNamespace()) {
+		return false, nil
+	}
+
+	if err := cl.Delete(ctx, obj); err != nil {
+		return false, fmt.Errorf("failed deleting %s %s: %w", msg, client.ObjectKeyFromObject(obj), err)
+	}
+	return true, nil
+}
+
+func (r *Reconciler) ensureRoles(
+	ctx context.Context,
+	cp *operatorv1beta1.ControlPlane,
+	generatedRoles []*rbacv1.Role,
+) (createdOrUpdated bool, roles []*rbacv1.Role, err error) {
+rolesLoop:
+	for _, generatedRole := range generatedRoles {
+		existingRoles, err := k8sutils.ListRoles(
+			ctx,
+			r.Client,
+			client.MatchingLabels(k8sutils.GetManagedByLabelSet(cp)),
+			client.InNamespace(generatedRole.Namespace),
+		)
+		if err != nil {
+			return false, nil, err
+		}
+
+		count := len(existingRoles)
+		switch count {
path: controller/controlplane/controller_reconciler_utils.go
user: login: programmer04
body: ```suggestion
		switch count := len(existingRoles); count {
```
created_at: 2025-04-24T12:10:11Z
author_association: MEMBER
diff_hunk: @@ -129,7 +130,17 @@ func (r *Reconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) err
 		// are referenced in a "from" instance.
 		Watches(
 			&gatewayv1beta1.ReferenceGrant{},
-			handler.EnqueueRequestsFromMapFunc(r.listControlPlanesForReferenceGrants))
+			handler.EnqueueRequestsFromMapFunc(r.listControlPlanesForReferenceGrants)).
+		// watch for events on Roles, if any Role event happen, enqueue
+		// reconciliation for all supported ControlPlane objects which use this Role.
+		Watches(
+			&rbacv1.Role{},
+			handler.EnqueueRequestsFromMapFunc(r.listControlPlanesForRoles)).
+		// watch for events on RoleBindings, if any RoleBinding event happen, enqueue
path: controller/controlplane/controller.go
user: login: programmer04
body: ```suggestion
		// Watch for events on RoleBindings, if any RoleBinding event happens, enqueue
```
created_at: 2025-04-24T12:27:34Z
author_association: MEMBER
diff_hunk: @@ -348,12 +356,306 @@ func (r *Reconciler) ensureRoles(
 		return op.Updated, nil // requeue will be triggered by the creation or update of the owned object
 	}
 
+	// If watchNamespaces is not empty then we need to generate roles and role bindings for each namespace.
+	if len(generatedRoles) > 0 {
+		log.Trace(logger, "ensuring Roles for ControlPlane Deployment exist")
+		createdOrUpdated, controlplaneRoles, err := r.ensureRoles(ctx, cp, generatedRoles)
+		if err != nil {
+			return op.Noop, err
+		}
+		if createdOrUpdated {
+			log.Debug(logger, "Roles created/updated")
+			return op.Updated, nil // requeue will be triggered by the creation or update of Roles.
+		}
+
+		res, err := r.ensureRoleBindings(ctx, cp, controlplaneServiceAccount, controlplaneRoles)
+		if err != nil {
+			return op.Noop, err
+		} else if res != op.Noop {
path: controller/controlplane/controller_reconciler_utils.go
user: login: programmer04
body: ```suggestion
		}
		if res != op.Noop {
```
to be consitent with what is above and below
created_at: 2025-04-24T12:28:54Z
author_association: MEMBER
diff_hunk: @@ -288,3 +288,81 @@ func (r *Reconciler) listControlPlanesForReferenceGrants(
 
 	return recs
 }
+
+func (r *Reconciler) listControlPlanesForRoles(
+	ctx context.Context,
+	obj client.Object,
+) []reconcile.Request {
+	role, ok := obj.(*rbacv1.Role)
+	if !ok {
+		ctrllog.FromContext(ctx).Error(
+			operatorerrors.ErrUnexpectedObject,
+			"failed to map Role on ControlPlane",
+			"expected", "Role", "found", reflect.TypeOf(obj),
+		)
+		return nil
+	}
+
+	nn, ok := isManagedByControlPlane(role)
+	if !ok {
+		return nil
+	}
+
+	return []reconcile.Request{
+		{
+			NamespacedName: nn,
+		},
+	}
+}
+
+func (r *Reconciler) listControlPlanesForRoleBindings(
+	ctx context.Context,
+	obj client.Object,
+) []reconcile.Request {
+	roleBinding, ok := obj.(*rbacv1.RoleBinding)
+	if !ok {
+		ctrllog.FromContext(ctx).Error(
+			operatorerrors.ErrUnexpectedObject,
+			"failed to map RoleBinding on ControlPlane",
+			"expected", "RoleBinding", "found", reflect.TypeOf(obj),
+		)
+		return nil
+	}
+
+	nn, ok := isManagedByControlPlane(roleBinding)
+	if !ok {
+		return nil
+	}
+
+	return []reconcile.Request{
+		{
+			NamespacedName: nn,
+		},
+	}
+}
path: controller/controlplane/controller_watch.go
user: login: pmalek
body: 👍 

Added that in 3f20b733182c2fca371d9c7beffa59d182d75704 (needed to change your snippet ever so slightly to make it compile).


created_at: 2025-04-24T12:43:16Z
author_association: MEMBER
diff_hunk: @@ -47,9 +47,31 @@
 - Added `spec.watchNamespace` field to `ControlPlane` CRD to allow watching resources
   only in the specified namespace.
   When `spec.watchNamespace.type=list` is used, each specified namespace requires
-  a `ReferenceGrant` in the namespace of the `ControlPlane` that allows the `ControlPlane`
-  to watch resources in the specified namespace.
+  a `ReferenceGrant` that allows the `ControlPlane` to watch resources in the specified namespace.
+  For example:
+
+  ```yaml
+  apiVersion: gateway.networking.k8s.io/v1beta1
+  kind: ReferenceGrant
+  metadata:
+    name: ref
+    namespace: namespace-a
+  spec:
+    from:
+    - group: gateway-operator.konghq.com
+      kind: ControlPlane
+      namespace: controlplane-namespace
+    to:
+    - group: ""
+      kind: Namespace
+      name: namespace-a
+  ```
path: CHANGELOG.md
user: login: pmalek
body: Added in 43874f5a642270e8202a983b2cfbe079cd727113

created_at: 2025-04-24T13:09:50Z
author_association: MEMBER

PR comments:
user: login: pmalek
created_at: 2025-04-15T10:15:28Z
body: > What I am getting from this implementation is the following use of the `ReferenceGrant` resource: we are "abstracting" the whole set of resources (services, routes, ingress, etc.) that have to be reconciled by KGO with the Namespace kind. This means that if I want to cross the namespace boundaries with the CP, I need to create a RG very similar to the one you posted in the CHANGELOG, and that will act as a "allow-all-kinds" for the namespace where the RG lives.
> 
> Is that correct?

That's correct yes. Exactly as proposed in https://docs.google.com/document/d/1Xr0U5yxRWt0aJf310vmxOsrUx2uLfg3SLMkEASgZIXg/edit?pli=1&tab=t.0#heading=h.1pagv99uxvhp
author_association: MEMBER
user: login: mlavacca
created_at: 2025-04-15T11:10:52Z
body: > > What I am getting from this implementation is the following use of the `ReferenceGrant` resource: we are "abstracting" the whole set of resources (services, routes, ingress, etc.) that have to be reconciled by KGO with the Namespace kind. This means that if I want to cross the namespace boundaries with the CP, I need to create a RG very similar to the one you posted in the CHANGELOG, and that will act as a "allow-all-kinds" for the namespace where the RG lives.
> > Is that correct?
> 
> That's correct yes. Exactly as proposed in https://docs.google.com/document/d/1Xr0U5yxRWt0aJf310vmxOsrUx2uLfg3SLMkEASgZIXg/edit?pli=1&tab=t.0#heading=h.1pagv99uxvhp

I am sorry I did not catch that earlier, but I think we are misusing the `ReferenceGrant` API here. Having the `ReferenceGrantTo` set to the `Namespace` kind means that the implementation has a grant on the `Namespace` object (which is problematic again, as the namespace is cluster-wide), not on all the objects needed in that namespace. I might be ok to leave it as it is now, but I strongly think we should reconsider and change our approach in the future to have a proper usage of the API.
author_association: MEMBER
user: login: pmalek
created_at: 2025-04-15T11:19:04Z
body: > I might be ok to leave it as it is now, but I strongly think we should reconsider and change our approach in the future to have a proper usage of the API.

Do you have a proposal on how you'd like to this API to look like?

Requiring `ReferenceGrant`s for each type of resource that `ControlPlane` needs to access seems  clunky and would be a bad UX IMHO.
author_association: MEMBER
user: login: mlavacca
created_at: 2025-04-15T11:38:33Z
body: > Requiring `ReferenceGrant`s for each type of resource that `ControlPlane` needs to access seems clunky and would be a bad UX IMHO.

100% agree. Even considering the target cap to 16, this is definitely a non-viable solution.

> Do you have a proposal on how you'd like to this API to look like?

At the moment, I don't. I think it would require a design of some sort.

Generally speaking, do you agree with this point I'm raising?
author_association: MEMBER
user: login: pmalek
created_at: 2025-04-15T13:31:13Z
body: We've discussed this with @mlavacca and decided to take a step back and consider changing the API.

We'll bring this up on next team sync.
author_association: MEMBER
user: login: pmalek
created_at: 2025-04-22T10:53:53Z
body: Updated API (`WatchNamespaceGrant`): https://github.com/Kong/kubernetes-configuration/pull/403

cc: @mlavacca 
author_association: MEMBER
user: login: pmalek
created_at: 2025-04-24T13:10:57Z
body: @programmer04 PTAL once more. Needed to adjust the changelog and sample manifest in https://github.com/Kong/gateway-operator/pull/1410/commits/ef4b9f1621aa5398465614b7d59c53141f8a37f1
author_association: MEMBER