Skip to content

Commit 257abf3

Browse files
Merge pull request #1192 from alebedev87/ne-1953-vap-deny-gwapi-crds
NE-1953: Add Validating Admission Policy for Gateway API CRDs
2 parents e80fa46 + 8e73871 commit 257abf3

File tree

6 files changed

+364
-4
lines changed

6 files changed

+364
-4
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ release-local:
5353

5454
.PHONY: test-e2e
5555
test-e2e: generate
56-
CGO_ENABLED=1 $(GO) test -timeout 1h -count 1 -v -tags e2e -run "$(TEST)" ./test/e2e
56+
CGO_ENABLED=1 $(GO) test -timeout 1.5h -count 1 -v -tags e2e -run "$(TEST)" ./test/e2e
5757

5858
.PHONY: test-e2e-list
5959
test-e2e-list: generate
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
apiVersion: admissionregistration.k8s.io/v1
2+
kind: ValidatingAdmissionPolicyBinding
3+
metadata:
4+
name: openshift-ingress-operator-gatewayapi-crd-admission
5+
annotations:
6+
capability.openshift.io/name: Ingress
7+
include.release.openshift.io/ibm-cloud-managed: "true"
8+
include.release.openshift.io/self-managed-high-availability: "true"
9+
include.release.openshift.io/single-node-developer: "true"
10+
release.openshift.io/feature-set: "TechPreviewNoUpgrade,CustomNoUpgrade"
11+
spec:
12+
policyName: openshift-ingress-operator-gatewayapi-crd-admission
13+
validationActions: [Deny]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
apiVersion: admissionregistration.k8s.io/v1
2+
kind: ValidatingAdmissionPolicy
3+
metadata:
4+
name: openshift-ingress-operator-gatewayapi-crd-admission
5+
annotations:
6+
capability.openshift.io/name: Ingress
7+
include.release.openshift.io/ibm-cloud-managed: "true"
8+
include.release.openshift.io/self-managed-high-availability: "true"
9+
include.release.openshift.io/single-node-developer: "true"
10+
release.openshift.io/feature-set: "TechPreviewNoUpgrade,CustomNoUpgrade"
11+
spec:
12+
matchConstraints:
13+
# Consider only requests to CRD resources.
14+
resourceRules:
15+
- apiGroups: ["apiextensions.k8s.io"]
16+
apiVersions: ["v1"]
17+
operations: ["CREATE","UPDATE","DELETE"]
18+
resources: ["customresourcedefinitions"]
19+
matchConditions:
20+
# Consider only request to Gateway API CRDs.
21+
- name: "check-only-gateway-api-crds"
22+
# When the operation is DELETE, the "object" variable is null.
23+
expression: "(request.operation == 'DELETE' ? oldObject : object).spec.group == 'gateway.networking.k8s.io'"
24+
# Validations are evaluated in the the order of their declaration.
25+
validations:
26+
# Verify that the request was sent by the ingress operator's service account.
27+
- expression: "has(request.userInfo.username) && (request.userInfo.username == 'system:serviceaccount:openshift-ingress-operator:ingress-operator')"
28+
message: "Gateway API Custom Resource Definitions are managed by the Ingress Operator and may not be modified"
29+
reason: Forbidden
30+
# Verify that the request was sent from a pod. The presence of both "node" and "pod" claims implies that the token is bound to a pod object.
31+
# Ref: https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/#schema-for-service-account-private-claims.
32+
- expression: "has(request.userInfo.extra) && ('authentication.kubernetes.io/node-name' in request.userInfo.extra) && ('authentication.kubernetes.io/pod-name' in request.userInfo.extra)"
33+
message: "this user must have both \"authentication.kubernetes.io/node-name\" and \"authentication.kubernetes.io/pod-name\" claims"
34+
reason: Forbidden
35+
# Fail the admission if any validation evaluates to false.
36+
failurePolicy: Fail

