diff --git a/go.mod b/go.mod index 8fa6ddcfe0..1f851cd27f 100644 --- a/go.mod +++ b/go.mod @@ -26,11 +26,13 @@ require ( k8s.io/apiserver v0.37.0-alpha.1 k8s.io/client-go v0.37.0-alpha.1 k8s.io/klog/v2 v2.140.0 - k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 + k8s.io/utils v0.0.0-20260626114624-be93311217bd sigs.k8s.io/structured-merge-diff/v6 v6.4.0 sigs.k8s.io/yaml v1.6.0 ) +require k8s.io/webhookauth v0.0.0-00010101000000-000000000000 + require ( cel.dev/expr v0.25.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect @@ -39,6 +41,7 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coreos/go-oidc v2.5.0+incompatible // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -71,6 +74,7 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/pquerna/cachecontrol v0.1.0 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/spf13/cobra v1.10.2 // indirect @@ -88,6 +92,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.55.1-0.20260602153038-42abb857022c // indirect golang.org/x/oauth2 v0.36.0 // indirect @@ -99,11 +104,14 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/component-base v0.37.0-alpha.1 // indirect - k8s.io/kube-openapi v0.0.0-20260519202549-bbf5c5577288 // indirect + k8s.io/kube-openapi v0.0.0-20260618221249-bc653b64f974 // indirect k8s.io/streaming v0.37.0-alpha.1 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect ) + +replace k8s.io/webhookauth => /Users/benjaminpetersen/github.com/benjaminapetersen_forks/kubernetes/staging/src/k8s.io/webhookauth diff --git a/go.sum b/go.sum index 74fd04820d..b653688730 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc v2.5.0+incompatible h1:6W0vGJR3Tu0r0PwfmjOrRZSlfxeEln8dsejt3ZWIvwo= +github.com/coreos/go-oidc v2.5.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -132,6 +134,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc= +github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -152,6 +156,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -194,6 +199,8 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= @@ -231,8 +238,11 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= +gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.37.0-alpha.1 h1:fTnRCeg6apyW4NEMAzh4I0QKmhLrfBn/Wd7ofdiWrWs= @@ -249,12 +259,12 @@ k8s.io/component-base v0.37.0-alpha.1 h1:C+wu77XGtTrSvU/v4CMCmg8Uq3BmYsgDSbAy8PD k8s.io/component-base v0.37.0-alpha.1/go.mod h1:syzFTe+v8SyroLQ5JItALe3snAEmX9VTPCrCEk2K0gk= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260519202549-bbf5c5577288 h1:A7Lby6ekC6nv+6oO38huCMFBRP0Os+tIeq1GkwxOQes= -k8s.io/kube-openapi v0.0.0-20260519202549-bbf5c5577288/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= +k8s.io/kube-openapi v0.0.0-20260618221249-bc653b64f974 h1:JVogoTvOj6gutlx8bUwGh0e8o8L4X8nDbTLyONmoVvk= +k8s.io/kube-openapi v0.0.0-20260618221249-bc653b64f974/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= k8s.io/streaming v0.37.0-alpha.1 h1:8/IDL5B3WI2l7cnO3na104t9oCuQYEXZ6H3095GpfLo= k8s.io/streaming v0.37.0-alpha.1/go.mod h1:QN1+yCAfxcSvo0908Z3rFKkA5Xlr3KgUZAGwKuR6qQk= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs= +k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/pkg/webhook/admission/authenticator.go b/pkg/webhook/admission/authenticator.go new file mode 100644 index 0000000000..593f692896 --- /dev/null +++ b/pkg/webhook/admission/authenticator.go @@ -0,0 +1,161 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission + +import ( + "context" + "net/http" + + "k8s.io/webhookauth/verify" + "k8s.io/webhookauth/verify/admissionhttp" + "k8s.io/webhookauth/verify/oidc" + "k8s.io/webhookauth/verify/oidc/incluster" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// healthChecker is implemented by Authenticators whose readiness can be probed +// (e.g. the verifier-backed authenticator, which is not ready until its audience is bound). +type healthChecker interface{ HealthCheck() error } + +// verifierAuthenticator adapts a k8s.io/webhookauth *verify.Verifier onto the +// admission.Authenticator seam. +type verifierAuthenticator struct{ v *verify.Verifier } + +var ( + _ Authenticator = verifierAuthenticator{} + _ healthChecker = verifierAuthenticator{} +) + +// NewAuthenticator wraps a k8s.io/webhookauth *verify.Verifier as an +// Authenticator that enforces KEP-6060 API server authentication. +// +// This is the advanced / bring-your-own verifier entry point. Most users should +// prefer the fluent Webhook.WithInClusterAuthenticator (zero-config, in-cluster) +// or Webhook.WithRemoteAuthenticator (explicit issuer/audience) methods, which +// build the verifier for you. +// +// The verifier verifies the API server's bearer token entirely offline against +// the library's contract (signature, issuer, audience, exp/nbf/iat, webhook +// binding, and allowed API group). Any verification failure fails closed. +func NewAuthenticator(v *verify.Verifier) Authenticator { + return verifierAuthenticator{v: v} +} + +// Authenticate verifies the API server's bearer token against the KEP-6060 +// contract and fails closed on any error. It reuses the AdmissionRequest that +// controller-runtime already decoded, so the review is never decoded twice. +// +// The library's failure model is deliberately opaque: every rejection is a single +// generic error (verify.ErrVerificationFailed). We therefore always deny with a +// 401 and log only a static message; callers must not branch on the reason and no +// claim material is ever surfaced. +func (a verifierAuthenticator) Authenticate(ctx context.Context, r *http.Request, req Request) Response { + token, ok := admissionhttp.BearerToken(r) + if !ok { + return Unauthenticated("missing or malformed bearer token") + } + if a.v == nil { + return Unauthenticated("verifier is not configured") + } + // Reuse controller-runtime's single decode; never re-read r.Body. + if err := admissionhttp.VerifyAdmissionRequest(ctx, a.v, &req.AdmissionRequest, token); err != nil { + logf.FromContext(ctx).Info("webhook-auth: denied unauthenticated request") + return Unauthenticated("unauthenticated") + } + return Allowed("") +} + +// HealthCheck reports whether the backing verifier is ready to verify API server +// tokens, delegating to the library verifier's own HealthCheck. A nil verifier +// gates nothing and always reports ready. +func (a verifierAuthenticator) HealthCheck() error { + if a.v == nil { + return nil // no verifier configured → nothing to gate readiness on + } + return a.v.HealthCheck() +} + +// HealthCheck reports whether the webhook's KEP-6060 authenticator (if any) is +// ready to verify API server tokens. It has the signature of healthz.Checker, so +// it can be registered directly: +// +// mgr.AddReadyzCheck("webhook-auth", wh.HealthCheck) +// +// This is a readiness check: register it with AddReadyzCheck, not AddHealthzCheck. +// An authenticator that cannot yet bind its audience should stay not-ready (so the +// pod is not sent traffic) rather than fail liveness, which would crash-loop the pod. +// +// When no verifier-backed authenticator is configured it always reports ready, so +// it is safe to register unconditionally. When an in-cluster/remote authenticator +// is configured, it reports not-ready until the verifier has bound its audience — +// so a pod that can never derive its audience fails readiness and is restarted, +// instead of silently denying all traffic. +func (wh *Webhook) HealthCheck(_ *http.Request) error { + if hc, ok := wh.authenticator.(healthChecker); ok { + return hc.HealthCheck() + } + return nil +} + +// WithAuthenticator sets a custom Authenticator (bring-your-own / testing) and +// returns the Webhook for fluent chaining. A nil Authenticator preserves the +// default admission webhook behavior. +func (wh *Webhook) WithAuthenticator(a Authenticator) *Webhook { + wh.authenticator = a + return wh +} + +// WithInClusterAuthenticator configures zero-config in-cluster KEP-6060 +// authentication: the issuer, audience, and trusted CA are read from the pod's +// own projected service-account token (see k8s.io/webhookauth/verify/oidc/incluster.InCluster). +// +// It performs OIDC discovery now (a network round-trip) and returns an error so +// setup fails fast at startup. Pass a process-lifetime ctx (e.g. the manager's) — +// it governs OIDC discovery and the long-lived background JWKS refresh. +// +// On success it returns the Webhook for fluent chaining. +func (wh *Webhook) WithInClusterAuthenticator(ctx context.Context) (*Webhook, error) { + v, err := incluster.InCluster(ctx) + if err != nil { + return nil, err + } + wh.authenticator = NewAuthenticator(v) + return wh, nil +} + +// WithRemoteAuthenticator configures explicit issuer/audience KEP-6060 +// authentication via OIDC discovery (see k8s.io/webhookauth/verify/oidc.NewRemoteVerifier). +// +// httpClient (may be nil) is the transport that trusts the issuer's serving CA. +// It performs OIDC discovery now (a network round-trip) and returns an error so +// setup fails fast at startup. Pass a process-lifetime ctx — it governs OIDC +// discovery and the long-lived background JWKS refresh. +// +// On success it returns the Webhook for fluent chaining. +func (wh *Webhook) WithRemoteAuthenticator(ctx context.Context, issuer, audience string, httpClient *http.Client) (*Webhook, error) { + var opts []oidc.Option + if httpClient != nil { + opts = append(opts, oidc.WithHTTPClient(httpClient)) + } + v, err := oidc.NewRemoteVerifier(ctx, issuer, audience, opts...) + if err != nil { + return nil, err + } + wh.authenticator = NewAuthenticator(v) + return wh, nil +} diff --git a/pkg/webhook/admission/authenticator_ext_test.go b/pkg/webhook/admission/authenticator_ext_test.go new file mode 100644 index 0000000000..e6646481ae --- /dev/null +++ b/pkg/webhook/admission/authenticator_ext_test.go @@ -0,0 +1,416 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/webhookauth/verify" + + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +const ( + testIssuer = "https://issuer.example.com" + testAudience = "webhook.example.com" + + // authFailureToken is a raw token that is NOT a decodable claims payload, so + // fakeAuthenticator's json.Unmarshal fails and it returns an error. In the v3 + // seam the core verify.Verifier no longer checks the signature or the standard + // iss/aud/exp claims — those live in the TokenAuthenticator — so this stands + // in for the real authenticator rejecting an expired / wrong-audience / + // bad-signature token. The Verifier collapses that into its single generic + // failure and the adapter fails closed. + authFailureToken = "simulated-signature-or-standard-claim-failure" +) + +// fakeAuthenticator is a stand-in verify.TokenAuthenticator that keeps the test +// pure-stdlib and offline. In the v3 seam AuthenticateToken returns the token's +// allowedAPIGroup values (or an error simulating a signature / iss / aud / exp +// failure); the webhook-binding and exactly-one-of-(validating|mutating) checks +// now live inside the real authenticator, so they are not exercised through this +// fake. It decodes the raw token as a JSON payload carrying the allowed groups; a +// json.Unmarshal failure plays the role of the real authenticator rejecting a +// token whose signature or standard claims did not verify. +type fakeAuthenticator struct{} + +func (fakeAuthenticator) AuthenticateToken(_ context.Context, rawToken string) ([]string, error) { + var payload struct { + AllowedAPIGroups []string `json:"allowedAPIGroups"` + } + if err := json.Unmarshal([]byte(rawToken), &payload); err != nil { + return nil, fmt.Errorf("simulated authentication failure: %w", err) + } + return payload.AllowedAPIGroups, nil +} + +// mkToken mints a token string for the offline fake: a JSON payload carrying the +// allowedAPIGroup values fakeAuthenticator returns. In the v3 seam the +// signature / iss / aud / exp checks and the webhook-binding checks live inside +// the real authenticator, so this offline fake models only the allowedAPIGroup +// list the Verifier matches against the review's group. +func mkToken(t *testing.T, group string) string { + t.Helper() + payload, err := json.Marshal(map[string]any{ + "allowedAPIGroups": []string{group}, + }) + if err != nil { + t.Fatalf("marshal token payload: %v", err) + } + return string(payload) +} + +func newVerifier(t *testing.T) *verify.Verifier { + t.Helper() + v, err := verify.NewVerifier(fakeAuthenticator{}) + if err != nil { + t.Fatalf("NewVerifier: %v", err) + } + return v +} + +func reqForGroup(group string) admission.Request { + return admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + UID: "req-uid", + Resource: metav1.GroupVersionResource{Group: group, Version: "v1", Resource: "deployments"}, + Name: "my-deploy", + }, + } +} + +func TestNewAuthenticator(t *testing.T) { + validApps := mkToken(t, "apps") + wildcard := mkToken(t, "*") + + // NOTE: the old bothBound / noBound / mutatingApps binding-violation cases are + // intentionally NOT rebuilt as controller-runtime tests. In the v3 seam the + // exactly-one-of-(validating|mutating) webhook-binding checks live INSIDE the + // library authenticator, not in CR's adapter. The offline fakeAuthenticator only + // returns the allowedAPIGroup list, so it cannot drive that binding logic without + // re-implementing it — a hollow test that would only assert our own fake. At CR's + // layer every such violation collapses to the same observable outcome: the library + // returns an error and the adapter fails closed. That single fail-closed contract + // is exercised by the "authenticator error" case below and, end-to-end through the + // ServeHTTP seam, by TestWithAuthenticatorEndToEnd's "verify error" subtest. The + // binding rules themselves are owned and tested by k8s.io/webhookauth. + + tests := []struct { + name string + setAuthHdr bool + authHeader string + token string + reqGroup string + wantAllowed bool + }{ + { + name: "valid token, matching group", + setAuthHdr: true, + token: validApps, + reqGroup: "apps", + wantAllowed: true, + }, + { + name: "wildcard allowedAPIGroup matches any review group", + setAuthHdr: true, + token: wildcard, + reqGroup: "batch", + wantAllowed: true, + }, + { + name: "missing token", + setAuthHdr: false, + reqGroup: "apps", + wantAllowed: false, + }, + { + name: "wrong scheme", + setAuthHdr: true, + authHeader: "Basic abc", + reqGroup: "apps", + wantAllowed: false, + }, + { + // A "Bearer" scheme with no token must be treated as absent, not as an + // empty-string token handed to the verifier. + name: "empty bearer token", + setAuthHdr: true, + authHeader: "Bearer ", + reqGroup: "apps", + wantAllowed: false, + }, + { + // Stands in for expired / wrong-audience / bad-signature: in v3 those + // checks live in the authenticator, which here returns an error. + name: "authenticator error", + setAuthHdr: true, + token: authFailureToken, + reqGroup: "apps", + wantAllowed: false, + }, + { + name: "allowedAPIGroup mismatch", + setAuthHdr: true, + token: validApps, + reqGroup: "batch", + wantAllowed: false, + }, + } + + auth := admission.NewAuthenticator(newVerifier(t)) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + httpReq := httptest.NewRequest(http.MethodPost, "/", nil) + if tc.setAuthHdr { + header := tc.authHeader + if header == "" { + header = "Bearer " + tc.token + } + httpReq.Header.Set("Authorization", header) + } + + resp := auth.Authenticate(context.Background(), httpReq, reqForGroup(tc.reqGroup)) + if resp.Allowed != tc.wantAllowed { + t.Fatalf("Authenticate allowed=%v, want %v", resp.Allowed, tc.wantAllowed) + } + if !resp.Allowed { + if resp.Result == nil || resp.Result.Code != http.StatusUnauthorized { + t.Fatalf("denied response = %#v, want 401", resp.Result) + } + if resp.Result.Reason != metav1.StatusReasonUnauthorized { + t.Fatalf("denied reason = %q, want %q", resp.Result.Reason, metav1.StatusReasonUnauthorized) + } + if tc.token != "" && strings.Contains(resp.Result.Message, tc.token) { + t.Fatalf("response leaked token material: %q", resp.Result.Message) + } + } + }) + } +} + +// TestNewAuthenticatorNilVerifierFailsClosed ensures a misconfigured +// authenticator (nil verifier) denies rather than panicking or allowing, and +// that the denial is a generic 401 that leaks no token material. +func TestNewAuthenticatorNilVerifierFailsClosed(t *testing.T) { + auth := admission.NewAuthenticator(nil) + httpReq := httptest.NewRequest(http.MethodPost, "/", nil) + token := mkToken(t, "apps") + httpReq.Header.Set("Authorization", "Bearer "+token) + + resp := auth.Authenticate(context.Background(), httpReq, reqForGroup("apps")) + if resp.Allowed { + t.Fatal("nil verifier must deny") + } + if resp.Result == nil || resp.Result.Code != http.StatusUnauthorized { + t.Fatalf("nil-verifier denial = %#v, want 401 Unauthenticated", resp.Result) + } + if strings.Contains(resp.Result.Message, token) { + t.Fatalf("nil-verifier denial leaked token material: %q", resp.Result.Message) + } +} + +// TestWithAuthenticatorEndToEnd drives a verifier-backed authenticator through +// the real admission.Webhook.ServeHTTP pipeline (decode -> Authenticate -> +// Handle), proving the hook short-circuits an unauthenticated request before the +// handler and reuses controller-runtime's single AdmissionReview decode. +// +// It also pins the load-bearing transport property: a denial is delivered as an +// HTTP 200 carrying Allowed:false (never a non-2xx), so a webhook configured with +// failurePolicy: Ignore cannot flip a deny into an admit. +func TestWithAuthenticatorEndToEnd(t *testing.T) { + v := newVerifier(t) + + var reached bool + spy := admission.HandlerFunc(func(_ context.Context, _ admission.Request) admission.Response { + reached = true + return admission.Allowed("") + }) + + wh := (&admission.Webhook{ + Handler: spy, + }).WithAuthenticator(admission.NewAuthenticator(v)) + srv := httptest.NewServer(wh) + defer srv.Close() + + review := admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{APIVersion: "admission.k8s.io/v1", Kind: "AdmissionReview"}, + Request: &admissionv1.AdmissionRequest{ + UID: "req-uid", + Resource: metav1.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, + Name: "my-deploy", + }, + } + body, err := json.Marshal(review) + if err != nil { + t.Fatalf("marshal review: %v", err) + } + + // post sends the review with the given Authorization header (empty = none) and + // returns the decoded AdmissionReview, the HTTP transport status, and the raw + // response body (so callers can assert no token material leaked). + post := func(authHeader string) (out admissionv1.AdmissionReview, status int, raw []byte) { + t.Helper() + req, err := http.NewRequest(http.MethodPost, srv.URL, bytes.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + raw, err = io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response body: %v", err) + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode response: %v", err) + } + return out, resp.StatusCode, raw + } + + t.Run("valid token reaches handler and is allowed (HTTP 200)", func(t *testing.T) { + reached = false + out, status, _ := post("Bearer " + mkToken(t, "apps")) + if status != http.StatusOK { + t.Fatalf("transport status = %d, want 200", status) + } + if out.Response == nil || !out.Response.Allowed { + t.Fatalf("expected Allowed=true, got %+v", out.Response) + } + if !reached { + t.Fatal("expected downstream handler to be reached") + } + }) + + t.Run("missing token is denied before handler (HTTP 200)", func(t *testing.T) { + reached = false + out, status, _ := post("") + if status != http.StatusOK { + t.Fatalf("transport status = %d, want 200 — a non-2xx would let failurePolicy:Ignore admit", status) + } + if out.Response == nil || out.Response.Allowed { + t.Fatalf("expected Allowed=false, got %+v", out.Response) + } + if reached { + t.Fatal("expected downstream handler NOT to be reached") + } + }) + + t.Run("verify error is denied before handler (HTTP 200, 401, no leak)", func(t *testing.T) { + // authFailureToken makes fakeAuthenticator return an error, so + // VerifyAdmissionRequest fails and the adapter fails closed. At CR's layer + // this single case SUBSUMES the old bothBound / noBound / mutatingApps + // binding violations — in v3 the library reports every one of them as the + // same generic error, and CR denies identically. + reached = false + out, status, raw := post("Bearer " + authFailureToken) + if status != http.StatusOK { + t.Fatalf("transport status = %d, want 200", status) + } + if out.Response == nil || out.Response.Allowed { + t.Fatalf("expected Allowed=false, got %+v", out.Response) + } + if reached { + t.Fatal("expected downstream handler NOT to be reached") + } + if out.Response.Result == nil || out.Response.Result.Code != http.StatusUnauthorized { + t.Fatalf("denied response = %#v, want 401", out.Response.Result) + } + if strings.Contains(string(raw), authFailureToken) { + t.Fatalf("response body leaked token material: %s", raw) + } + }) +} + +// TestWithInClusterAuthenticatorErrorPropagation exercises the fail-fast method. +// In a unit-test environment there is no projected service-account token at +// /var/run/secrets/kubernetes.io/serviceaccount/token, so oidc.InCluster fails +// and the method must surface that error (and a nil Webhook). +// +// The happy path performs live OIDC discovery and background JWKS refresh, so it +// is intentionally not tested here — it is covered by the library's own tests and +// by the compile-time examples. We do not stand up a real OIDC server. +func TestWithInClusterAuthenticatorErrorPropagation(t *testing.T) { + wh, err := (&admission.Webhook{}).WithInClusterAuthenticator(context.Background()) + if err == nil { + t.Fatalf("expected an error with no projected service-account token, got nil (wh=%v)", wh) + } + if wh != nil { + t.Fatalf("expected a nil Webhook on error, got %v", wh) + } +} + +// TestWithRemoteAuthenticatorErrorPropagation exercises the explicit +// issuer/audience path. A bogus, unreachable issuer combined with an +// already-expired context makes OIDC discovery fail fast, so the method must +// surface that error (and a nil Webhook) without any network round-trip +// completing. +func TestWithRemoteAuthenticatorErrorPropagation(t *testing.T) { + // Already-cancelled context so discovery fails immediately rather than + // waiting on a real network dial. + ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond) + defer cancel() + + wh, err := (&admission.Webhook{}).WithRemoteAuthenticator(ctx, "https://issuer.invalid", testAudience, nil) + if err == nil { + t.Fatalf("expected an error for an unreachable issuer, got nil (wh=%v)", wh) + } + if wh != nil { + t.Fatalf("expected a nil Webhook on error, got %v", wh) + } +} + +// TestWithRemoteAuthenticatorValidatesArgs confirms the empty-issuer/empty-audience +// guards in the underlying oidc.NewRemoteVerifier propagate through the method. +// This path is fully offline (the guards reject before any discovery). +func TestWithRemoteAuthenticatorValidatesArgs(t *testing.T) { + for _, tc := range []struct { + name string + issuer string + audience string + }{ + {name: "empty issuer", issuer: "", audience: testAudience}, + {name: "empty audience", issuer: testIssuer, audience: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + wh, err := (&admission.Webhook{}).WithRemoteAuthenticator(context.Background(), tc.issuer, tc.audience, nil) + if err == nil { + t.Fatalf("expected an error for %s, got nil (wh=%v)", tc.name, wh) + } + if wh != nil { + t.Fatalf("expected a nil Webhook on error, got %v", wh) + } + }) + } +} diff --git a/pkg/webhook/admission/authenticator_test.go b/pkg/webhook/admission/authenticator_test.go new file mode 100644 index 0000000000..097f2ba1a0 --- /dev/null +++ b/pkg/webhook/admission/authenticator_test.go @@ -0,0 +1,264 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +var _ = Describe("Admission webhook authenticators", func() { + const ( + gvkJSONv1 = `"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1"` + gvkJSONv1beta1 = `"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1beta1"` + ) + + It("preserves existing behavior when no authenticator is configured", func() { + body := fmt.Sprintf(`{%s,"request":{}}`, gvkJSONv1) + newWebhook := func() *Webhook { + return &Webhook{ + Handler: &fakeHandler{}, + } + } + + reqWithoutAuth := newAdmissionReviewHTTPRequest(body) + reqWithAuth := newAdmissionReviewHTTPRequest(body) + reqWithAuth.Header.Set("Authorization", "Bearer ignored") + + withoutAuthRecorder := httptest.NewRecorder() + withAuthRecorder := httptest.NewRecorder() + + newWebhook().ServeHTTP(withoutAuthRecorder, reqWithoutAuth) + newWebhook().ServeHTTP(withAuthRecorder, reqWithAuth) + + Expect(withAuthRecorder.Body.String()).To(Equal(withoutAuthRecorder.Body.String())) + }) + + It("runs the authenticator after decode and before the handler", func() { + events := []string{} + handler := &fakeHandler{ + fn: func(ctx context.Context, req Request) Response { + events = append(events, "handler") + return Allowed("handled") + }, + } + webhook := (&Webhook{ + Handler: handler, + }).WithAuthenticator(authenticatorFunc(func(ctx context.Context, httpReq *http.Request, req Request) Response { + events = append(events, "authenticator") + Expect(httpReq.Header.Get("Authorization")).To(Equal("Bearer token")) + Expect(req.Resource.Group).To(Equal("apps")) + return Allowed("") + })) + + req := newAdmissionReviewHTTPRequest(fmt.Sprintf(`{%s,"request":{"resource":{"group":"apps","version":"v1","resource":"deployments"}}}`, gvkJSONv1)) + req.Header.Set("Authorization", "Bearer token") + respRecorder := httptest.NewRecorder() + + webhook.ServeHTTP(respRecorder, req) + + Expect(events).To(Equal([]string{"authenticator", "handler"})) + Expect(handler.invoked).To(BeTrue()) + }) + + DescribeTable("short-circuits denied authentication responses with the request AdmissionReview version", + func(authResponse Response, expectedCode int32, expectedReason metav1.StatusReason, expectedMessage string) { + handler := &fakeHandler{} + webhook := (&Webhook{ + Handler: handler, + }).WithAuthenticator(authenticatorFunc(func(ctx context.Context, httpReq *http.Request, req Request) Response { + Expect(req.Resource.Group).To(Equal("apps")) + return authResponse + })) + req := newAdmissionReviewHTTPRequest(fmt.Sprintf(`{%s,"request":{"uid":"auth-uid","resource":{"group":"apps","version":"v1","resource":"deployments"}}}`, gvkJSONv1beta1)) + respRecorder := httptest.NewRecorder() + + webhook.ServeHTTP(respRecorder, req) + + Expect(handler.invoked).To(BeFalse()) + var review admissionv1.AdmissionReview + Expect(json.Unmarshal(respRecorder.Body.Bytes(), &review)).To(Succeed()) + Expect(review.APIVersion).To(Equal("admission.k8s.io/v1beta1")) + Expect(review.Kind).To(Equal("AdmissionReview")) + Expect(review.Response).NotTo(BeNil()) + Expect(string(review.Response.UID)).To(Equal("auth-uid")) + Expect(review.Response.Allowed).To(BeFalse()) + Expect(review.Response.Result).NotTo(BeNil()) + Expect(review.Response.Result.Code).To(Equal(expectedCode)) + Expect(review.Response.Result.Reason).To(Equal(expectedReason)) + Expect(review.Response.Result.Message).To(Equal(expectedMessage)) + }, + Entry("unauthenticated", Unauthenticated("missing bearer token"), int32(http.StatusUnauthorized), metav1.StatusReasonUnauthorized, "missing bearer token"), + Entry("unauthorized", Unauthorized("api group is not allowed"), int32(http.StatusForbidden), metav1.StatusReasonForbidden, "api group is not allowed"), + // A bare denied Response (nil Result / zero Code) must be defaulted to 403 + // Forbidden. Without completeDeniedAuthenticationResponse doing this, Complete + // would silently default the code to 200 for a denied response. + Entry("bare denial defaults to forbidden", Response{}, int32(http.StatusForbidden), metav1.StatusReasonForbidden, ""), + ) + + It("keeps typed, multi-handler, and standalone webhooks compatible", func() { + scheme := runtime.NewScheme() + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + + constructors := map[string]func(Authenticator) http.Handler{ + "WithValidator": func(authenticator Authenticator) http.Handler { + webhook := WithValidator[*corev1.Pod](scheme, podValidator{}) + return webhook.WithAuthenticator(authenticator) + }, + "WithDefaulter": func(authenticator Authenticator) http.Handler { + webhook := WithDefaulter[*corev1.Pod](scheme, podDefaulter{}) + return webhook.WithAuthenticator(authenticator) + }, + "WithCustomDefaulter": func(authenticator Authenticator) http.Handler { + webhook := WithCustomDefaulter(scheme, &corev1.Pod{}, customPodDefaulter{}) + return webhook.WithAuthenticator(authenticator) + }, + "MultiValidatingHandler": func(authenticator Authenticator) http.Handler { + return (&Webhook{ + Handler: MultiValidatingHandler(&fakeHandler{}), + }).WithAuthenticator(authenticator) + }, + "MultiMutatingHandler": func(authenticator Authenticator) http.Handler { + return (&Webhook{ + Handler: MultiMutatingHandler(&fakeHandler{}), + }).WithAuthenticator(authenticator) + }, + "StandaloneWebhook": func(authenticator Authenticator) http.Handler { + handler, err := StandaloneWebhook((&Webhook{ + Handler: &fakeHandler{}, + }).WithAuthenticator(authenticator), StandaloneOptions{}) + Expect(err).NotTo(HaveOccurred()) + return handler + }, + } + + for name, constructor := range constructors { + By(name) + called := false + handler := constructor(authenticatorFunc(func(ctx context.Context, httpReq *http.Request, req Request) Response { + called = true + return Unauthorized("blocked") + })) + req := newAdmissionReviewHTTPRequest(fmt.Sprintf(`{%s,"request":{"uid":"compat-uid"}}`, gvkJSONv1)) + respRecorder := httptest.NewRecorder() + + handler.ServeHTTP(respRecorder, req) + + Expect(called).To(BeTrue()) + var review admissionv1.AdmissionReview + Expect(json.Unmarshal(respRecorder.Body.Bytes(), &review)).To(Succeed()) + Expect(review.Response).NotTo(BeNil()) + Expect(review.Response.Allowed).To(BeFalse()) + Expect(review.Response.Result.Code).To(Equal(int32(http.StatusForbidden))) + } + }) +}) + +var _ = Describe("Webhook.HealthCheck (readyz seam)", func() { + It("reports ready when no authenticator is configured", func() { + wh := &Webhook{Handler: &fakeHandler{}} + Expect(wh.HealthCheck(nil)).To(Succeed()) + }) + + It("reports ready when the authenticator does not implement HealthCheck", func() { + // A plain authenticatorFunc has no HealthCheck method, so registering the + // checker unconditionally must be safe (always ready). + wh := (&Webhook{Handler: &fakeHandler{}}).WithAuthenticator( + authenticatorFunc(func(context.Context, *http.Request, Request) Response { + return Allowed("") + })) + Expect(wh.HealthCheck(nil)).To(Succeed()) + }) + + It("surfaces the not-ready error from a health-aware authenticator", func() { + sentinel := errors.New("audience not bound") + wh := (&Webhook{Handler: &fakeHandler{}}).WithAuthenticator( + healthAwareAuthenticator{healthErr: sentinel}) + Expect(wh.HealthCheck(nil)).To(MatchError(sentinel)) + }) + + It("reports ready when a health-aware authenticator is ready", func() { + wh := (&Webhook{Handler: &fakeHandler{}}).WithAuthenticator( + healthAwareAuthenticator{healthErr: nil}) + Expect(wh.HealthCheck(nil)).To(Succeed()) + }) + + It("reports ready for a verifierAuthenticator with a nil verifier", func() { + Expect(verifierAuthenticator{}.HealthCheck()).To(Succeed()) + }) +}) + +type authenticatorFunc func(context.Context, *http.Request, Request) Response + +func (f authenticatorFunc) Authenticate(ctx context.Context, httpReq *http.Request, req Request) Response { + return f(ctx, httpReq, req) +} + +// healthAwareAuthenticator is a minimal Authenticator that also implements the +// unexported healthChecker seam, returning healthErr from HealthCheck. +type healthAwareAuthenticator struct{ healthErr error } + +func (healthAwareAuthenticator) Authenticate(context.Context, *http.Request, Request) Response { + return Allowed("") +} + +func (a healthAwareAuthenticator) HealthCheck() error { return a.healthErr } + +func newAdmissionReviewHTTPRequest(body string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/admission", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + return req +} + +type podValidator struct{} + +func (podValidator) ValidateCreate(context.Context, *corev1.Pod) (Warnings, error) { + return nil, nil +} + +func (podValidator) ValidateUpdate(context.Context, *corev1.Pod, *corev1.Pod) (Warnings, error) { + return nil, nil +} + +func (podValidator) ValidateDelete(context.Context, *corev1.Pod) (Warnings, error) { + return nil, nil +} + +type podDefaulter struct{} + +func (podDefaulter) Default(context.Context, *corev1.Pod) error { + return nil +} + +type customPodDefaulter struct{} + +func (customPodDefaulter) Default(context.Context, runtime.Object) error { + return nil +} diff --git a/pkg/webhook/admission/example_authenticator_test.go b/pkg/webhook/admission/example_authenticator_test.go new file mode 100644 index 0000000000..5355a02fd8 --- /dev/null +++ b/pkg/webhook/admission/example_authenticator_test.go @@ -0,0 +1,102 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission_test + +import ( + "context" + "net/http" + + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// exampleHandler is a no-op admission handler used only to complete the wiring +// in these compile-time documentation examples. +var exampleHandler = admission.HandlerFunc(func(context.Context, admission.Request) admission.Response { + return admission.Allowed("") +}) + +// ExampleWebhook_WithInClusterAuthenticator shows the zero-config, in-cluster +// wiring: the issuer, audience, and trusted CA are discovered from the pod's +// projected service-account token. The fluent method turns KEP-6060 API-server +// authentication on and fails fast at startup on any discovery error. +// +// This example is compiled but not executed (it has no "// Output:" line): the +// method performs OIDC discovery and background JWKS refresh against the live +// in-cluster issuer, which is unavailable in a unit-test environment. +func ExampleWebhook_WithInClusterAuthenticator() { + // Pass a process-lifetime context (e.g. the manager's) — it governs OIDC + // discovery and the long-lived background JWKS refresh. + ctx := context.Background() + + wh, err := (&admission.Webhook{ + Handler: exampleHandler, + }).WithInClusterAuthenticator(ctx) + if err != nil { + // In a real setup, fail startup; here we simply return. + return + } + _ = wh +} + +// ExampleWebhook_WithRemoteAuthenticator shows the out-of-cluster wiring with an +// explicit issuer and audience. Pass an *http.Client whose transport trusts the +// issuer's serving CA (nil uses the default transport). +// +// This example is compiled but not executed (it has no "// Output:" line): the +// method performs OIDC discovery against the issuer, which is unavailable in a +// unit-test environment. +func ExampleWebhook_WithRemoteAuthenticator() { + // Pass a process-lifetime context — it governs OIDC discovery and the + // long-lived background JWKS refresh. + ctx := context.Background() + + const ( + issuer = "https://issuer.example.com" + audience = "webhook.example.com" + ) + + // A default http.Client is used here for illustration; in a real + // deployment pass a client with a transport that trusts the issuer's + // serving CA. + httpClient := &http.Client{} + + wh, err := (&admission.Webhook{ + Handler: exampleHandler, + }).WithRemoteAuthenticator(ctx, issuer, audience, httpClient) + if err != nil { + // In a real setup, fail startup; here we simply return. + return + } + _ = wh +} + +// ExampleWebhook_WithAuthenticator shows bring-your-own wiring: wrap a +// pre-built k8s.io/webhookauth *verify.Verifier with admission.NewAuthenticator +// and set it via the fluent method. Prefer WithInClusterAuthenticator or +// WithRemoteAuthenticator unless you need full control over verifier +// construction. +func ExampleWebhook_WithAuthenticator() { + // In a real setup, build a *verify.Verifier (e.g. via k8s.io/webhookauth's + // oidc helpers) and wrap it with admission.NewAuthenticator. A nil + // authenticator preserves the default admission webhook behavior. + var custom admission.Authenticator + + wh := (&admission.Webhook{ + Handler: exampleHandler, + }).WithAuthenticator(custom) + _ = wh +} diff --git a/pkg/webhook/admission/http.go b/pkg/webhook/admission/http.go index f049fb66e6..14658b7165 100644 --- a/pkg/webhook/admission/http.go +++ b/pkg/webhook/admission/http.go @@ -25,6 +25,7 @@ import ( v1 "k8s.io/api/admission/v1" "k8s.io/api/admission/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" @@ -116,9 +117,33 @@ func (wh *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) { } wh.getLogger(&req).V(5).Info("received request") + if wh.authenticator != nil { + response := wh.authenticator.Authenticate(ctx, r, req) + if !response.Allowed { + if err := completeDeniedAuthenticationResponse(&response, req); err != nil { + wh.getLogger(&req).Error(err, "unable to encode authentication response") + response = Errored(http.StatusInternalServerError, errUnableToEncodeResponse) + response.UID = req.UID + } + wh.writeResponseTyped(w, response, actualAdmRevGVK) + return + } + } + wh.writeResponseTyped(w, wh.Handle(ctx, req), actualAdmRevGVK) } +func completeDeniedAuthenticationResponse(response *Response, req Request) error { + if response.Result == nil { + response.Result = &metav1.Status{} + } + if response.Result.Code == 0 { + response.Result.Code = http.StatusForbidden + response.Result.Reason = metav1.StatusReasonForbidden + } + return response.Complete(req) +} + // writeResponse writes response to w generically, i.e. without encoding GVK information. func (wh *Webhook) writeResponse(w io.Writer, response Response) { wh.writeAdmissionResponse(w, v1.AdmissionReview{Response: &response.AdmissionResponse}) diff --git a/pkg/webhook/admission/response.go b/pkg/webhook/admission/response.go index ec1c88c989..ba10cc5715 100644 --- a/pkg/webhook/admission/response.go +++ b/pkg/webhook/admission/response.go @@ -59,6 +59,32 @@ func Errored(code int32, err error) Response { } } +// Unauthenticated constructs a response indicating that the caller could not be authenticated. +func Unauthenticated(message string) Response { + return authenticationResponse(http.StatusUnauthorized, metav1.StatusReasonUnauthorized, message) +} + +// Unauthorized constructs a response indicating that the authenticated caller is not authorized. +func Unauthorized(message string) Response { + return authenticationResponse(http.StatusForbidden, metav1.StatusReasonForbidden, message) +} + +func authenticationResponse(code int, reason metav1.StatusReason, message string) Response { + resp := Response{ + AdmissionResponse: admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Code: int32(code), + Reason: reason, + }, + }, + } + if len(message) > 0 { + resp.Result.Message = message + } + return resp +} + // ValidationResponse returns a response for admitting a request. func ValidationResponse(allowed bool, message string) Response { code := http.StatusForbidden diff --git a/pkg/webhook/admission/webhook.go b/pkg/webhook/admission/webhook.go index cba6da2cb0..286ae5588c 100644 --- a/pkg/webhook/admission/webhook.go +++ b/pkg/webhook/admission/webhook.go @@ -104,6 +104,15 @@ type Handler interface { Handle(context.Context, Request) Response } +// Authenticator verifies the HTTP caller for an already-decoded AdmissionRequest. +// A nil Authenticator preserves the default admission webhook behavior. +type Authenticator interface { + // Authenticate returns an allowed response when the caller is authenticated and + // authorized for req. Any non-allowed response rejects the request before Handler + // is called. + Authenticate(context.Context, *http.Request, Request) Response +} + // HandlerFunc implements Handler interface using a single function. type HandlerFunc func(context.Context, Request) Response @@ -138,6 +147,15 @@ type Webhook struct { // outside the context of requests. LogConstructor func(base logr.Logger, req *Request) logr.Logger + // authenticator verifies the HTTP caller after the AdmissionReview is decoded + // and before Handler is called. Nil preserves existing behavior exactly. + // + // It is set only via the fluent With*Authenticator methods: + // WithInClusterAuthenticator (zero-config, in-cluster KEP-6060 auth), + // WithRemoteAuthenticator (explicit issuer/audience), or WithAuthenticator + // (bring-your-own / testing). + authenticator Authenticator + setupLogOnce sync.Once log logr.Logger }