Repository: open-policy-agent/gatekeeper
Pull request #3837: fix: parse namespace from oldObject when processing a DELETE operation
Author: login: clementnuss
Created: 2025-03-04T17:24:23Z | Merged: 2025-03-18T14:45:28Z

**What this PR does / why we need it**:

**Which issue(s) this PR fixes**

#3743 

**Special notes for your reviewer**:

I think that substituting req.Object for req.OldObject is the simplest solution, and it prevents us from modifying the logic in https://github.com/open-policy-agent/gatekeeper/blob/v3.18.0/pkg/webhook/policy.go#L172.

let me know if you want me to do it in another way.


Diff:
diff --git a/pkg/webhook/common.go b/pkg/webhook/common.go
index 7b2306ea3b0..f4b3d760f12 100644
--- a/pkg/webhook/common.go
+++ b/pkg/webhook/common.go
@@ -48,6 +48,7 @@ var (
 	runtimeScheme                      = k8sruntime.NewScheme()
 	codecs                             = serializer.NewCodecFactory(runtimeScheme)
 	deserializer                       = codecs.UniversalDeserializer()
+	errOldObjectIsNil                  = errors.New("oldObject cannot be nil for DELETE operations")
 	disableEnforcementActionValidation = flag.Bool("disable-enforcementaction-validation", false, "disable validation of the enforcementAction and scopedEnforcementActions field of a constraint")
 	logDenies                          = flag.Bool("log-denies", false, "log detailed info on each deny")
 	emitAdmissionEvents                = flag.Bool("emit-admission-events", false, "(alpha) emit Kubernetes events for each admission violation")
@@ -149,8 +150,27 @@ func (h *webhookHandler) tracingLevel(ctx context.Context, req *admission.Reques
 }
 
 func (h *webhookHandler) skipExcludedNamespace(req *admissionv1.AdmissionRequest, excludedProcess process.Process) (bool, error) {
+	var data []byte
+	if req.Operation == admissionv1.Delete {
+		// oldObject is the existing object.
+		// It is null for DELETE operations in API servers prior to v1.15.0.
+		// https://github.com/kubernetes/website/pull/14671
+		if req.OldObject.Raw == nil {
+			return false, errOldObjectIsNil
+		}
+
+		// For admission webhooks registered for DELETE operations on k8s built APIs or CRDs,
+		// the apiserver now sends the existing object as admissionRequest.Request.OldObject to the webhook
+		// object is the new object being admitted.
+		// It is null for DELETE operations.
+		// https://github.com/kubernetes/kubernetes/pull/76346
+		data = req.OldObject.Raw
+	} else {
+		data = req.Object.Raw
+	}
+
 	obj := &unstructured.Unstructured{}
-	if _, _, err := deserializer.Decode(req.Object.Raw, nil, obj); err != nil {
+	if _, _, err := deserializer.Decode(data, nil, obj); err != nil {
 		return false, err
 	}
 	obj.SetNamespace(req.Namespace)
diff --git a/pkg/webhook/policy_test.go b/pkg/webhook/policy_test.go
index f57ba27ebb1..bb1b9566a30 100644
--- a/pkg/webhook/policy_test.go
+++ b/pkg/webhook/policy_test.go
@@ -418,6 +418,108 @@ func TestReviewDefaultNS(t *testing.T) {
 	t.Run("with max threads", testFn)
 }
 
+func TestExcludedNamespaces(t *testing.T) {
+	cfg := &v1alpha1.Config{
+		Spec: v1alpha1.ConfigSpec{
+			Match: []v1alpha1.MatchEntry{
+				{
+					ExcludedNamespaces: []wildcard.Wildcard{"kube-*"},
+					Processes:          []string{"*"},
+				},
+			},
+			Validation: v1alpha1.Validation{
+				Traces: []v1alpha1.Trace{},
+			},
+		},
+	}
+	ctx := context.Background()
+	opa, err := makeOpaClient()
+	if err != nil {
+		t.Fatalf("Could not initialize OPA: %s", err)
+	}
+	if _, err := opa.AddTemplate(ctx, validRegoTemplate()); err != nil {
+		t.Fatalf("could not add template: %s", err)
+	}
+	if _, err := opa.AddConstraint(ctx, validRegoTemplateConstraint()); err != nil {
+		t.Fatalf("could not add constraint: %s", err)
+	}
+	pe := process.New()
+	pe.Add(cfg.Spec.Match)
+	expSystem := expansion.NewSystem(mutation.NewSystem(mutation.SystemOpts{}))
+	handler := validationHandler{
+		opa:             opa,
+		expansionSystem: expSystem,
+		webhookHandler: webhookHandler{
+			injectedConfig:  cfg,
+			client:          &nsGetter{},
+			reader:          &nsGetter{},
+			processExcluder: pe,
+		},
+		log: log,
+	}
+	tc := []struct {
+		Name            string
+		Namespace       string
+		Operation       admissionv1.Operation
+		Raw             []byte
+		OldRaw          []byte
+		AllowedExpected bool
+	}{
+		{
+			Name:            "ExcludedNamespace invalid create",
+			Namespace:       "notkube-test",
+			Operation:       admissionv1.Create,
+			Raw:             []byte(`{"apiVersion": "v1", "kind": "Pod", "metadata": {"name": "acbd","namespace": ""}}`),
+			AllowedExpected: false,
+		},
+		{
+			Name:            "ExcludedNamespace valid create",
+			Namespace:       "kube-test",
+			Operation:       admissionv1.Create,
+			Raw:             []byte(`{"apiVersion": "v1", "kind": "Pod", "metadata": {"name": "acbd","namespace": ""}}`),
+			AllowedExpected: true,
+		},
+		{
+			Name:            "ExcludedNamespace invalid delete",
+			Namespace:       "kube-test",
+			Operation:       admissionv1.Delete,
+			Raw:             nil,
+			OldRaw:          nil,   // OldRaw is nil for K8s < 1.15
+			AllowedExpected: false, // in that case, we do not except a valid namespace exclusion
+		},
+		{
+			Name:            "ExcludedNamespace valid delete",
+			Namespace:       "kube-test",
+			Operation:       admissionv1.Delete,
+			OldRaw:          []byte(`{"apiVersion": "v1", "kind": "Pod", "metadata": {"name": "acbd","namespace": ""}}`),
+			AllowedExpected: true,
+		},
+	}
+	for _, tt := range tc {
+		t.Run(tt.Name, func(t *testing.T) {
+			review := admission.Request{
+				AdmissionRequest: admissionv1.AdmissionRequest{
+					Kind: metav1.GroupVersionKind{
+						Group:   "",
+						Version: "v1",
+						Kind:    "Pod",
+					},
+					Object: runtime.RawExtension{
+						Raw: tt.Raw,
+					},
+					OldObject: runtime.RawExtension{
+						Raw: tt.OldRaw,
+					},
+					Namespace: tt.Namespace,
+					Operation: tt.Operation,
+				},
+			}
+			resp := handler.Handle(context.Background(), review)
+			require.Equal(t, tt.AllowedExpected, resp.Allowed, "invalid admission response")
+		})
+	}
+}
+
 func TestConstraintValidation(t *testing.T) {
 	tc := []struct {
 		Name          string


Reviews:
user: login: ritazh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T18:35:45Z
user: login: clementnuss
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T08:21:55Z
user: login: JaydipGabani
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T22:19:18Z
user: login: clementnuss
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T06:00:52Z
user: login: JaydipGabani
body: Thanks, just a nit 😄 
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T20:12:11Z
user: login: clementnuss
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T09:30:06Z
user: login: JaydipGabani
body: lgtm. @clementnuss can you also add unit tests for `skipExcludedNamespace` to make sure this does not happen again?
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T00:24:28Z
user: login: ritazh
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-18T04:56:09Z

Review comments:
diff_hunk: @@ -149,6 +149,10 @@ func (h *webhookHandler) tracingLevel(ctx context.Context, req *admission.Reques
 }
 
 func (h *webhookHandler) skipExcludedNamespace(req *admissionv1.AdmissionRequest, excludedProcess process.Process) (bool, error) {
+	if req.Operation == admissionv1.Delete && req.Object.Raw == nil {
path: pkg/webhook/common.go
user: login: ritazh
body: Thanks for the pr @clementnuss!

This PR https://github.com/open-policy-agent/gatekeeper/pull/3511/files moved `setObjectOnDelete` specifically it removed https://github.com/open-policy-agent/gatekeeper/pull/3511/files#diff-d3c8e52b699d3596d1057b82890a2ed8624445f1ee8f6d3d8a532e13766b99a8L146-L151 which would have updated `req` that would be used by skipExcludedNamespace. can you put it back and validate? Thanks! 
created_at: 2025-03-04T18:35:45Z
author_association: MEMBER
diff_hunk: @@ -149,6 +149,10 @@ func (h *webhookHandler) tracingLevel(ctx context.Context, req *admission.Reques
 }
 
 func (h *webhookHandler) skipExcludedNamespace(req *admissionv1.AdmissionRequest, excludedProcess process.Process) (bool, error) {
+	if req.Operation == admissionv1.Delete && req.Object.Raw == nil {
path: pkg/webhook/common.go
user: login: clementnuss
body: thanks for the pointer!
I added it back with my first commit, and in my second commit I reduced code duplication and used `util.SetObjectOnDelete` from the `target.go` file.

it works on my side, and all tests are passing. let me know if I should remove the 2nd commit or how you want it.
created_at: 2025-03-05T08:21:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -130,7 +128,7 @@ func (h *K8sValidationTarget) handleReview(obj interface{}) (bool, *gkReview, er
 		return false, nil, nil
 	}
 
-	if err := setObjectOnDelete(review); err != nil {
+	if err := util.SetObjectOnDelete(&review.AdmissionRequest); err != nil {
path: pkg/target/target.go
user: login: JaydipGabani
body: This will be executed twice for admission requests going through webhook. Once in policy.go and once in target handler. Best way to fix this is probably to revert the change and not have it under target handler or use oldObject when object is nil in `skipExcludedNamespace`.


created_at: 2025-03-05T22:16:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -130,7 +128,7 @@ func (h *K8sValidationTarget) handleReview(obj interface{}) (bool, *gkReview, er
 		return false, nil, nil
 	}
 
-	if err := setObjectOnDelete(review); err != nil {
+	if err := util.SetObjectOnDelete(&review.AdmissionRequest); err != nil {
path: pkg/target/target.go
user: login: clementnuss
body: then I'll go for the second option, i.e. use oldObject when object is nil (and we are having to do with a delete op) in skipExcluded namespace, as I imagine there were some reasons to move it to target in the first place
created_at: 2025-03-06T06:00:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -149,6 +150,22 @@ func (h *webhookHandler) tracingLevel(ctx context.Context, req *admission.Reques
 }
 
 func (h *webhookHandler) skipExcludedNamespace(req *admissionv1.AdmissionRequest, excludedProcess process.Process) (bool, error) {
+	if req.Operation == admissionv1.Delete {
+		// oldObject is the existing object.
+		// It is null for DELETE operations in API servers prior to v1.15.0.
+		// https://github.com/kubernetes/website/pull/14671
+		if req.OldObject.Raw == nil {
+			return false, errOldObjectIsNil
+		}
+
+		// For admission webhooks registered for DELETE operations on k8s built APIs or CRDs,
+		// the apiserver now sends the existing object as admissionRequest.Request.OldObject to the webhook
+		// object is the new object being admitted.
+		// It is null for DELETE operations.
+		// https://github.com/kubernetes/kubernetes/pull/76346
+		req.Object = req.OldObject
path: pkg/webhook/common.go
user: login: JaydipGabani
body: I would recommend not setting the req.Object to OldObject, but rather just use `req.OldObject` in local scope when `Object` is nil without setting Object. I understand that down the line we are setting the `req.Object` to `req.OldObject` for delete operations, but I want to make sure that this is only be done at one place rather than two for future purposes, and I also want to be safe in not affecting the code in-between these two points (skipExcludedNamespace, and targetHandler) if it relies on `req.Object`. 
created_at: 2025-03-06T20:10:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -149,6 +150,22 @@ func (h *webhookHandler) tracingLevel(ctx context.Context, req *admission.Reques
 }
 
 func (h *webhookHandler) skipExcludedNamespace(req *admissionv1.AdmissionRequest, excludedProcess process.Process) (bool, error) {
+	if req.Operation == admissionv1.Delete {
+		// oldObject is the existing object.
+		// It is null for DELETE operations in API servers prior to v1.15.0.
+		// https://github.com/kubernetes/website/pull/14671
+		if req.OldObject.Raw == nil {
+			return false, errOldObjectIsNil
+		}
+
+		// For admission webhooks registered for DELETE operations on k8s built APIs or CRDs,
+		// the apiserver now sends the existing object as admissionRequest.Request.OldObject to the webhook
+		// object is the new object being admitted.
+		// It is null for DELETE operations.
+		// https://github.com/kubernetes/kubernetes/pull/76346
+		req.Object = req.OldObject
path: pkg/webhook/common.go
user: login: clementnuss
body: had some concerns on this as well! no worries I'll change that 👍🏼 
created_at: 2025-03-07T09:30:06Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov-commenter
created_at: 2025-03-05T22:02:10Z
body: ## [Codecov](https://app.codecov.io/gh/open-policy-agent/gatekeeper/pull/3837?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-policy-agent) Report
:x: Patch coverage is `92.30769%` with `1 line` in your changes missing coverage. Please review.
:white_check_mark: Project coverage is 47.84%. Comparing base ([`3350319`](https://app.codecov.io/gh/open-policy-agent/gatekeeper/commit/3350319f76d3e2d78df0b972c63258cba7c7915f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-policy-agent)) to head ([`5a2a1d5`](https://app.codecov.io/gh/open-policy-agent/gatekeeper/commit/5a2a1d5344ec45d776838eab2f097be16ecb4a25?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-policy-agent)).
:warning: Report is 647 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/open-policy-agent/gatekeeper/pull/3837?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-policy-agent) | Patch % | Lines |
|---|---|---|
| [pkg/webhook/common.go](https://app.codecov.io/gh/open-policy-agent/gatekeeper/pull/3837?src=pr&el=tree&filepath=pkg%2Fwebhook%2Fcommon.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-policy-agent#diff-cGtnL3dlYmhvb2svY29tbW9uLmdv) | 92.30% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/open-policy-agent/gatekeeper/pull/3837?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-policy-agent) |
> :exclamation:  There is a different number of reports uploaded between BASE (3350319) and HEAD (5a2a1d5). Click for more details.
> 
> <details><summary>HEAD has 1 upload less than BASE</summary>
>
>| Flag | BASE (3350319) | HEAD (5a2a1d5) |
>|------|------|------|
>|unittests|2|1|
></details>

<details><summary>Additional details and impacted files</summary>



```diff
@@            Coverage Diff             @@
##           master    #3837      +/-   ##
==========================================
- Coverage   54.49%   47.84%   -6.66%     
==========================================
  Files         134      234     +100     
  Lines       12329    19870    +7541     
==========================================
+ Hits         6719     9507    +2788     
- Misses       5116     9478    +4362     
- Partials      494      885     +391     
```

| [Flag](https://app.codecov.io/gh/open-policy-agent/gatekeeper/pull/3837/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-policy-agent) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/open-policy-agent/gatekeeper/pull/3837/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-policy-agent) | `47.84% <92.30%> (-6.66%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-policy-agent#carryforward-flags-in-the-pull-request-comment) to find out more.
</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/open-policy-agent/gatekeeper/pull/3837?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-policy-agent).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-policy-agent).
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: clementnuss
created_at: 2025-03-10T09:36:44Z
body: good idea. just added a few testcases, which do fail on main but pass on this branch

author_association: CONTRIBUTOR
user: login: JaydipGabani
created_at: 2025-03-11T18:21:22Z
body: @ritazh @sozercan @maxsmythe PTAL
author_association: CONTRIBUTOR