test/e2e/all_test.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ func TestAll(t *testing.T) {
8383
t.Run("TestSetRouteResponseHeaders", TestSetRouteResponseHeaders)
8484
t.Run("TestReconcileInternalService", TestReconcileInternalService)
8585
t.Run("TestConnectTimeout", TestConnectTimeout)
86-
t.Run("TestGatewayAPI", TestGatewayAPI)
8786
t.Run("TestAWSLBSubnets", TestAWSLBSubnets)
8887
t.Run("TestUnmanagedAWSLBSubnets", TestUnmanagedAWSLBSubnets)
8988
t.Run("TestAWSEIPAllocationsForNLB", TestAWSEIPAllocationsForNLB)
@@ -126,5 +125,16 @@ func TestAll(t *testing.T) {
126125
t.Run("TestRouteHardStopAfterEnableOnIngressControllerHasPriorityOverIngressConfig", TestRouteHardStopAfterEnableOnIngressControllerHasPriorityOverIngressConfig)
127126
t.Run("TestHostNetworkPortBinding", TestHostNetworkPortBinding)
128127
t.Run("TestDashboardCreation", TestDashboardCreation)
128+
// TestGatewayAPI creates a test ServiceMeshControlPlane (SMCP) resource,
129+
// which triggers the creation of a mutating webhook for all pods in the cluster.
130+
// This introduces a race condition where any pod creation request between
131+
// the webhook's creation and the SMCP pod becoming ready can fail with:
132+
//
133+
// failed calling webhook "sidecar-injector.istio.io": failed to call webhook:
134+
// no endpoints available for service "istiod-openshift-gateway"
135+
//
136+
// Serializing the test ensures it runs in isolation with other tests,
137+
// preventing any impact of the mutating webhook on pod creation in the cluster
138+
t.Run("TestGatewayAPI", TestGatewayAPI)
129139
})
130140
}

test/e2e/gateway_api_test.go

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,20 @@ import (
1010
"testing"
1111

1212
"github.com/openshift/api/features"
13+
operatorclient "github.com/openshift/cluster-ingress-operator/pkg/operator/client"
1314
operatorcontroller "github.com/openshift/cluster-ingress-operator/pkg/operator/controller"
1415
"github.com/openshift/cluster-ingress-operator/pkg/operator/controller/gatewayclass"
1516

1617
corev1 "k8s.io/api/core/v1"
18+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
1719
"k8s.io/apimachinery/pkg/api/errors"
1820
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
21+
"k8s.io/apimachinery/pkg/types"
1922
"k8s.io/apiserver/pkg/storage/names"
23+
"k8s.io/client-go/rest"
2024

25+
"sigs.k8s.io/controller-runtime/pkg/client"
26+
"sigs.k8s.io/controller-runtime/pkg/client/config"
2127
gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1"
2228
)
2329

@@ -30,6 +36,8 @@ const (
3036
expectedCatalogSourceNamespace = "openshift-marketplace"
3137
// The test gateway name used in multiple places.
3238
testGatewayName = "test-gateway"
39+
// gwapiCRDVAPName is the name of the ingress operator's Validating Admission Policy (VAP).
40+
gwapiCRDVAPName = "openshift-ingress-operator-gatewayapi-crd-admission"
3341
)
3442

