From 0112d91a056ac4f84e2e9a6a6695cc8a3db4e8b4 Mon Sep 17 00:00:00 2001 From: Jordan Liggitt Date: Thu, 2 Nov 2023 14:39:32 -0400 Subject: [PATCH] Add multi-webhook integration test --- test/integration/auth/authz_config_test.go | 323 +++++++++++++++++++++ 1 file changed, 323 insertions(+) diff --git a/test/integration/auth/authz_config_test.go b/test/integration/auth/authz_config_test.go index f5be54e3540..3f5128aa6e4 100644 --- a/test/integration/auth/authz_config_test.go +++ b/test/integration/auth/authz_config_test.go @@ -18,9 +18,15 @@ package auth import ( "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" + "sync/atomic" "testing" + "time" authorizationv1 "k8s.io/api/authorization/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -93,3 +99,320 @@ authorizers: t.Fatal("expected allowed, got denied") } } + +func TestMultiWebhookAuthzConfig(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthorizationConfiguration, true)() + + dir := t.TempDir() + + kubeconfigTemplate := ` +apiVersion: v1 +kind: Config +clusters: +- name: integration + cluster: + server: %q + insecure-skip-tls-verify: true +contexts: +- name: default-context + context: + cluster: integration + user: test +current-context: default-context +users: +- name: test +` + + // returns malformed responses when called + serverErrorCalled := atomic.Int32{} + serverError := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + serverErrorCalled.Add(1) + sar := &authorizationv1.SubjectAccessReview{} + if err := json.NewDecoder(req.Body).Decode(sar); err != nil { + t.Error(err) + } + t.Log("serverError", sar) + if _, err := w.Write([]byte(`error response`)); err != nil { + t.Error(err) + } + })) + defer serverError.Close() + serverErrorKubeconfigName := filepath.Join(dir, "serverError.yaml") + if err := os.WriteFile(serverErrorKubeconfigName, []byte(fmt.Sprintf(kubeconfigTemplate, serverError.URL)), os.FileMode(0644)); err != nil { + t.Fatal(err) + } + + // hangs for 2 seconds when called + serverTimeoutCalled := atomic.Int32{} + serverTimeout := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + serverTimeoutCalled.Add(1) + sar := &authorizationv1.SubjectAccessReview{} + if err := json.NewDecoder(req.Body).Decode(sar); err != nil { + t.Error(err) + } + t.Log("serverTimeout", sar) + time.Sleep(2 * time.Second) + })) + defer serverTimeout.Close() + serverTimeoutKubeconfigName := filepath.Join(dir, "serverTimeout.yaml") + if err := os.WriteFile(serverTimeoutKubeconfigName, []byte(fmt.Sprintf(kubeconfigTemplate, serverTimeout.URL)), os.FileMode(0644)); err != nil { + t.Fatal(err) + } + + // returns a deny response when called + serverDenyCalled := atomic.Int32{} + serverDeny := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + serverDenyCalled.Add(1) + sar := &authorizationv1.SubjectAccessReview{} + if err := json.NewDecoder(req.Body).Decode(sar); err != nil { + t.Error(err) + } + t.Log("serverDeny", sar) + sar.Status.Allowed = false + sar.Status.Denied = true + sar.Status.Reason = "denied by webhook" + if err := json.NewEncoder(w).Encode(sar); err != nil { + t.Error(err) + } + })) + defer serverDeny.Close() + serverDenyKubeconfigName := filepath.Join(dir, "serverDeny.yaml") + if err := os.WriteFile(serverDenyKubeconfigName, []byte(fmt.Sprintf(kubeconfigTemplate, serverDeny.URL)), os.FileMode(0644)); err != nil { + t.Fatal(err) + } + + // returns a no opinion response when called + serverNoOpinionCalled := atomic.Int32{} + serverNoOpinion := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + serverNoOpinionCalled.Add(1) + sar := &authorizationv1.SubjectAccessReview{} + if err := json.NewDecoder(req.Body).Decode(sar); err != nil { + t.Error(err) + } + t.Log("serverNoOpinion", sar) + sar.Status.Allowed = false + sar.Status.Denied = false + if err := json.NewEncoder(w).Encode(sar); err != nil { + t.Error(err) + } + })) + defer serverNoOpinion.Close() + serverNoOpinionKubeconfigName := filepath.Join(dir, "serverNoOpinion.yaml") + if err := os.WriteFile(serverNoOpinionKubeconfigName, []byte(fmt.Sprintf(kubeconfigTemplate, serverNoOpinion.URL)), os.FileMode(0644)); err != nil { + t.Fatal(err) + } + + // returns an allow response when called + serverAllowCalled := atomic.Int32{} + serverAllow := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + serverAllowCalled.Add(1) + sar := &authorizationv1.SubjectAccessReview{} + if err := json.NewDecoder(req.Body).Decode(sar); err != nil { + t.Error(err) + } + t.Log("serverAllow", sar) + sar.Status.Allowed = true + sar.Status.Reason = "allowed by webhook" + if err := json.NewEncoder(w).Encode(sar); err != nil { + t.Error(err) + } + })) + defer serverAllow.Close() + serverAllowKubeconfigName := filepath.Join(dir, "serverAllow.yaml") + if err := os.WriteFile(serverAllowKubeconfigName, []byte(fmt.Sprintf(kubeconfigTemplate, serverAllow.URL)), os.FileMode(0644)); err != nil { + t.Fatal(err) + } + + resetCounts := func() { + serverErrorCalled.Store(0) + serverTimeoutCalled.Store(0) + serverDenyCalled.Store(0) + serverNoOpinionCalled.Store(0) + serverAllowCalled.Store(0) + } + assertCounts := func(errorCount, timeoutCount, denyCount, noOpinionCount, allowCount int32) { + t.Helper() + if e, a := errorCount, serverErrorCalled.Load(); e != a { + t.Errorf("expected fail webhook calls: %d, got %d", e, a) + } + if e, a := timeoutCount, serverTimeoutCalled.Load(); e != a { + t.Errorf("expected timeout webhook calls: %d, got %d", e, a) + } + if e, a := denyCount, serverDenyCalled.Load(); e != a { + t.Errorf("expected deny webhook calls: %d, got %d", e, a) + } + if e, a := noOpinionCount, serverNoOpinionCalled.Load(); e != a { + t.Errorf("expected noOpinion webhook calls: %d, got %d", e, a) + } + if e, a := allowCount, serverAllowCalled.Load(); e != a { + t.Errorf("expected allow webhook calls: %d, got %d", e, a) + } + resetCounts() + } + + configFileName := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configFileName, []byte(` +apiVersion: apiserver.config.k8s.io/v1alpha1 +kind: AuthorizationConfiguration +authorizers: +- type: Webhook + name: error.example.com + webhook: + timeout: 5s + failurePolicy: Deny + subjectAccessReviewVersion: v1 + matchConditionSubjectAccessReviewVersion: v1 + connectionInfo: + type: KubeConfigFile + kubeConfigFile: `+serverErrorKubeconfigName+` + matchConditions: + - expression: has(request.resourceAttributes) + - expression: 'request.resourceAttributes.namespace == "fail"' + - expression: 'request.resourceAttributes.name == "error"' + +- type: Webhook + name: timeout.example.com + webhook: + timeout: 1s + failurePolicy: Deny + subjectAccessReviewVersion: v1 + matchConditionSubjectAccessReviewVersion: v1 + connectionInfo: + type: KubeConfigFile + kubeConfigFile: `+serverTimeoutKubeconfigName+` + matchConditions: + - expression: has(request.resourceAttributes) + - expression: 'request.resourceAttributes.namespace == "fail"' + - expression: 'request.resourceAttributes.name == "timeout"' + +- type: Webhook + name: deny.example.com + webhook: + timeout: 5s + failurePolicy: NoOpinion + subjectAccessReviewVersion: v1 + matchConditionSubjectAccessReviewVersion: v1 + connectionInfo: + type: KubeConfigFile + kubeConfigFile: `+serverDenyKubeconfigName+` + matchConditions: + - expression: has(request.resourceAttributes) + - expression: 'request.resourceAttributes.namespace == "fail"' + +- type: Webhook + name: noopinion.example.com + webhook: + timeout: 5s + failurePolicy: Deny + subjectAccessReviewVersion: v1 + connectionInfo: + type: KubeConfigFile + kubeConfigFile: `+serverNoOpinionKubeconfigName+` + +- type: Webhook + name: allow.example.com + webhook: + timeout: 5s + failurePolicy: Deny + subjectAccessReviewVersion: v1 + connectionInfo: + type: KubeConfigFile + kubeConfigFile: `+serverAllowKubeconfigName+` +`), os.FileMode(0644)); err != nil { + t.Fatal(err) + } + + server := kubeapiservertesting.StartTestServerOrDie( + t, + nil, + []string{"--authorization-config=" + configFileName}, + framework.SharedEtcd(), + ) + t.Cleanup(server.TearDownFn) + + adminClient := clientset.NewForConfigOrDie(server.ClientConfig) + + // malformed webhook short circuits + t.Log("checking error") + if result, err := adminClient.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), &authorizationv1.SubjectAccessReview{Spec: authorizationv1.SubjectAccessReviewSpec{ + User: "alice", + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Verb: "get", + Group: "", + Version: "v1", + Resource: "configmaps", + Namespace: "fail", + Name: "error", + }, + }}, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } else if result.Status.Allowed { + t.Fatal("expected denied, got allowed") + } else { + t.Log(result.Status.Reason) + assertCounts(1, 0, 0, 0, 0) + } + + // timeout webhook short circuits + t.Log("checking timeout") + if result, err := adminClient.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), &authorizationv1.SubjectAccessReview{Spec: authorizationv1.SubjectAccessReviewSpec{ + User: "alice", + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Verb: "get", + Group: "", + Version: "v1", + Resource: "configmaps", + Namespace: "fail", + Name: "timeout", + }, + }}, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } else if result.Status.Allowed { + t.Fatal("expected denied, got allowed") + } else { + t.Log(result.Status.Reason) + assertCounts(0, 1, 0, 0, 0) + } + + // deny webhook short circuits + t.Log("checking deny") + if result, err := adminClient.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), &authorizationv1.SubjectAccessReview{Spec: authorizationv1.SubjectAccessReviewSpec{ + User: "alice", + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Verb: "list", + Group: "", + Version: "v1", + Resource: "configmaps", + Namespace: "fail", + Name: "", + }, + }}, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } else if result.Status.Allowed { + t.Fatal("expected denied, got allowed") + } else { + t.Log(result.Status.Reason) + assertCounts(0, 0, 1, 0, 0) + } + + // no-opinion webhook passes through, allow webhook allows + t.Log("checking allow") + if result, err := adminClient.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), &authorizationv1.SubjectAccessReview{Spec: authorizationv1.SubjectAccessReviewSpec{ + User: "alice", + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Verb: "list", + Group: "", + Version: "v1", + Resource: "configmaps", + Namespace: "allow", + Name: "", + }, + }}, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } else if !result.Status.Allowed { + t.Fatal("expected allowed, got denied") + } else { + t.Log(result.Status.Reason) + assertCounts(0, 0, 0, 1, 1) + } +}