Repository: openshift/cluster-ingress-operator
Pull request #1206: NE-1912: Add aggregated ClusterRoles for Gateway API
Author: login: shaneutt
Labels: name: lgtm
description: Indicates that a PR is ready to be merged.
name: approved
description: Indicates a PR has been approved by an approver from all required OWNERS files.
name: tide/merge-method-rebase
description: Denotes a PR that should be rebased by tide when it merges.
name: jira/valid-reference
description: Indicates that this PR references a valid Jira ticket of any type.
Created: 2025-03-27T20:06:21Z | Merged: 2025-04-03T07:26:06Z

This adds aggregated ClusterRoles for Gateway API as per the "Security Policy" in the Gateway API EP.


Diff:
diff --git a/manifests/00-cluster-role.yaml b/manifests/00-cluster-role.yaml
index 1a7a90268f..e29ed4bd76 100644
--- a/manifests/00-cluster-role.yaml
+++ b/manifests/00-cluster-role.yaml
@@ -164,6 +164,7 @@ rules:
   - gatewayclasses
   - gateways
   - httproutes
+  - grpcroutes
   verbs:
   - '*'
 
diff --git a/pkg/manifests/assets/gateway-api/aggregated-cluster-roles/admin-cluster-role.yaml b/pkg/manifests/assets/gateway-api/aggregated-cluster-roles/admin-cluster-role.yaml
new file mode 100644
index 0000000000..58ccf492a7
--- /dev/null
+++ b/pkg/manifests/assets/gateway-api/aggregated-cluster-roles/admin-cluster-role.yaml
@@ -0,0 +1,30 @@
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: system:openshift:gateway-api:aggregate-to-admin
+  labels:
+    rbac.authorization.k8s.io/aggregate-to-admin: "true"
+    rbac.authorization.k8s.io/aggregate-to-edit: "true"
+rules:
+  - apiGroups:
+      - gateway.networking.k8s.io
+    resources:
+      - gateways
+    verbs:
+      - get
+      - list
+      - watch
+  - apiGroups:
+      - gateway.networking.k8s.io
+    resources:
+      - httproutes
+      - grpcroutes
+    verbs:
+      - get
+      - list
+      - watch
+      - create
+      - update
+      - patch
+      - delete
+      - deletecollection
diff --git a/pkg/manifests/assets/gateway-api/aggregated-cluster-roles/view-cluster-role.yaml b/pkg/manifests/assets/gateway-api/aggregated-cluster-roles/view-cluster-role.yaml
new file mode 100644
index 0000000000..01985a8d68
--- /dev/null
+++ b/pkg/manifests/assets/gateway-api/aggregated-cluster-roles/view-cluster-role.yaml
@@ -0,0 +1,24 @@
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: system:openshift:gateway-api:aggregate-to-view
+  labels:
+    rbac.authorization.k8s.io/aggregate-to-view: "true"
+rules:
+  - apiGroups:
+      - gateway.networking.k8s.io
+    resources:
+      - gateways
+    verbs:
+      - get
+      - list
+      - watch
+  - apiGroups:
+      - gateway.networking.k8s.io
+    resources:
+      - httproutes
+      - grpcroutes
+    verbs:
+      - get
+      - list
+      - watch
diff --git a/pkg/manifests/manifests.go b/pkg/manifests/manifests.go
index 1b86b21eb5..76c3f26ec4 100644
--- a/pkg/manifests/manifests.go
+++ b/pkg/manifests/manifests.go
@@ -42,11 +42,13 @@ const (
 	CanaryServiceAsset   = "assets/canary/service.yaml"
 	CanaryRouteAsset     = "assets/canary/route.yaml"
 
-	GatewayClassCRDAsset   = "assets/gateway-api/gateway.networking.k8s.io_gatewayclasses.yaml"
-	GatewayCRDAsset        = "assets/gateway-api/gateway.networking.k8s.io_gateways.yaml"
-	GRPCRouteCRDAsset      = "assets/gateway-api/gateway.networking.k8s.io_grpcroutes.yaml"
-	HTTPRouteCRDAsset      = "assets/gateway-api/gateway.networking.k8s.io_httproutes.yaml"
-	ReferenceGrantCRDAsset = "assets/gateway-api/gateway.networking.k8s.io_referencegrants.yaml"
+	GatewayClassCRDAsset            = "assets/gateway-api/gateway.networking.k8s.io_gatewayclasses.yaml"
+	GatewayCRDAsset                 = "assets/gateway-api/gateway.networking.k8s.io_gateways.yaml"
+	GRPCRouteCRDAsset               = "assets/gateway-api/gateway.networking.k8s.io_grpcroutes.yaml"
+	HTTPRouteCRDAsset               = "assets/gateway-api/gateway.networking.k8s.io_httproutes.yaml"
+	ReferenceGrantCRDAsset          = "assets/gateway-api/gateway.networking.k8s.io_referencegrants.yaml"
+	GatewayAPIAdminClusterRoleAsset = "assets/gateway-api/aggregated-cluster-roles/admin-cluster-role.yaml"
+	GatewayAPIViewClusterRoleAsset  = "assets/gateway-api/aggregated-cluster-roles/view-cluster-role.yaml"
 
 	// Annotation used to inform the certificate generation service to
 	// generate a cluster-signed certificate and populate the secret.
@@ -296,6 +298,22 @@ func ReferenceGrantCRD() *apiextensionsv1.CustomResourceDefinition {
 	return crd
 }
 
+func GatewayAPIAdminClusterRole() *rbacv1.ClusterRole {
+	clusterRole, err := NewClusterRole(MustAssetReader(GatewayAPIAdminClusterRoleAsset))
+	if err != nil {
+		panic(err)
+	}
+	return clusterRole
+}
+
+func GatewayAPIViewClusterRole() *rbacv1.ClusterRole {
+	clusterRole, err := NewClusterRole(MustAssetReader(GatewayAPIViewClusterRoleAsset))
+	if err != nil {
+		panic(err)
+	}
+	return clusterRole
+}
+
 func NewServiceAccount(manifest io.Reader) (*corev1.ServiceAccount, error) {
 	sa := corev1.ServiceAccount{}
 	if err := yaml.NewYAMLOrJSONDecoder(manifest, 100).Decode(&sa); err != nil {
diff --git a/pkg/manifests/manifests_test.go b/pkg/manifests/manifests_test.go
index 57d93114b7..187b07f1b2 100644
--- a/pkg/manifests/manifests_test.go
+++ b/pkg/manifests/manifests_test.go
@@ -1,6 +1,8 @@
 package manifests
 
 import (
+	"slices"
+	"strings"
 	"testing"
 
 	operatorv1 "github.com/openshift/api/operator/v1"
@@ -50,6 +52,19 @@ func TestManifests(t *testing.T) {
 	HTTPRouteCRD()
 	ReferenceGrantCRD()
 
+	adminOnlyVerbs := []string{"create", "update", "patch", "delete", "deletecollection"}
+	GatewayAPIAdminClusterRole()
+	viewClusterRole := GatewayAPIViewClusterRole()
+	for _, policyRule := range viewClusterRole.Rules {
+		for _, adminOnlyVerb := range adminOnlyVerbs {
+			if slices.ContainsFunc(policyRule.Verbs, func(verb string) bool {
+				return strings.EqualFold(verb, adminOnlyVerb)
+			}) {
+				t.Errorf("view role %s should only contain read verbs, found: %s", viewClusterRole.Name, adminOnlyVerb)
+			}
+		}
+	}
+
 	MustAsset(CustomResourceDefinitionManifest)
 	MustAsset(NamespaceManifest)
 }
diff --git a/pkg/operator/controller/gatewayapi/controller.go b/pkg/operator/controller/gatewayapi/controller.go
index 949c7e76e3..13e44c1d32 100644
--- a/pkg/operator/controller/gatewayapi/controller.go
+++ b/pkg/operator/controller/gatewayapi/controller.go
@@ -107,6 +107,10 @@ func (r *reconciler) Reconcile(ctx context.Context, request reconcile.Request) (
 		return reconcile.Result{}, err
 	}
 
+	if err := r.ensureGatewayAPIRBAC(ctx); err != nil {
+		return reconcile.Result{}, err
+	}
+
 	if !r.config.GatewayAPIControllerEnabled {
 		return reconcile.Result{}, nil
 	}
diff --git a/pkg/operator/controller/gatewayapi/controller_test.go b/pkg/operator/controller/gatewayapi/controller_test.go
index f7db0eb5bf..c2e0d7e2ec 100644
--- a/pkg/operator/controller/gatewayapi/controller_test.go
+++ b/pkg/operator/controller/gatewayapi/controller_test.go
@@ -11,6 +11,7 @@ import (
 
 	configv1 "github.com/openshift/api/config/v1"
 
+	rbacv1 "k8s.io/api/rbac/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -30,6 +31,11 @@ func Test_Reconcile(t *testing.T) {
 			ObjectMeta: metav1.ObjectMeta{Name: name},
 		}
 	}
+	clusterRole := func(name string) *rbacv1.ClusterRole {
+		return &rbacv1.ClusterRole{
+			ObjectMeta: metav1.ObjectMeta{Name: name},
+		}
+	}
 	tests := []struct {
 		name                        string
 		gatewayAPIEnabled           bool
@@ -58,6 +64,8 @@ func Test_Reconcile(t *testing.T) {
 				crd("grpcroutes.gateway.networking.k8s.io"),
 				crd("httproutes.gateway.networking.k8s.io"),
 				crd("referencegrants.gateway.networking.k8s.io"),
+				clusterRole("system:openshift:gateway-api:aggregate-to-admin"),
+				clusterRole("system:openshift:gateway-api:aggregate-to-view"),
 			},
 			expectUpdate:    []client.Object{},
 			expectDelete:    []client.Object{},
@@ -73,6 +81,8 @@ func Test_Reconcile(t *testing.T) {
 				crd("grpcroutes.gateway.networking.k8s.io"),
 				crd("httproutes.gateway.networking.k8s.io"),
 				crd("referencegrants.gateway.networking.k8s.io"),
+				clusterRole("system:openshift:gateway-api:aggregate-to-admin"),
+				clusterRole("system:openshift:gateway-api:aggregate-to-view"),
 			},
 			expectUpdate:    []client.Object{},
 			expectDelete:    []client.Object{},
@@ -83,6 +93,7 @@ func Test_Reconcile(t *testing.T) {
 	scheme := runtime.NewScheme()
 	configv1.Install(scheme)
 	apiextensionsv1.AddToScheme(scheme)
+	rbacv1.AddToScheme(scheme)
 
 	for _, tc := range tests {
 		t.Run(tc.name, func(t *testing.T) {
@@ -119,9 +130,10 @@ func Test_Reconcile(t *testing.T) {
 			assert.Equal(t, ctrl.Started, tc.expectStartCtrl, "fake controller should have been started")
 			cmpOpts := []cmp.Option{
 				cmpopts.EquateEmpty(),
-				cmpopts.IgnoreFields(metav1.ObjectMeta{}, "Annotations", "ResourceVersion"),
+				cmpopts.IgnoreFields(metav1.ObjectMeta{}, "Labels", "Annotations", "ResourceVersion"),
 				cmpopts.IgnoreFields(metav1.TypeMeta{}, "Kind", "APIVersion"),
 				cmpopts.IgnoreFields(apiextensionsv1.CustomResourceDefinition{}, "Spec"),
+				cmpopts.IgnoreFields(rbacv1.ClusterRole{}, "Rules", "AggregationRule"),
 			}
 			if diff := cmp.Diff(tc.expectCreate, cl.Added, cmpOpts...); diff != "" {
 				t.Fatalf("found diff between expected and actual creates: %s", diff)
@@ -140,6 +152,7 @@ func TestReconcileOnlyStartsControllerOnce(t *testing.T) {
 	scheme := runtime.NewScheme()
 	configv1.Install(scheme)
 	apiextensionsv1.AddToScheme(scheme)
+	rbacv1.AddToScheme(scheme)
 	fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects().Build()
 	cl := &testutil.FakeClientRecorder{fakeClient, t, []client.Object{}, []client.Object{}, []client.Object{}}
 	ctrl := &testutil.FakeController{t, false, make(chan struct{})}
diff --git a/pkg/operator/controller/gatewayapi/rbac.go b/pkg/operator/controller/gatewayapi/rbac.go
new file mode 100644
index 0000000000..6c495550d0
--- /dev/null
+++ b/pkg/operator/controller/gatewayapi/rbac.go
@@ -0,0 +1,123 @@
+package gatewayapi
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/openshift/cluster-ingress-operator/pkg/manifests"
+
+	rbacv1 "k8s.io/api/rbac/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+var managedClusterRoles = []*rbacv1.ClusterRole{
+	manifests.GatewayAPIAdminClusterRole(),
+	manifests.GatewayAPIViewClusterRole(),
+}
+
+// ensureGatewayAPIRBAC ensures that all RBAC resources for Gateway API are
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureGatewayAPIRBAC(ctx context.Context) error {
+	var errs []error
+
+	for _, clusterRole := range managedClusterRoles {
+		if err := r.ensureClusterRole(ctx, clusterRole); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	return utilerrors.NewAggregate(errs)
+}
+
+// ensureClusterRole ensures that a specific ClusterRole RBAC resource is
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	clusterRoleAlreadyExists, current, err := r.currentClusterRole(ctx, desired.Name)
+	if err != nil {
+		return err
+	}
+
+	if clusterRoleAlreadyExists {
+		if updated, err := r.updateClusterRole(ctx, current, desired); err != nil {
+			return err
+		} else if updated {
+			if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+				return err
+			}
+		}
+	} else {
+		return r.createClusterRole(ctx, desired)
+	}
+
+	return nil
+}
+
+// currentClusterRole retrieves a ClusterRole from the cluster, given the name
+// of that role.
+func (r *reconciler) currentClusterRole(ctx context.Context, name string) (bool, *rbacv1.ClusterRole, error) {
+	var existingClusterRole rbacv1.ClusterRole
+
+	if err := r.client.Get(ctx, types.NamespacedName{Name: name}, &existingClusterRole); err != nil {
+		if errors.IsNotFound(err) {
+			return false, nil, nil
+		}
+		return false, nil, fmt.Errorf("failed to get ClusterRole %s: %w", name, err)
+	}
+
+	return true, &existingClusterRole, nil
+}
+
+// createClusterRole creates the desired ClusterRole on the cluster.
+func (r *reconciler) createClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	desired.ResourceVersion = ""
+
+	if err := r.client.Create(ctx, desired); err != nil {
+		return fmt.Errorf("failed to create ClusterRole %s: %w", desired.Name, err)
+	}
+
+	log.Info("Created ClusterRole", "name", desired.Name)
+
+	return nil
+}
+
+// updateClusterRole verifies whether the current ClusterRole is up to date with
+// what is desired, and performs an update if not.
+func (r *reconciler) updateClusterRole(ctx context.Context, current, desired *rbacv1.ClusterRole) (bool, error) {
+	changed, updated := clusterRoleChanged(current, desired)
+	if !changed {
+		return false, nil
+	}
+
+	// Diff before updating because the client may mutate the object.
+	diff := cmp.Diff(current, updated, cmpopts.EquateEmpty())
+	if err := r.client.Update(ctx, updated); err != nil {
+		return false, fmt.Errorf("failed to update ClusterRole %s: %w", updated.Name, err)
+	}
+
+	log.Info("updated ClusterRole", "name", updated.Name, "diff", diff)
+
+	return true, nil
+}
+
+// clusterRoleChanged indicates whether there are changes between the current
+// ClusterRole and what is expected, and provides the updated version of the
+// ClusterRole if so.
+func clusterRoleChanged(current, expected *rbacv1.ClusterRole) (bool, *rbacv1.ClusterRole) {
+	if cmp.Equal(current.Rules, expected.Rules) &&
+		cmp.Equal(current.ObjectMeta.Labels, expected.ObjectMeta.Labels) &&
+		cmp.Equal(current.ObjectMeta.Annotations, expected.ObjectMeta.Annotations) {
+		return false, nil
+	}
+
+	updated := current.DeepCopy()
+	updated.ObjectMeta.Labels = expected.ObjectMeta.Labels
+	updated.ObjectMeta.Annotations = expected.ObjectMeta.Annotations
+	updated.Rules = expected.Rules
+
+	return true, updated
+}
diff --git a/test/e2e/gateway_api_test.go b/test/e2e/gateway_api_test.go
index 4db396edc4..b4e3e96987 100644
--- a/test/e2e/gateway_api_test.go
+++ b/test/e2e/gateway_api_test.go
@@ -105,6 +105,8 @@ func TestGatewayAPI(t *testing.T) {
 		t.Log("Gateway API Controller not enabled, skipping controller tests")
 	}
 	t.Run("testGatewayAPIResourcesProtection", testGatewayAPIResourcesProtection)
+
+	t.Run("testGatewayAPIRBAC", testGatewayAPIRBAC)
 }
 
 // testGatewayAPIResources tests that Gateway API Custom Resource Definitions are available.
@@ -402,6 +404,25 @@ func testGatewayAPIResourcesProtection(t *testing.T) {
 	}
 }
 
+// testGatewayAPIRBAC checks whether RBAC resources for Gateway API (such as the
+// aggregated ClusterRoles) are properly deployed and aggregated.
+func testGatewayAPIRBAC(t *testing.T) {
+	aggregationMapping := map[string][]string{
+		"system:openshift:gateway-api:aggregate-to-admin": {"admin", "edit"},
+		"system:openshift:gateway-api:aggregate-to-view":  {"view"},
+	}
+
+	for srcClusterRoleName, destClusterRoleNames := range aggregationMapping {
+		for _, destClusterRoleName := range destClusterRoleNames {
+			t.Logf("verifying that ClusterRole %s aggregates all PolicyRules from %s", destClusterRoleName, srcClusterRoleName)
+
+			if err := eventuallyClusterRoleContainsAggregatedPolicies(t, destClusterRoleName, srcClusterRoleName); err != nil {
+				t.Errorf("ClusterRole %s did not aggregate PolicyRules from %s", destClusterRoleName, srcClusterRoleName)
+			}
+		}
+	}
+}
+
 // ensureCRDs tests that the Gateway API custom resource definitions exist.
 func ensureCRDs(t *testing.T) {
 	t.Helper()
diff --git a/test/e2e/util_gatewayapi_test.go b/test/e2e/util_gatewayapi_test.go
index a830e268a2..0349fe6471 100644
--- a/test/e2e/util_gatewayapi_test.go
+++ b/test/e2e/util_gatewayapi_test.go
@@ -11,6 +11,7 @@ import (
 	"io/ioutil"
 	"net"
 	"net/http"
+	"slices"
 	"strings"
 	"testing"
 	"time"
@@ -21,9 +22,11 @@ import (
 	util "github.com/openshift/cluster-ingress-operator/pkg/util"
 	operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1"
 
+	"github.com/google/go-cmp/cmp"
 	admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
+	rbacv1 "k8s.io/api/rbac/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	kerrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -947,3 +950,48 @@ func (m *vapManager) enable() {
 		m.t.Errorf("failed to find vap %q: %v", m.name, err)
 	}
 }
+
+func eventuallyClusterRoleContainsAggregatedPolicies(t *testing.T, destClusterRoleName, srcClusterRoleName string) error {
+	t.Helper()
+
+	return wait.PollImmediate(time.Second, timeout, func() (bool, error) {
+		var destClusterRole rbacv1.ClusterRole
+		if err := kclient.Get(context.Background(), types.NamespacedName{Name: destClusterRoleName}, &destClusterRole); err != nil {
+			t.Logf("Failed to get destination ClusterRole %s; retrying...: %v", destClusterRoleName, err)
+			return false, nil
+		}
+
+		var srcClusterRole rbacv1.ClusterRole
+		if err := kclient.Get(context.Background(), types.NamespacedName{Name: srcClusterRoleName}, &srcClusterRole); err != nil {
+			t.Logf("Failed to get source ClusterRole %s: %v; retrying...", srcClusterRoleName, err)
+			return false, nil
+		}
+
+		if len(destClusterRole.Rules) == 0 {
+			return false, fmt.Errorf("ClusterRole %s unexpectedly had no PolicyRules set", destClusterRoleName)
+		}
+
+		if len(srcClusterRole.Rules) == 0 {
+			return false, fmt.Errorf("ClusterRole %s unexpectedly had no PolicyRules set", srcClusterRoleName)
+		}
+
+		if containsPolicyRules(destClusterRole.Rules, srcClusterRole.Rules) {
+			t.Logf("ClusterRole %s aggregated all rules from %s", destClusterRoleName, srcClusterRoleName)
+			return true, nil
+		}
+
+		return false, nil
+	})
+}
+
+func containsPolicyRules(destRules, srcRules []rbacv1.PolicyRule) bool {
+	for _, srcRule := range srcRules {
+		if !slices.ContainsFunc(destRules, func(destRule rbacv1.PolicyRule) bool {
+			return cmp.Equal(destRule, srcRule)
+		}) {
+			return false
+		}
+	}
+
+	return true
+}


Reviews:
user: login: Miciah
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T00:39:10Z
user: login: shaneutt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T11:54:00Z
user: login: shaneutt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T11:54:48Z
user: login: shaneutt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T11:55:36Z
user: login: Miciah
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T07:00:06Z
user: login: Miciah
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T07:02:07Z
user: login: Miciah
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T07:03:58Z
user: login: Miciah
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T07:04:51Z
user: login: Miciah
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T07:07:26Z
user: login: Miciah
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T07:09:20Z
user: login: Miciah
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T07:17:37Z
user: login: shaneutt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T11:54:10Z
user: login: shaneutt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T11:55:02Z

Review comments:
diff_hunk: @@ -0,0 +1,127 @@
+package gatewayapi
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/openshift/cluster-ingress-operator/pkg/manifests"
+
+	rbacv1 "k8s.io/api/rbac/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+var managedClusterRoles = []*rbacv1.ClusterRole{
+	manifests.GatewayAPIAdminClusterRole(),
+	manifests.GatewayAPIViewClusterRole(),
+}
+
+// ensureGatewayAPIRBAC ensures that all RBAC resources for Gateway API are
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureGatewayAPIRBAC(ctx context.Context) error {
+	var errs []error
+
+	for _, clusterRole := range managedClusterRoles {
+		if err := r.ensureClusterRole(ctx, clusterRole); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	return utilerrors.NewAggregate(errs)
+}
+
+// ensureClusterRole ensures that a specific ClusterRole RBAC resource is
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	clusterRoleAlreadyExists, current, err := r.currentClusterRole(ctx, desired.Name)
+	if err != nil {
+		return err
+	}
+
+	if clusterRoleAlreadyExists {
+		if updated, err := r.updateClusterRole(ctx, current, desired); err != nil {
+			return err
+		} else if updated {
+			if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+				return err
+			}
+		}
+	} else {
+		if err := r.createClusterRole(ctx, desired); err != nil {
+			return err
+		}
+		if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// currentClusterRole retrieves a ClusterRole from the cluster, given the name
+// of that role.
+func (r *reconciler) currentClusterRole(ctx context.Context, name string) (bool, *rbacv1.ClusterRole, error) {
+	var existingClusterRole rbacv1.ClusterRole
+
+	if err := r.client.Get(ctx, types.NamespacedName{Name: name}, &existingClusterRole); err != nil {
+		if errors.IsNotFound(err) {
+			return false, nil, nil
+		}
+		return false, nil, fmt.Errorf("failed to get ClusterRole %s: %w", name, err)
+	}
+
+	return true, &existingClusterRole, nil
+}
+
+// createClusterRole creates the desired ClusterRole on the cluster.
+func (r *reconciler) createClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	if err := r.client.Create(ctx, desired); err != nil {
+		return fmt.Errorf("failed to create ClusterRole %s: %w", desired.Name, err)
+	}
+
+	log.Info("created ClusterRole", "name", desired.Name)
+
+	return nil
+}
+
+// updateClusterRole verifies whether the current ClusterRole is up to date with
+// what is desired, and performs an update if not.
+func (r *reconciler) updateClusterRole(ctx context.Context, current, desired *rbacv1.ClusterRole) (bool, error) {
+	changed, updated := clusterRoleChanged(current, desired)
+	if !changed {
+		return false, nil
+	}
+
+	// Diff before updating because the client may mutate the object.
+	diff := cmp.Diff(current, updated, cmpopts.EquateEmpty())
+	if err := r.client.Update(ctx, updated); err != nil {
+		return false, fmt.Errorf("failed to update ClusterRole %s: %w", updated.Name, err)
+	}
+
+	log.Info("updated ClusterRole", "name", updated.Name, "diff", diff)
+
+	return true, nil
+}
+
+// clusterRoleChanged indicates whether there are changes between the current
+// ClusterRole and what is expected, and provides the updated version of the
+// ClusterRole if so.
+func clusterRoleChanged(current, expected *rbacv1.ClusterRole) (bool, *rbacv1.ClusterRole) {
+	if cmp.Equal(current.Rules, expected.Rules) &&
+		cmp.Equal(current.ObjectMeta.Labels, expected.ObjectMeta.Labels) &&
+		cmp.Equal(current.ObjectMeta.Annotations, expected.ObjectMeta.Annotations) &&
+		cmp.Equal(current.ObjectMeta.Finalizers, expected.ObjectMeta.Finalizers) {
+		return false, nil
+	}
+
+	updated := current.DeepCopy()
+	updated.ObjectMeta.Labels = expected.ObjectMeta.Labels
+	updated.ObjectMeta.Annotations = expected.ObjectMeta.Annotations
+	updated.Rules = expected.Rules
path: pkg/operator/controller/gatewayapi/rbac.go
user: login: Miciah
body: Do we really care about finalizers?  If so, this function needs to update `updated.ObjectMeta.Finalizers`:
```suggestion
	if cmp.Equal(current.Rules, expected.Rules) &&
		cmp.Equal(current.ObjectMeta.Labels, expected.ObjectMeta.Labels) &&
		cmp.Equal(current.ObjectMeta.Annotations, expected.ObjectMeta.Annotations) &&
		cmp.Equal(current.ObjectMeta.Finalizers, expected.ObjectMeta.Finalizers) {
		return false, nil
	}

	updated := current.DeepCopy()
	updated.ObjectMeta.Labels = expected.ObjectMeta.Labels
	updated.ObjectMeta.Annotations = expected.ObjectMeta.Annotations
	updated.Rules = expected.Rules
	updated.ObjectMeta.Finalizers = expected.ObjectMeta.Finalizers
```
And the it might also be appropriate to check the deletion timestamp to make sure we don't try to add finalizers to an object that is marked for deletion.  
created_at: 2025-03-28T00:23:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -941,3 +944,52 @@ func (m *vapManager) enable() {
 		m.t.Errorf("failed to find vap %q: %v", m.name, err)
 	}
 }
+
+func eventuallyClusterRoleContainsAggregatedPolicies(t *testing.T, destClusterRoleName, srcClusterRoleName string) error {
+	t.Helper()
+
+	return wait.PollImmediate(time.Second, timeout, func() (bool, error) {
+		var destClusterRole rbacv1.ClusterRole
+		if err := kclient.Get(context.Background(), types.NamespacedName{Name: destClusterRoleName}, &destClusterRole); err != nil {
+			if kerrors.IsNotFound(err) {
+				return false, nil
+			}
+			return false, err
path: test/e2e/util_gatewayapi_test.go
user: login: Miciah
body: We usually retry on all errors, and in this case, I think it is notable and worth logging if you get a not-found error for any of the standard clusterroles.  
```suggestion
			t.Logf("Failed to get destination ClusterRole %s; retrying...: %v", err)
			return false, nil
```
created_at: 2025-03-28T00:27:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -941,3 +944,52 @@ func (m *vapManager) enable() {
 		m.t.Errorf("failed to find vap %q: %v", m.name, err)
 	}
 }
+
+func eventuallyClusterRoleContainsAggregatedPolicies(t *testing.T, destClusterRoleName, srcClusterRoleName string) error {
+	t.Helper()
+
+	return wait.PollImmediate(time.Second, timeout, func() (bool, error) {
+		var destClusterRole rbacv1.ClusterRole
+		if err := kclient.Get(context.Background(), types.NamespacedName{Name: destClusterRoleName}, &destClusterRole); err != nil {
+			if kerrors.IsNotFound(err) {
+				return false, nil
+			}
+			return false, err
+		}
+
+		var srcClusterRole rbacv1.ClusterRole
+		if err := kclient.Get(context.Background(), types.NamespacedName{Name: srcClusterRoleName}, &srcClusterRole); err != nil {
+			if kerrors.IsNotFound(err) {
+				return false, nil
+			}
+			return false, err
path: test/e2e/util_gatewayapi_test.go
user: login: Miciah
body: ```suggestion
			t.Logf("Failed to get source ClusterRole %s: %v; retrying...", err)
			return false, nil
```
created_at: 2025-03-28T00:28:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,28 @@
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: system:gateway-api:aggregate-to-admin
path: pkg/manifests/assets/gateway-api/aggregated-cluster-roles/admin-cluster-role.yaml
user: login: Miciah
body: This is a fairly generic, non-OpenShift-specific name for a cluster-scoped resource.  What happens if the cluster-admin is already using this name for a clusterrole?  
created_at: 2025-03-28T00:30:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+package gatewayapi
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/openshift/cluster-ingress-operator/pkg/manifests"
+
+	rbacv1 "k8s.io/api/rbac/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+var managedClusterRoles = []*rbacv1.ClusterRole{
+	manifests.GatewayAPIAdminClusterRole(),
+	manifests.GatewayAPIViewClusterRole(),
+}
+
+// ensureGatewayAPIRBAC ensures that all RBAC resources for Gateway API are
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureGatewayAPIRBAC(ctx context.Context) error {
+	var errs []error
+
+	for _, clusterRole := range managedClusterRoles {
+		if err := r.ensureClusterRole(ctx, clusterRole); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	return utilerrors.NewAggregate(errs)
+}
+
+// ensureClusterRole ensures that a specific ClusterRole RBAC resource is
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	clusterRoleAlreadyExists, current, err := r.currentClusterRole(ctx, desired.Name)
+	if err != nil {
+		return err
+	}
+
+	if clusterRoleAlreadyExists {
+		if updated, err := r.updateClusterRole(ctx, current, desired); err != nil {
+			return err
+		} else if updated {
+			if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+				return err
+			}
+		}
+	} else {
+		if err := r.createClusterRole(ctx, desired); err != nil {
+			return err
+		}
+		if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+			return err
+		}
path: pkg/operator/controller/gatewayapi/rbac.go
user: login: Miciah
body: Is it really useful to get the resource again after doing a create or an update?  Some of our `ensureFoo` functions to this so that they can return the created or updated object, but otherwise it seems unnecessary.  
created_at: 2025-03-28T00:32:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+package gatewayapi
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/openshift/cluster-ingress-operator/pkg/manifests"
+
+	rbacv1 "k8s.io/api/rbac/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+var managedClusterRoles = []*rbacv1.ClusterRole{
+	manifests.GatewayAPIAdminClusterRole(),
+	manifests.GatewayAPIViewClusterRole(),
+}
+
+// ensureGatewayAPIRBAC ensures that all RBAC resources for Gateway API are
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureGatewayAPIRBAC(ctx context.Context) error {
+	var errs []error
+
+	for _, clusterRole := range managedClusterRoles {
+		if err := r.ensureClusterRole(ctx, clusterRole); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	return utilerrors.NewAggregate(errs)
+}
+
+// ensureClusterRole ensures that a specific ClusterRole RBAC resource is
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	clusterRoleAlreadyExists, current, err := r.currentClusterRole(ctx, desired.Name)
+	if err != nil {
+		return err
+	}
+
+	if clusterRoleAlreadyExists {
+		if updated, err := r.updateClusterRole(ctx, current, desired); err != nil {
+			return err
+		} else if updated {
+			if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+				return err
+			}
+		}
+	} else {
+		if err := r.createClusterRole(ctx, desired); err != nil {
+			return err
+		}
+		if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// currentClusterRole retrieves a ClusterRole from the cluster, given the name
+// of that role.
+func (r *reconciler) currentClusterRole(ctx context.Context, name string) (bool, *rbacv1.ClusterRole, error) {
+	var existingClusterRole rbacv1.ClusterRole
+
+	if err := r.client.Get(ctx, types.NamespacedName{Name: name}, &existingClusterRole); err != nil {
+		if errors.IsNotFound(err) {
+			return false, nil, nil
+		}
+		return false, nil, fmt.Errorf("failed to get ClusterRole %s: %w", name, err)
+	}
+
+	return true, &existingClusterRole, nil
+}
+
+// createClusterRole creates the desired ClusterRole on the cluster.
+func (r *reconciler) createClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	if err := r.client.Create(ctx, desired); err != nil {
+		return fmt.Errorf("failed to create ClusterRole %s: %w", desired.Name, err)
+	}
+
+	log.Info("created ClusterRole", "name", desired.Name)
path: pkg/operator/controller/gatewayapi/rbac.go
user: login: Miciah
body: I recently realized that the Kubernetes guidelines specify that every log message should start with a capital letter: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md#message-style-guidelines
```suggestion
	log.Info("Created ClusterRole", "name", desired.Name)
```
Same applies to the `log.Info` in `updateClusterRole` and the instances of `t.Logf` in `test/e2e/gateway_api_test.go` in the last commit.  
created_at: 2025-03-28T00:38:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,28 @@
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: system:gateway-api:aggregate-to-admin
path: pkg/manifests/assets/gateway-api/aggregated-cluster-roles/admin-cluster-role.yaml
user: login: shaneutt
body: Yeah, this is just draft cruft I was looking for a better name I think I found one based on looking at other ones other operators used :+1: 
created_at: 2025-03-28T11:54:00Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,127 @@
+package gatewayapi
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/openshift/cluster-ingress-operator/pkg/manifests"
+
+	rbacv1 "k8s.io/api/rbac/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+var managedClusterRoles = []*rbacv1.ClusterRole{
+	manifests.GatewayAPIAdminClusterRole(),
+	manifests.GatewayAPIViewClusterRole(),
+}
+
+// ensureGatewayAPIRBAC ensures that all RBAC resources for Gateway API are
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureGatewayAPIRBAC(ctx context.Context) error {
+	var errs []error
+
+	for _, clusterRole := range managedClusterRoles {
+		if err := r.ensureClusterRole(ctx, clusterRole); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	return utilerrors.NewAggregate(errs)
+}
+
+// ensureClusterRole ensures that a specific ClusterRole RBAC resource is
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	clusterRoleAlreadyExists, current, err := r.currentClusterRole(ctx, desired.Name)
+	if err != nil {
+		return err
+	}
+
+	if clusterRoleAlreadyExists {
+		if updated, err := r.updateClusterRole(ctx, current, desired); err != nil {
+			return err
+		} else if updated {
+			if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+				return err
+			}
+		}
+	} else {
+		if err := r.createClusterRole(ctx, desired); err != nil {
+			return err
+		}
+		if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+			return err
+		}
path: pkg/operator/controller/gatewayapi/rbac.go
user: login: shaneutt
body: Yeah, draft cruft :+1: 
created_at: 2025-03-28T11:54:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,127 @@
+package gatewayapi
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/openshift/cluster-ingress-operator/pkg/manifests"
+
+	rbacv1 "k8s.io/api/rbac/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+var managedClusterRoles = []*rbacv1.ClusterRole{
+	manifests.GatewayAPIAdminClusterRole(),
+	manifests.GatewayAPIViewClusterRole(),
+}
+
+// ensureGatewayAPIRBAC ensures that all RBAC resources for Gateway API are
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureGatewayAPIRBAC(ctx context.Context) error {
+	var errs []error
+
+	for _, clusterRole := range managedClusterRoles {
+		if err := r.ensureClusterRole(ctx, clusterRole); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	return utilerrors.NewAggregate(errs)
+}
+
+// ensureClusterRole ensures that a specific ClusterRole RBAC resource is
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	clusterRoleAlreadyExists, current, err := r.currentClusterRole(ctx, desired.Name)
+	if err != nil {
+		return err
+	}
+
+	if clusterRoleAlreadyExists {
+		if updated, err := r.updateClusterRole(ctx, current, desired); err != nil {
+			return err
+		} else if updated {
+			if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+				return err
+			}
+		}
+	} else {
+		if err := r.createClusterRole(ctx, desired); err != nil {
+			return err
+		}
+		if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// currentClusterRole retrieves a ClusterRole from the cluster, given the name
+// of that role.
+func (r *reconciler) currentClusterRole(ctx context.Context, name string) (bool, *rbacv1.ClusterRole, error) {
+	var existingClusterRole rbacv1.ClusterRole
+
+	if err := r.client.Get(ctx, types.NamespacedName{Name: name}, &existingClusterRole); err != nil {
+		if errors.IsNotFound(err) {
+			return false, nil, nil
+		}
+		return false, nil, fmt.Errorf("failed to get ClusterRole %s: %w", name, err)
+	}
+
+	return true, &existingClusterRole, nil
+}
+
+// createClusterRole creates the desired ClusterRole on the cluster.
+func (r *reconciler) createClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	if err := r.client.Create(ctx, desired); err != nil {
+		return fmt.Errorf("failed to create ClusterRole %s: %w", desired.Name, err)
+	}
+
+	log.Info("created ClusterRole", "name", desired.Name)
+
+	return nil
+}
+
+// updateClusterRole verifies whether the current ClusterRole is up to date with
+// what is desired, and performs an update if not.
+func (r *reconciler) updateClusterRole(ctx context.Context, current, desired *rbacv1.ClusterRole) (bool, error) {
+	changed, updated := clusterRoleChanged(current, desired)
+	if !changed {
+		return false, nil
+	}
+
+	// Diff before updating because the client may mutate the object.
+	diff := cmp.Diff(current, updated, cmpopts.EquateEmpty())
+	if err := r.client.Update(ctx, updated); err != nil {
+		return false, fmt.Errorf("failed to update ClusterRole %s: %w", updated.Name, err)
+	}
+
+	log.Info("updated ClusterRole", "name", updated.Name, "diff", diff)
+
+	return true, nil
+}
+
+// clusterRoleChanged indicates whether there are changes between the current
+// ClusterRole and what is expected, and provides the updated version of the
+// ClusterRole if so.
+func clusterRoleChanged(current, expected *rbacv1.ClusterRole) (bool, *rbacv1.ClusterRole) {
+	if cmp.Equal(current.Rules, expected.Rules) &&
+		cmp.Equal(current.ObjectMeta.Labels, expected.ObjectMeta.Labels) &&
+		cmp.Equal(current.ObjectMeta.Annotations, expected.ObjectMeta.Annotations) &&
+		cmp.Equal(current.ObjectMeta.Finalizers, expected.ObjectMeta.Finalizers) {
+		return false, nil
+	}
+
+	updated := current.DeepCopy()
+	updated.ObjectMeta.Labels = expected.ObjectMeta.Labels
+	updated.ObjectMeta.Annotations = expected.ObjectMeta.Annotations
+	updated.Rules = expected.Rules
path: pkg/operator/controller/gatewayapi/rbac.go
user: login: shaneutt
body: Draft cruft, hadn't self-reviewed yet and we don't need to worry about finalizers :+1: 

```suggestion
	if cmp.Equal(current.Rules, expected.Rules) &&
		cmp.Equal(current.ObjectMeta.Labels, expected.ObjectMeta.Labels) &&
		cmp.Equal(current.ObjectMeta.Annotations, expected.ObjectMeta.Annotations) {
		return false, nil
	}

	updated := current.DeepCopy()
	updated.ObjectMeta.Labels = expected.ObjectMeta.Labels
	updated.ObjectMeta.Annotations = expected.ObjectMeta.Annotations
	updated.Rules = expected.Rules
```
created_at: 2025-03-28T11:55:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,123 @@
+package gatewayapi
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/openshift/cluster-ingress-operator/pkg/manifests"
+
+	rbacv1 "k8s.io/api/rbac/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+var managedClusterRoles = []*rbacv1.ClusterRole{
+	manifests.GatewayAPIAdminClusterRole(),
+	manifests.GatewayAPIViewClusterRole(),
+}
+
+// ensureGatewayAPIRBAC ensures that all RBAC resources for Gateway API are
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureGatewayAPIRBAC(ctx context.Context) error {
+	var errs []error
+
+	for _, clusterRole := range managedClusterRoles {
+		if err := r.ensureClusterRole(ctx, clusterRole); err != nil {
+			errs = append(errs, err)
+		}
path: pkg/operator/controller/gatewayapi/rbac.go
user: login: Miciah
body: `NewAggregate` ignores nil values.  
```suggestion
		errs = append(errs, r.ensureClusterRole(ctx, clusterRole))
```
created_at: 2025-04-03T07:00:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,123 @@
+package gatewayapi
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/openshift/cluster-ingress-operator/pkg/manifests"
+
+	rbacv1 "k8s.io/api/rbac/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+var managedClusterRoles = []*rbacv1.ClusterRole{
+	manifests.GatewayAPIAdminClusterRole(),
+	manifests.GatewayAPIViewClusterRole(),
+}
+
+// ensureGatewayAPIRBAC ensures that all RBAC resources for Gateway API are
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureGatewayAPIRBAC(ctx context.Context) error {
+	var errs []error
+
+	for _, clusterRole := range managedClusterRoles {
+		if err := r.ensureClusterRole(ctx, clusterRole); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	return utilerrors.NewAggregate(errs)
+}
+
+// ensureClusterRole ensures that a specific ClusterRole RBAC resource is
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	clusterRoleAlreadyExists, current, err := r.currentClusterRole(ctx, desired.Name)
+	if err != nil {
+		return err
+	}
+
+	if clusterRoleAlreadyExists {
+		if updated, err := r.updateClusterRole(ctx, current, desired); err != nil {
+			return err
+		} else if updated {
+			if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+				return err
+			}
path: pkg/operator/controller/gatewayapi/rbac.go
user: login: Miciah
body: Is this get after update needed?  Related comment from my earlier review: https://github.com/openshift/cluster-ingress-operator/pull/1206#discussion_r2017755806
created_at: 2025-04-03T07:02:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,123 @@
+package gatewayapi
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/openshift/cluster-ingress-operator/pkg/manifests"
+
+	rbacv1 "k8s.io/api/rbac/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+var managedClusterRoles = []*rbacv1.ClusterRole{
+	manifests.GatewayAPIAdminClusterRole(),
+	manifests.GatewayAPIViewClusterRole(),
+}
+
+// ensureGatewayAPIRBAC ensures that all RBAC resources for Gateway API are
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureGatewayAPIRBAC(ctx context.Context) error {
+	var errs []error
+
+	for _, clusterRole := range managedClusterRoles {
+		if err := r.ensureClusterRole(ctx, clusterRole); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	return utilerrors.NewAggregate(errs)
+}
+
+// ensureClusterRole ensures that a specific ClusterRole RBAC resource is
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	clusterRoleAlreadyExists, current, err := r.currentClusterRole(ctx, desired.Name)
+	if err != nil {
+		return err
+	}
+
+	if clusterRoleAlreadyExists {
+		if updated, err := r.updateClusterRole(ctx, current, desired); err != nil {
+			return err
+		} else if updated {
+			if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+				return err
+			}
+		}
+	} else {
+		return r.createClusterRole(ctx, desired)
+	}
+
+	return nil
+}
+
+// currentClusterRole retrieves a ClusterRole from the cluster, given the name
+// of that role.
+func (r *reconciler) currentClusterRole(ctx context.Context, name string) (bool, *rbacv1.ClusterRole, error) {
+	var existingClusterRole rbacv1.ClusterRole
+
+	if err := r.client.Get(ctx, types.NamespacedName{Name: name}, &existingClusterRole); err != nil {
+		if errors.IsNotFound(err) {
+			return false, nil, nil
+		}
+		return false, nil, fmt.Errorf("failed to get ClusterRole %s: %w", name, err)
+	}
+
+	return true, &existingClusterRole, nil
+}
+
+// createClusterRole creates the desired ClusterRole on the cluster.
+func (r *reconciler) createClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	desired.ResourceVersion = ""
path: pkg/operator/controller/gatewayapi/rbac.go
user: login: Miciah
body: Why is this needed?  The object came from the manifest, so it won't have `revisionVersion` set anyway, right?
created_at: 2025-04-03T07:03:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,123 @@
+package gatewayapi
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/openshift/cluster-ingress-operator/pkg/manifests"
+
+	rbacv1 "k8s.io/api/rbac/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+var managedClusterRoles = []*rbacv1.ClusterRole{
+	manifests.GatewayAPIAdminClusterRole(),
+	manifests.GatewayAPIViewClusterRole(),
+}
+
+// ensureGatewayAPIRBAC ensures that all RBAC resources for Gateway API are
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureGatewayAPIRBAC(ctx context.Context) error {
+	var errs []error
+
+	for _, clusterRole := range managedClusterRoles {
+		if err := r.ensureClusterRole(ctx, clusterRole); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	return utilerrors.NewAggregate(errs)
+}
+
+// ensureClusterRole ensures that a specific ClusterRole RBAC resource is
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	clusterRoleAlreadyExists, current, err := r.currentClusterRole(ctx, desired.Name)
+	if err != nil {
+		return err
+	}
+
+	if clusterRoleAlreadyExists {
+		if updated, err := r.updateClusterRole(ctx, current, desired); err != nil {
+			return err
+		} else if updated {
+			if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+				return err
+			}
+		}
+	} else {
+		return r.createClusterRole(ctx, desired)
+	}
+
+	return nil
+}
+
+// currentClusterRole retrieves a ClusterRole from the cluster, given the name
+// of that role.
+func (r *reconciler) currentClusterRole(ctx context.Context, name string) (bool, *rbacv1.ClusterRole, error) {
+	var existingClusterRole rbacv1.ClusterRole
+
+	if err := r.client.Get(ctx, types.NamespacedName{Name: name}, &existingClusterRole); err != nil {
+		if errors.IsNotFound(err) {
+			return false, nil, nil
+		}
+		return false, nil, fmt.Errorf("failed to get ClusterRole %s: %w", name, err)
+	}
+
+	return true, &existingClusterRole, nil
+}
+
+// createClusterRole creates the desired ClusterRole on the cluster.
+func (r *reconciler) createClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	desired.ResourceVersion = ""
+
+	if err := r.client.Create(ctx, desired); err != nil {
+		return fmt.Errorf("failed to create ClusterRole %s: %w", desired.Name, err)
+	}
+
+	log.Info("Created ClusterRole", "name", desired.Name)
+
+	return nil
+}
+
+// updateClusterRole verifies whether the current ClusterRole is up to date with
+// what is desired, and performs an update if not.
+func (r *reconciler) updateClusterRole(ctx context.Context, current, desired *rbacv1.ClusterRole) (bool, error) {
+	changed, updated := clusterRoleChanged(current, desired)
+	if !changed {
+		return false, nil
+	}
+
+	// Diff before updating because the client may mutate the object.
+	diff := cmp.Diff(current, updated, cmpopts.EquateEmpty())
+	if err := r.client.Update(ctx, updated); err != nil {
+		return false, fmt.Errorf("failed to update ClusterRole %s: %w", updated.Name, err)
+	}
+
+	log.Info("updated ClusterRole", "name", updated.Name, "diff", diff)
path: pkg/operator/controller/gatewayapi/rbac.go
user: login: Miciah
body: ```suggestion
	log.Info("Updated ClusterRole", "name", updated.Name, "diff", diff)
```
created_at: 2025-04-03T07:04:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -402,6 +404,25 @@ func testGatewayAPIResourcesProtection(t *testing.T) {
 	}
 }
 
+// testGatewayAPIRBAC checks whether RBAC resources for Gateway API (such as the
+// aggregated ClusterRoles) are properly deployed and aggregated.
+func testGatewayAPIRBAC(t *testing.T) {
+	aggregationMapping := map[string][]string{
+		"system:openshift:gateway-api:aggregate-to-admin": {"admin", "edit"},
+		"system:openshift:gateway-api:aggregate-to-view":  {"view"},
+	}
+
+	for srcClusterRoleName, destClusterRoleNames := range aggregationMapping {
+		for _, destClusterRoleName := range destClusterRoleNames {
+			t.Logf("verifying that ClusterRole %s aggregates all PolicyRules from %s", destClusterRoleName, srcClusterRoleName)
path: test/e2e/gateway_api_test.go
user: login: Miciah
body: ```suggestion
			t.Logf("Verifying that ClusterRole %s aggregates all PolicyRules from %s", destClusterRoleName, srcClusterRoleName)
```
created_at: 2025-04-03T07:07:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -947,3 +950,48 @@ func (m *vapManager) enable() {
 		m.t.Errorf("failed to find vap %q: %v", m.name, err)
 	}
 }
+
+func eventuallyClusterRoleContainsAggregatedPolicies(t *testing.T, destClusterRoleName, srcClusterRoleName string) error {
+	t.Helper()
+
+	return wait.PollImmediate(time.Second, timeout, func() (bool, error) {
+		var destClusterRole rbacv1.ClusterRole
+		if err := kclient.Get(context.Background(), types.NamespacedName{Name: destClusterRoleName}, &destClusterRole); err != nil {
+			t.Logf("Failed to get destination ClusterRole %s; retrying...: %v", destClusterRoleName, err)
path: test/e2e/util_gatewayapi_test.go
user: login: Miciah
body: ```suggestion
			t.Logf("Failed to get destination ClusterRole %s: %v; retrying...", destClusterRoleName, err)
```
created_at: 2025-04-03T07:09:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -947,3 +950,48 @@ func (m *vapManager) enable() {
 		m.t.Errorf("failed to find vap %q: %v", m.name, err)
 	}
 }
+
+func eventuallyClusterRoleContainsAggregatedPolicies(t *testing.T, destClusterRoleName, srcClusterRoleName string) error {
+	t.Helper()
+
+	return wait.PollImmediate(time.Second, timeout, func() (bool, error) {
path: test/e2e/util_gatewayapi_test.go
user: login: Miciah
body: Yeesh, we have multiple tests using this `timeout` const, which is defined here: https://github.com/openshift/cluster-ingress-operator/blob/e60422dca3964c4f00b7cac64944d5ec2a874baa/test/e2e/configurable_route_test.go#L27

So we have multiple unrelated tests using the same generically named const, which is defined in an arbitrary location, with an arbitrary value, with no godoc.  We should clean that up at some point.  

I don't mean to pick on this PR though.  Evidently we've been cargo-culting this `timeout` for some time.  At some point, we should clean all these uses of `timeout` up.  
created_at: 2025-04-03T07:17:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,123 @@
+package gatewayapi
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/openshift/cluster-ingress-operator/pkg/manifests"
+
+	rbacv1 "k8s.io/api/rbac/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+var managedClusterRoles = []*rbacv1.ClusterRole{
+	manifests.GatewayAPIAdminClusterRole(),
+	manifests.GatewayAPIViewClusterRole(),
+}
+
+// ensureGatewayAPIRBAC ensures that all RBAC resources for Gateway API are
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureGatewayAPIRBAC(ctx context.Context) error {
+	var errs []error
+
+	for _, clusterRole := range managedClusterRoles {
+		if err := r.ensureClusterRole(ctx, clusterRole); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	return utilerrors.NewAggregate(errs)
+}
+
+// ensureClusterRole ensures that a specific ClusterRole RBAC resource is
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	clusterRoleAlreadyExists, current, err := r.currentClusterRole(ctx, desired.Name)
+	if err != nil {
+		return err
+	}
+
+	if clusterRoleAlreadyExists {
+		if updated, err := r.updateClusterRole(ctx, current, desired); err != nil {
+			return err
+		} else if updated {
+			if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+				return err
+			}
path: pkg/operator/controller/gatewayapi/rbac.go
user: login: shaneutt
body: Not needed: a leftover from copying the general structure of the CRD logic.
created_at: 2025-04-03T11:54:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,123 @@
+package gatewayapi
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/openshift/cluster-ingress-operator/pkg/manifests"
+
+	rbacv1 "k8s.io/api/rbac/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+var managedClusterRoles = []*rbacv1.ClusterRole{
+	manifests.GatewayAPIAdminClusterRole(),
+	manifests.GatewayAPIViewClusterRole(),
+}
+
+// ensureGatewayAPIRBAC ensures that all RBAC resources for Gateway API are
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureGatewayAPIRBAC(ctx context.Context) error {
+	var errs []error
+
+	for _, clusterRole := range managedClusterRoles {
+		if err := r.ensureClusterRole(ctx, clusterRole); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	return utilerrors.NewAggregate(errs)
+}
+
+// ensureClusterRole ensures that a specific ClusterRole RBAC resource is
+// deployed to the cluster, and up to date.
+func (r *reconciler) ensureClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	clusterRoleAlreadyExists, current, err := r.currentClusterRole(ctx, desired.Name)
+	if err != nil {
+		return err
+	}
+
+	if clusterRoleAlreadyExists {
+		if updated, err := r.updateClusterRole(ctx, current, desired); err != nil {
+			return err
+		} else if updated {
+			if _, _, err := r.currentClusterRole(ctx, desired.Name); err != nil {
+				return err
+			}
+		}
+	} else {
+		return r.createClusterRole(ctx, desired)
+	}
+
+	return nil
+}
+
+// currentClusterRole retrieves a ClusterRole from the cluster, given the name
+// of that role.
+func (r *reconciler) currentClusterRole(ctx context.Context, name string) (bool, *rbacv1.ClusterRole, error) {
+	var existingClusterRole rbacv1.ClusterRole
+
+	if err := r.client.Get(ctx, types.NamespacedName{Name: name}, &existingClusterRole); err != nil {
+		if errors.IsNotFound(err) {
+			return false, nil, nil
+		}
+		return false, nil, fmt.Errorf("failed to get ClusterRole %s: %w", name, err)
+	}
+
+	return true, &existingClusterRole, nil
+}
+
+// createClusterRole creates the desired ClusterRole on the cluster.
+func (r *reconciler) createClusterRole(ctx context.Context, desired *rbacv1.ClusterRole) error {
+	desired.ResourceVersion = ""
path: pkg/operator/controller/gatewayapi/rbac.go
user: login: shaneutt
body: Sorry, this line was leftover when I was doing some testing. This should not be needed, though it is negligible.
created_at: 2025-04-03T11:55:01Z
author_association: MEMBER

PR comments:
user: login: openshift-ci-robot
created_at: 2025-03-27T20:06:25Z
body: @shaneutt: This pull request references [NE-1912](https://issues.redhat.com//browse/NE-1912) which is a valid jira issue.

Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the story to target the "4.19.0" version, but no target version was set.

<details>

In response to [this](https://github.com/openshift/cluster-ingress-operator/pull/1206):

>This adds aggregated ClusterRoles for Gateway API as per the "Security Policy" in the Gateway API EP.


Instructions for interacting with me using PR comments are available [here](https://prow.ci.openshift.org/command-help?repo=openshift%2Fcluster-ingress-operator).  If you have questions or suggestions related to my behavior, please file an issue against the [openshift-eng/jira-lifecycle-plugin](https://github.com/openshift-eng/jira-lifecycle-plugin/issues/new) repository.
</details>
author_association: CONTRIBUTOR
user: login: openshift-ci[bot]
created_at: 2025-03-27T20:06:26Z
body: Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with `/test all`
author_association: CONTRIBUTOR
user: login: candita
created_at: 2025-03-31T19:04:07Z
body: Test failure in e2e-aws-operator-techpreview is unusual.  The dnsrecord isn't even created, even while trying to get it for one minute.
 
> util_gatewayapi_test.go:854: Failed to get DNSRecord openshift-ingress/test-gateway-f47b7d78-wildcard: dnsrecords.ingress.operator.openshift.io "test-gateway-f47b7d78-wildcard" not found; retrying...
 
author_association: CONTRIBUTOR
user: login: openshift-ci[bot]
created_at: 2025-04-01T22:50:22Z
body: @shaneutt: all tests **passed!**


[Full PR test history](https://prow.ci.openshift.org/pr-history?org=openshift&repo=cluster-ingress-operator&pr=1206). [Your PR dashboard](https://prow.ci.openshift.org/pr?query=is:pr+state:open+author:shaneutt).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
author_association: CONTRIBUTOR
user: login: knobunc
created_at: 2025-04-02T14:33:54Z
body: /approve

Looks good to me too, but I'll wait for a second review
author_association: CONTRIBUTOR
user: login: openshift-ci[bot]
created_at: 2025-04-02T14:35:17Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/openshift/cluster-ingress-operator/pull/1206#issuecomment-2772763510" title="Approved">knobunc</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=openshift%2Fcluster-ingress-operator).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/openshift/cluster-ingress-operator/blob/master/OWNERS)~~ [knobunc]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: candita
created_at: 2025-04-02T15:12:46Z
body: /assign @Miciah 
author_association: CONTRIBUTOR
user: login: Miciah
created_at: 2025-04-03T07:22:52Z
body: No major issues.  All suggestions can be addressed in follow-ups, if needed.  I hope our CI greens are still fresh...

/lgtm
author_association: CONTRIBUTOR
user: login: Miciah
created_at: 2025-04-03T07:52:53Z
body: Oh, does "merge-method-rebase" mean we get no merge commit?  
author_association: CONTRIBUTOR
user: login: shaneutt
created_at: 2025-04-03T12:00:40Z
body: > Oh, does "merge-method-rebase" mean we get no merge commit?

Right, it will apply the commits on top as individual commits without a merge commit. It didn't occur to me this would be an issue, if you prefer merge commits I'll keep this in mind for any of my future contributions.

---

As for your other comments: it's merged now (since we're on a tight schedule), but _if_ there happens to be any spare time for some refactoring in the near future I was thinking it might be good to look at places where things can be made more generic between the CRD and RBAC logic, given the similarities. That might be worth spending a few cycles after all the required deliverables are in.
author_association: MEMBER
user: login: openshift-bot
created_at: 2025-04-03T12:36:16Z
body: **[ART PR BUILD NOTIFIER]**

Distgit: ose-cluster-ingress-operator
This PR has been included in build [ose-cluster-ingress-operator-container-v4.19.0-202504031145.p0.g6fe4db1.assembly.stream.el9](https://brewweb.engineering.redhat.com/brew/buildinfo?buildID=3588601).
All builds following this will include this PR.
author_association: CONTRIBUTOR