3543
var crdNames = []string{
@@ -51,8 +59,6 @@ var defaultRoutename = ""
5159
// feature gate is still in effect, preface the test names with "TestGatewayAPI"
5260
// so that they run via the openshift/release test configuration.
5361
func TestGatewayAPI(t *testing.T) {
54-
t.Parallel()
55-
5662
// Skip if feature is not enabled
5763
if gatewayAPIEnabled, err := isFeatureGateEnabled(features.FeatureGateGatewayAPI); err != nil {
5864
t.Fatalf("error checking feature gate enabled status: %v", err)
@@ -84,6 +90,7 @@ func TestGatewayAPI(t *testing.T) {
8490
} else {
8591
t.Log("Gateway API Controller not enabled, skipping testGatewayAPIObjects and testGatewayAPIIstioInstallation")
8692
}
93+
t.Run("testGatewayAPIResourcesProtection", testGatewayAPIResourcesProtection)
8794
}
8895

8996
// testGatewayAPIResources tests that Gateway API Custom Resource Definitions are available.
@@ -167,6 +174,93 @@ func testGatewayAPIObjects(t *testing.T) {
167174
}
168175
}
169176

177+
// testGatewayAPIResourcesProtection verifies that the ingress operator's Validating Admission Policy
178+
// denies admission requests attempting to modify Gateway API CRDs on behalf of a user
179+
// who is not the ingress operator's service account.
180+
func testGatewayAPIResourcesProtection(t *testing.T) {
181+
t.Helper()
182+
183+
// Get kube client which impersonates ingress operator's service account.
184+
kubeConfig, err := config.GetConfig()
185+
if err != nil {
186+
t.Fatalf("failed to get kube config: %v", err)
187+
}
188+
kubeConfig.Impersonate = rest.ImpersonationConfig{
189+
UserName: "system:serviceaccount:openshift-ingress-operator:ingress-operator",
190+
}
191+
kubeClient, err := operatorclient.NewClient(kubeConfig)
192+
if err != nil {
193+
t.Fatalf("failed to to create kube client: %v", err)
194+
}
195+
196+
// Create test CRDs.
197+
var testCRDs []*apiextensionsv1.CustomResourceDefinition
198+
for _, name := range crdNames {
199+
testCRDs = append(testCRDs, buildGWAPICRDFromName(name))
200+
}
201+
202+
testCases := []struct {
203+
name string
204+
kclient client.Client
205+
expectedErrMsg string
206+
}{
207+
{
208+
name: "Ingress operator service account required",
209+
kclient: kclient,
210+
expectedErrMsg: "Gateway API Custom Resource Definitions are managed by the Ingress Operator and may not be modified",
211+
},
212+
{
213+
name: "Pod binding required",
214+
kclient: kubeClient,
215+
expectedErrMsg: "this user must have both \"authentication.kubernetes.io/node-name\" and \"authentication.kubernetes.io/pod-name\" claims",
216+
},
217+
}
218+
219+
for _, tc := range testCases {
220+
t.Run(tc.name, func(t *testing.T) {
221+
// Verify that GatewayAPI CRD creation is forbidden.
222+
for i := range testCRDs {
223+
if err := tc.kclient.Create(context.Background(), testCRDs[i]); err != nil {
224+
if !strings.Contains(err.Error(), tc.expectedErrMsg) {
225+
t.Errorf("unexpected error received while creating CRD %q: %v", testCRDs[i].Name, err)
226+
}
227+
} else {
228+
t.Errorf("admission error is expected while creating CRD %q but not received", testCRDs[i].Name)
229+
}
230+
}
231+
232+
// Verify that GatewayAPI CRD update is forbidden.
233+
for i := range testCRDs {
234+
crdName := types.NamespacedName{Name: testCRDs[i].Name}
235+
crd := &apiextensionsv1.CustomResourceDefinition{}
236+
if err := tc.kclient.Get(context.Background(), crdName, crd); err != nil {
237+
t.Errorf("failed to get %q CRD: %v", crdName.Name, err)
238+
continue
239+
}
240+
crd.Spec = testCRDs[i].Spec
241+
if err := tc.kclient.Update(context.Background(), crd); err != nil {
242+
if !strings.Contains(err.Error(), tc.expectedErrMsg) {
243+
t.Errorf("unexpected error received while updating CRD %q: %v", testCRDs[i].Name, err)
244+
}
245+
} else {
246+
t.Errorf("admission error is expected while updating CRD %q but not received", testCRDs[i].Name)
247+
}
248+
}
249+
250+
// Verify that GatewayAPI CRD deletion is forbidden.
251+
for i := range testCRDs {
252+
if err := tc.kclient.Delete(context.Background(), testCRDs[i]); err != nil {
253+
if !strings.Contains(err.Error(), tc.expectedErrMsg) {
254+
t.Errorf("unexpected error received while deleting CRD %q: %v", testCRDs[i].Name, err)
255+
}
256+
} else {
257+
t.Errorf("admission error is expected while deleting CRD %q but not received", testCRDs[i].Name)
258+
}
259+
}
260+
})
261+
}
262+
}
263+
170264
// ensureCRDs tests that the Gateway API custom resource definitions exist.
171265
func ensureCRDs(t *testing.T) {
172266
t.Helper()
@@ -182,6 +276,19 @@ func ensureCRDs(t *testing.T) {
182276
// deleteCRDs deletes Gateway API custom resource definitions.
183277
func deleteCRDs(t *testing.T) {
184278
t.Helper()
279+
280+
vm := newVAPManager(t, gwapiCRDVAPName)
281+
// Remove the ingress operator's Validating Admission Policy (VAP)
282+
// which prevents modifications of Gateway API CRDs
283+
// by anything other than the ingress operator.
284+
if err, recoverFn := vm.disable(); err != nil {
285+
defer recoverFn()
286+
t.Fatalf("failed to disable vap: %v", err)
287+
}
288+
// Put back the VAP to ensure that it does not prevent
289+
// the ingress operator from managing Gateway API CRDs.
290+
defer vm.enable()
291+
185292
for _, crdName := range crdNames {
186293
err := deleteExistingCRD(t, crdName)
187294
if err != nil {

0 commit comments

Comments
 (0)