k8s.io/kubernetes@v1.29.3/test/integration/apiserver/admissionwebhook/client_auth_test.go (about) 1 /* 2 Copyright 2019 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package admissionwebhook 18 19 import ( 20 "context" 21 "crypto/tls" 22 "crypto/x509" 23 "encoding/json" 24 "fmt" 25 "io" 26 "net/http" 27 "net/http/httptest" 28 "net/url" 29 "os" 30 "sync" 31 "testing" 32 "time" 33 34 utiltesting "k8s.io/client-go/util/testing" 35 36 "k8s.io/api/admission/v1beta1" 37 admissionregistrationv1 "k8s.io/api/admissionregistration/v1" 38 corev1 "k8s.io/api/core/v1" 39 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 40 "k8s.io/apimachinery/pkg/types" 41 "k8s.io/apimachinery/pkg/util/wait" 42 clientset "k8s.io/client-go/kubernetes" 43 "k8s.io/client-go/rest" 44 kubeapiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" 45 "k8s.io/kubernetes/test/integration/framework" 46 ) 47 48 const ( 49 testClientAuthClientUsername = "webhook-client-auth-integration-client" 50 ) 51 52 // TestWebhookClientAuthWithAggregatorRouting ensures client auth is used for requests to URL backends 53 func TestWebhookClientAuthWithAggregatorRouting(t *testing.T) { 54 testWebhookClientAuth(t, true) 55 } 56 57 // TestWebhookClientAuthWithoutAggregatorRouting ensures client auth is used for requests to URL backends 58 func TestWebhookClientAuthWithoutAggregatorRouting(t *testing.T) { 59 testWebhookClientAuth(t, false) 60 } 61 62 func testWebhookClientAuth(t *testing.T, enableAggregatorRouting bool) { 63 64 roots := x509.NewCertPool() 65 if !roots.AppendCertsFromPEM(localhostCert) { 66 t.Fatal("Failed to append Cert from PEM") 67 } 68 cert, err := tls.X509KeyPair(localhostCert, localhostKey) 69 if err != nil { 70 t.Fatalf("Failed to build cert with error: %+v", err) 71 } 72 73 recorder := &clientAuthRecorder{} 74 webhookServer := httptest.NewUnstartedServer(newClientAuthWebhookHandler(t, recorder)) 75 webhookServer.TLS = &tls.Config{ 76 77 RootCAs: roots, 78 Certificates: []tls.Certificate{cert}, 79 } 80 webhookServer.StartTLS() 81 defer webhookServer.Close() 82 83 webhookServerURL, err := url.Parse(webhookServer.URL) 84 if err != nil { 85 t.Fatal(err) 86 } 87 88 kubeConfigFile, err := os.CreateTemp("", "admission-config.yaml") 89 if err != nil { 90 t.Fatal(err) 91 } 92 defer utiltesting.CloseAndRemove(t, kubeConfigFile) 93 94 if err := os.WriteFile(kubeConfigFile.Name(), []byte(` 95 apiVersion: v1 96 kind: Config 97 users: 98 - name: "`+webhookServerURL.Host+`" 99 user: 100 token: "localhost-match-with-port" 101 - name: "`+webhookServerURL.Hostname()+`" 102 user: 103 token: "localhost-match-without-port" 104 - name: "*.localhost" 105 user: 106 token: "localhost-prefix" 107 - name: "*" 108 user: 109 token: "fallback" 110 `), os.FileMode(0755)); err != nil { 111 t.Fatal(err) 112 } 113 114 admissionConfigFile, err := os.CreateTemp("", "admission-config.yaml") 115 if err != nil { 116 t.Fatal(err) 117 } 118 defer utiltesting.CloseAndRemove(t, admissionConfigFile) 119 120 if err := os.WriteFile(admissionConfigFile.Name(), []byte(` 121 apiVersion: apiserver.k8s.io/v1alpha1 122 kind: AdmissionConfiguration 123 plugins: 124 - name: ValidatingAdmissionWebhook 125 configuration: 126 apiVersion: apiserver.config.k8s.io/v1alpha1 127 kind: WebhookAdmission 128 kubeConfigFile: "`+kubeConfigFile.Name()+`" 129 - name: MutatingAdmissionWebhook 130 configuration: 131 apiVersion: apiserver.config.k8s.io/v1alpha1 132 kind: WebhookAdmission 133 kubeConfigFile: "`+kubeConfigFile.Name()+`" 134 `), os.FileMode(0755)); err != nil { 135 t.Fatal(err) 136 } 137 138 s := kubeapiservertesting.StartTestServerOrDie(t, kubeapiservertesting.NewDefaultTestServerOptions(), []string{ 139 "--disable-admission-plugins=ServiceAccount", 140 fmt.Sprintf("--enable-aggregator-routing=%v", enableAggregatorRouting), 141 "--admission-control-config-file=" + admissionConfigFile.Name(), 142 }, framework.SharedEtcd()) 143 defer s.TearDownFn() 144 145 // Configure a client with a distinct user name so that it is easy to distinguish requests 146 // made by the client from requests made by controllers. We use this to filter out requests 147 // before recording them to ensure we don't accidentally mistake requests from controllers 148 // as requests made by the client. 149 clientConfig := rest.CopyConfig(s.ClientConfig) 150 clientConfig.Impersonate.UserName = testClientAuthClientUsername 151 clientConfig.Impersonate.Groups = []string{"system:masters", "system:authenticated"} 152 client, err := clientset.NewForConfig(clientConfig) 153 if err != nil { 154 t.Fatalf("unexpected error: %v", err) 155 } 156 157 _, err = client.CoreV1().Pods("default").Create(context.TODO(), clientAuthMarkerFixture, metav1.CreateOptions{}) 158 if err != nil { 159 t.Fatal(err) 160 } 161 162 upCh := recorder.Reset() 163 ns := "load-balance" 164 _, err = client.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}, metav1.CreateOptions{}) 165 if err != nil { 166 t.Fatal(err) 167 } 168 169 fail := admissionregistrationv1.Fail 170 mutatingCfg, err := client.AdmissionregistrationV1().MutatingWebhookConfigurations().Create(context.TODO(), &admissionregistrationv1.MutatingWebhookConfiguration{ 171 ObjectMeta: metav1.ObjectMeta{Name: "admission.integration.test"}, 172 Webhooks: []admissionregistrationv1.MutatingWebhook{{ 173 Name: "admission.integration.test", 174 ClientConfig: admissionregistrationv1.WebhookClientConfig{ 175 URL: &webhookServer.URL, 176 CABundle: localhostCert, 177 }, 178 Rules: []admissionregistrationv1.RuleWithOperations{{ 179 Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, 180 Rule: admissionregistrationv1.Rule{APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}}, 181 }}, 182 FailurePolicy: &fail, 183 AdmissionReviewVersions: []string{"v1beta1"}, 184 SideEffects: &noSideEffects, 185 }}, 186 }, metav1.CreateOptions{}) 187 if err != nil { 188 t.Fatal(err) 189 } 190 defer func() { 191 err := client.AdmissionregistrationV1().MutatingWebhookConfigurations().Delete(context.TODO(), mutatingCfg.GetName(), metav1.DeleteOptions{}) 192 if err != nil { 193 t.Fatal(err) 194 } 195 }() 196 197 // wait until new webhook is called 198 if err := wait.PollImmediate(time.Millisecond*5, wait.ForeverTestTimeout, func() (bool, error) { 199 _, err = client.CoreV1().Pods("default").Patch(context.TODO(), clientAuthMarkerFixture.Name, types.JSONPatchType, []byte("[]"), metav1.PatchOptions{}) 200 if t.Failed() { 201 return true, nil 202 } 203 select { 204 case <-upCh: 205 return true, nil 206 default: 207 t.Logf("Waiting for webhook to become effective, getting marker object: %v", err) 208 return false, nil 209 } 210 }); err != nil { 211 t.Fatal(err) 212 } 213 214 } 215 216 type clientAuthRecorder struct { 217 mu sync.Mutex 218 upCh chan struct{} 219 upOnce sync.Once 220 } 221 222 // Reset zeros out all counts and returns a channel that is closed when the first admission of the 223 // marker object is received. 224 func (i *clientAuthRecorder) Reset() chan struct{} { 225 i.mu.Lock() 226 defer i.mu.Unlock() 227 i.upCh = make(chan struct{}) 228 i.upOnce = sync.Once{} 229 return i.upCh 230 } 231 232 func (i *clientAuthRecorder) MarkerReceived() { 233 i.mu.Lock() 234 defer i.mu.Unlock() 235 i.upOnce.Do(func() { 236 close(i.upCh) 237 }) 238 } 239 240 func newClientAuthWebhookHandler(t *testing.T, recorder *clientAuthRecorder) http.Handler { 241 allow := func(w http.ResponseWriter) { 242 w.Header().Set("Content-Type", "application/json") 243 json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{ 244 Response: &v1beta1.AdmissionResponse{ 245 Allowed: true, 246 }, 247 }) 248 } 249 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 250 defer r.Body.Close() 251 data, err := io.ReadAll(r.Body) 252 if err != nil { 253 http.Error(w, err.Error(), http.StatusBadRequest) 254 } 255 review := v1beta1.AdmissionReview{} 256 if err := json.Unmarshal(data, &review); err != nil { 257 http.Error(w, err.Error(), http.StatusBadRequest) 258 } 259 if review.Request.UserInfo.Username != testClientAuthClientUsername { 260 // skip requests not originating from this integration test's client 261 allow(w) 262 return 263 } 264 265 if authz := r.Header.Get("Authorization"); authz != "Bearer localhost-match-with-port" { 266 t.Errorf("unexpected authz header: %q", authz) 267 http.Error(w, "Invalid auth", http.StatusUnauthorized) 268 return 269 } 270 271 if len(review.Request.Object.Raw) == 0 { 272 http.Error(w, err.Error(), http.StatusBadRequest) 273 return 274 } 275 pod := &corev1.Pod{} 276 if err := json.Unmarshal(review.Request.Object.Raw, pod); err != nil { 277 http.Error(w, err.Error(), http.StatusBadRequest) 278 return 279 } 280 281 // When resetting between tests, a marker object is patched until this webhook 282 // observes it, at which point it is considered ready. 283 if pod.Namespace == clientAuthMarkerFixture.Namespace && pod.Name == clientAuthMarkerFixture.Name { 284 recorder.MarkerReceived() 285 allow(w) 286 return 287 } 288 }) 289 } 290 291 var clientAuthMarkerFixture = &corev1.Pod{ 292 ObjectMeta: metav1.ObjectMeta{ 293 Namespace: "default", 294 Name: "marker", 295 }, 296 Spec: corev1.PodSpec{ 297 Containers: []corev1.Container{{ 298 Name: "fake-name", 299 Image: "fakeimage", 300 }}, 301 }, 302 }