Skip to content

Commit 6ae3ee1

Browse files
committedMay 6, 2021
adding TokenReview.auth.k8s.io/v1 webhook support
Signed-off-by: Chris Hein <[email protected]>
1 parent 55a329c commit 6ae3ee1

File tree

11 files changed

+1057
-9
lines changed

11 files changed

+1057
-9
lines changed
 

‎examples/tokenreview/main.go

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
Copyright 2021 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 main
18+
19+
import (
20+
"os"
21+
22+
_ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
23+
"sigs.k8s.io/controller-runtime/pkg/client/config"
24+
"sigs.k8s.io/controller-runtime/pkg/log"
25+
"sigs.k8s.io/controller-runtime/pkg/log/zap"
26+
"sigs.k8s.io/controller-runtime/pkg/manager"
27+
"sigs.k8s.io/controller-runtime/pkg/manager/signals"
28+
"sigs.k8s.io/controller-runtime/pkg/webhook/authentication"
29+
)
30+
31+
func init() {
32+
log.SetLogger(zap.New())
33+
}
34+
35+
func main() {
36+
entryLog := log.Log.WithName("entrypoint")
37+
38+
// Setup a Manager
39+
entryLog.Info("setting up manager")
40+
mgr, err := manager.New(config.GetConfigOrDie(), manager.Options{})
41+
if err != nil {
42+
entryLog.Error(err, "unable to set up overall controller manager")
43+
os.Exit(1)
44+
}
45+
46+
// Setup webhooks
47+
entryLog.Info("setting up webhook server")
48+
hookServer := mgr.GetWebhookServer()
49+
50+
entryLog.Info("registering webhooks to the webhook server")
51+
hookServer.Register("/validate-v1-tokenreview", &authentication.Webhook{Handler: &authenticator{}})
52+
53+
entryLog.Info("starting manager")
54+
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {
55+
entryLog.Error(err, "unable to run manager")
56+
os.Exit(1)
57+
}
58+
}

‎examples/tokenreview/tokenreview.go

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
Copyright 2021 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 main
18+
19+
import (
20+
"context"
21+
22+
v1 "k8s.io/api/authentication/v1"
23+
24+
"sigs.k8s.io/controller-runtime/pkg/webhook/authentication"
25+
)
26+
27+
// authenticator validates tokenreviews
28+
type authenticator struct {
29+
}
30+
31+
// authenticator admits a request by the token.
32+
func (a *authenticator) Handle(ctx context.Context, req authentication.Request) authentication.Response {
33+
if req.Spec.Token == "invalid" {
34+
return authentication.Unauthenticated("invalid is an invalid token", v1.UserInfo{})
35+
}
36+
return authentication.Authenticated("", v1.UserInfo{})
37+
}

‎pkg/webhook/admission/http.go

+9-9
Original file line numberDiff line numberDiff line change
@@ -51,21 +51,22 @@ func (wh *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
5151
}
5252

5353
var reviewResponse Response
54-
if r.Body != nil {
55-
if body, err = ioutil.ReadAll(r.Body); err != nil {
56-
wh.log.Error(err, "unable to read the body from the incoming request")
57-
reviewResponse = Errored(http.StatusBadRequest, err)
58-
wh.writeResponse(w, reviewResponse)
59-
return
60-
}
61-
} else {
54+
if r.Body == nil {
6255
err = errors.New("request body is empty")
6356
wh.log.Error(err, "bad request")
6457
reviewResponse = Errored(http.StatusBadRequest, err)
6558
wh.writeResponse(w, reviewResponse)
6659
return
6760
}
6861

62+
defer r.Body.Close()
63+
if body, err = ioutil.ReadAll(r.Body); err != nil {
64+
wh.log.Error(err, "unable to read the body from the incoming request")
65+
reviewResponse = Errored(http.StatusBadRequest, err)
66+
wh.writeResponse(w, reviewResponse)
67+
return
68+
}
69+
6970
// verify the content type is accurate
7071
contentType := r.Header.Get("Content-Type")
7172
if contentType != "application/json" {
@@ -96,7 +97,6 @@ func (wh *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
9697
}
9798
wh.log.V(1).Info("received request", "UID", req.UID, "kind", req.Kind, "resource", req.Resource)
9899

99-
// TODO: add panic-recovery for Handle
100100
reviewResponse = wh.Handle(ctx, req)
101101
wh.writeResponseTyped(w, reviewResponse, actualAdmRevGVK)
102102
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
Copyright 2021 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 authentication
18+
19+
import (
20+
"testing"
21+
22+
. "github.com/onsi/ginkgo"
23+
. "github.com/onsi/gomega"
24+
25+
"sigs.k8s.io/controller-runtime/pkg/envtest/printer"
26+
logf "sigs.k8s.io/controller-runtime/pkg/log"
27+
"sigs.k8s.io/controller-runtime/pkg/log/zap"
28+
)
29+
30+
func TestAuthenticationWebhook(t *testing.T) {
31+
RegisterFailHandler(Fail)
32+
suiteName := "Authentication Webhook Suite"
33+
RunSpecsWithDefaultAndCustomReporters(t, suiteName, []Reporter{printer.NewlineReporter{}, printer.NewProwReporter(suiteName)})
34+
}
35+
36+
var _ = BeforeSuite(func(done Done) {
37+
logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
38+
39+
close(done)
40+
}, 60)

‎pkg/webhook/authentication/doc.go

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
Copyright 2021 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+
/*
18+
Package authentication provides implementation for authentication webhook and
19+
methods to implement authentication webhook handlers.
20+
21+
See examples/tokenreview/ for an example of authentication webhooks.
22+
*/
23+
package authentication
24+
25+
import (
26+
logf "sigs.k8s.io/controller-runtime/pkg/internal/log"
27+
)
28+
29+
var log = logf.RuntimeLog.WithName("authentication")

‎pkg/webhook/authentication/http.go

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
Copyright 2021 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 authentication
18+
19+
import (
20+
"encoding/json"
21+
"errors"
22+
"fmt"
23+
"io"
24+
"io/ioutil"
25+
"net/http"
26+
27+
authenticationv1 "k8s.io/api/authentication/v1"
28+
authenticationv1beta1 "k8s.io/api/authentication/v1beta1"
29+
"k8s.io/apimachinery/pkg/runtime"
30+
"k8s.io/apimachinery/pkg/runtime/schema"
31+
"k8s.io/apimachinery/pkg/runtime/serializer"
32+
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
33+
)
34+
35+
var authenticationScheme = runtime.NewScheme()
36+
var authenticationCodecs = serializer.NewCodecFactory(authenticationScheme)
37+
38+
func init() {
39+
utilruntime.Must(authenticationv1.AddToScheme(authenticationScheme))
40+
utilruntime.Must(authenticationv1beta1.AddToScheme(authenticationScheme))
41+
}
42+
43+
var _ http.Handler = &Webhook{}
44+
45+
func (wh *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
46+
var body []byte
47+
var err error
48+
ctx := r.Context()
49+
if wh.WithContextFunc != nil {
50+
ctx = wh.WithContextFunc(ctx, r)
51+
}
52+
53+
var reviewResponse Response
54+
if r.Body == nil {
55+
err = errors.New("request body is empty")
56+
wh.log.Error(err, "bad request")
57+
reviewResponse = Errored(err)
58+
wh.writeResponse(w, reviewResponse)
59+
return
60+
}
61+
62+
defer r.Body.Close()
63+
if body, err = ioutil.ReadAll(r.Body); err != nil {
64+
wh.log.Error(err, "unable to read the body from the incoming request")
65+
reviewResponse = Errored(err)
66+
wh.writeResponse(w, reviewResponse)
67+
return
68+
}
69+
70+
// verify the content type is accurate
71+
contentType := r.Header.Get("Content-Type")
72+
if contentType != "application/json" {
73+
err = fmt.Errorf("contentType=%s, expected application/json", contentType)
74+
wh.log.Error(err, "unable to process a request with an unknown content type", "content type", contentType)
75+
reviewResponse = Errored(err)
76+
wh.writeResponse(w, reviewResponse)
77+
return
78+
}
79+
80+
// Both v1 and v1beta1 TokenReview types are exactly the same, so the v1beta1 type can
81+
// be decoded into the v1 type. The v1beta1 api is deprecated as of 1.19 and will be
82+
// removed in authenticationv1.22. However the runtime codec's decoder guesses which type to
83+
// decode into by type name if an Object's TypeMeta isn't set. By setting TypeMeta of an
84+
// unregistered type to the v1 GVK, the decoder will coerce a v1beta1 TokenReview to authenticationv1.
85+
// The actual TokenReview GVK will be used to write a typed response in case the
86+
// webhook config permits multiple versions, otherwise this response will fail.
87+
req := Request{}
88+
ar := unversionedTokenReview{}
89+
// avoid an extra copy
90+
ar.TokenReview = &req.TokenReview
91+
ar.SetGroupVersionKind(authenticationv1.SchemeGroupVersion.WithKind("TokenReview"))
92+
_, actualTokRevGVK, err := authenticationCodecs.UniversalDeserializer().Decode(body, nil, &ar)
93+
if err != nil {
94+
wh.log.Error(err, "unable to decode the request")
95+
reviewResponse = Errored(err)
96+
wh.writeResponse(w, reviewResponse)
97+
return
98+
}
99+
wh.log.V(1).Info("received request", "UID", req.UID, "kind", req.Kind)
100+
101+
if req.Spec.Token == "" {
102+
err = errors.New("token is empty")
103+
wh.log.Error(err, "bad request")
104+
reviewResponse = Errored(err)
105+
wh.writeResponse(w, reviewResponse)
106+
return
107+
}
108+
109+
reviewResponse = wh.Handle(ctx, req)
110+
wh.writeResponseTyped(w, reviewResponse, actualTokRevGVK)
111+
}
112+
113+
// writeResponse writes response to w generically, i.e. without encoding GVK information.
114+
func (wh *Webhook) writeResponse(w io.Writer, response Response) {
115+
wh.writeTokenResponse(w, response.TokenReview)
116+
}
117+
118+
// writeResponseTyped writes response to w with GVK set to tokRevGVK, which is necessary
119+
// if multiple TokenReview versions are permitted by the webhook.
120+
func (wh *Webhook) writeResponseTyped(w io.Writer, response Response, tokRevGVK *schema.GroupVersionKind) {
121+
ar := response.TokenReview
122+
123+
// Default to a v1 TokenReview, otherwise the API server may not recognize the request
124+
// if multiple TokenReview versions are permitted by the webhook config.
125+
if tokRevGVK == nil || *tokRevGVK == (schema.GroupVersionKind{}) {
126+
ar.SetGroupVersionKind(authenticationv1.SchemeGroupVersion.WithKind("TokenReview"))
127+
} else {
128+
ar.SetGroupVersionKind(*tokRevGVK)
129+
}
130+
wh.writeTokenResponse(w, ar)
131+
}
132+
133+
// writeTokenResponse writes ar to w.
134+
func (wh *Webhook) writeTokenResponse(w io.Writer, ar authenticationv1.TokenReview) {
135+
if err := json.NewEncoder(w).Encode(ar); err != nil {
136+
wh.log.Error(err, "unable to encode the response")
137+
wh.writeResponse(w, Errored(err))
138+
}
139+
res := ar
140+
if log := wh.log; log.V(1).Enabled() {
141+
log.V(1).Info("wrote response", "UID", res.UID, "authenticated", res.Status.Authenticated)
142+
}
143+
return
144+
}
145+
146+
// unversionedTokenReview is used to decode both v1 and v1beta1 TokenReview types.
147+
type unversionedTokenReview struct {
148+
*authenticationv1.TokenReview
149+
}
150+
151+
var _ runtime.Object = &unversionedTokenReview{}
+241
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/*
2+
Copyright 2021 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 authentication
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"fmt"
23+
"io"
24+
"net/http"
25+
"net/http/httptest"
26+
27+
. "github.com/onsi/ginkgo"
28+
. "github.com/onsi/gomega"
29+
authenticationv1 "k8s.io/api/authentication/v1"
30+
31+
logf "sigs.k8s.io/controller-runtime/pkg/internal/log"
32+
"sigs.k8s.io/controller-runtime/pkg/runtime/inject"
33+
)
34+
35+
var _ = Describe("Authentication Webhooks", func() {
36+
37+
const (
38+
gvkJSONv1 = `"kind":"TokenReview","apiVersion":"authentication.k8s.io/v1"`
39+
gvkJSONv1beta1 = `"kind":"TokenReview","apiVersion":"authentication.k8s.io/v1beta1"`
40+
)
41+
42+
Describe("HTTP Handler", func() {
43+
var respRecorder *httptest.ResponseRecorder
44+
webhook := &Webhook{
45+
Handler: nil,
46+
}
47+
BeforeEach(func() {
48+
respRecorder = &httptest.ResponseRecorder{
49+
Body: bytes.NewBuffer(nil),
50+
}
51+
_, err := inject.LoggerInto(log.WithName("test-webhook"), webhook)
52+
Expect(err).NotTo(HaveOccurred())
53+
})
54+
55+
It("should return bad-request when given an empty body", func() {
56+
req := &http.Request{Body: nil}
57+
58+
expected := `{"metadata":{"creationTimestamp":null},"spec":{},"status":{"user":{},"error":"request body is empty"}}
59+
`
60+
webhook.ServeHTTP(respRecorder, req)
61+
Expect(respRecorder.Body.String()).To(Equal(expected))
62+
})
63+
64+
It("should return bad-request when given the wrong content-type", func() {
65+
req := &http.Request{
66+
Header: http.Header{"Content-Type": []string{"application/foo"}},
67+
Method: http.MethodPost,
68+
Body: nopCloser{Reader: bytes.NewBuffer(nil)},
69+
}
70+
71+
expected := `{"metadata":{"creationTimestamp":null},"spec":{},"status":{"user":{},"error":"contentType=application/foo, expected application/json"}}
72+
`
73+
webhook.ServeHTTP(respRecorder, req)
74+
Expect(respRecorder.Body.String()).To(Equal(expected))
75+
})
76+
77+
It("should return bad-request when given an undecodable body", func() {
78+
req := &http.Request{
79+
Header: http.Header{"Content-Type": []string{"application/json"}},
80+
Method: http.MethodPost,
81+
Body: nopCloser{Reader: bytes.NewBufferString("{")},
82+
}
83+
84+
expected := `{"metadata":{"creationTimestamp":null},"spec":{},"status":{"user":{},"error":"couldn't get version/kind; json parse error: unexpected end of JSON input"}}
85+
`
86+
webhook.ServeHTTP(respRecorder, req)
87+
Expect(respRecorder.Body.String()).To(Equal(expected))
88+
})
89+
90+
It("should return bad-request when given an undecodable body", func() {
91+
req := &http.Request{
92+
Header: http.Header{"Content-Type": []string{"application/json"}},
93+
Method: http.MethodPost,
94+
Body: nopCloser{Reader: bytes.NewBufferString(`{"spec":{"token":""}}`)},
95+
}
96+
97+
expected := `{"metadata":{"creationTimestamp":null},"spec":{},"status":{"user":{},"error":"token is empty"}}
98+
`
99+
webhook.ServeHTTP(respRecorder, req)
100+
Expect(respRecorder.Body.String()).To(Equal(expected))
101+
})
102+
103+
It("should return the response given by the handler with version defaulted to v1", func() {
104+
req := &http.Request{
105+
Header: http.Header{"Content-Type": []string{"application/json"}},
106+
Method: http.MethodPost,
107+
Body: nopCloser{Reader: bytes.NewBufferString(`{"spec":{"token":"foobar"}}`)},
108+
}
109+
webhook := &Webhook{
110+
Handler: &fakeHandler{},
111+
log: logf.RuntimeLog.WithName("webhook"),
112+
}
113+
114+
expected := fmt.Sprintf(`{%s,"metadata":{"creationTimestamp":null},"spec":{},"status":{"authenticated":true,"user":{}}}
115+
`, gvkJSONv1)
116+
117+
webhook.ServeHTTP(respRecorder, req)
118+
Expect(respRecorder.Body.String()).To(Equal(expected))
119+
})
120+
121+
It("should return the v1 response given by the handler", func() {
122+
req := &http.Request{
123+
Header: http.Header{"Content-Type": []string{"application/json"}},
124+
Method: http.MethodPost,
125+
Body: nopCloser{Reader: bytes.NewBufferString(fmt.Sprintf(`{%s,"spec":{"token":"foobar"}}`, gvkJSONv1))},
126+
}
127+
webhook := &Webhook{
128+
Handler: &fakeHandler{},
129+
log: logf.RuntimeLog.WithName("webhook"),
130+
}
131+
132+
expected := fmt.Sprintf(`{%s,"metadata":{"creationTimestamp":null},"spec":{},"status":{"authenticated":true,"user":{}}}
133+
`, gvkJSONv1)
134+
webhook.ServeHTTP(respRecorder, req)
135+
Expect(respRecorder.Body.String()).To(Equal(expected))
136+
})
137+
138+
It("should return the v1beta1 response given by the handler", func() {
139+
req := &http.Request{
140+
Header: http.Header{"Content-Type": []string{"application/json"}},
141+
Method: http.MethodPost,
142+
Body: nopCloser{Reader: bytes.NewBufferString(fmt.Sprintf(`{%s,"spec":{"token":"foobar"}}`, gvkJSONv1beta1))},
143+
}
144+
webhook := &Webhook{
145+
Handler: &fakeHandler{},
146+
log: logf.RuntimeLog.WithName("webhook"),
147+
}
148+
149+
expected := fmt.Sprintf(`{%s,"metadata":{"creationTimestamp":null},"spec":{},"status":{"authenticated":true,"user":{}}}
150+
`, gvkJSONv1beta1)
151+
webhook.ServeHTTP(respRecorder, req)
152+
Expect(respRecorder.Body.String()).To(Equal(expected))
153+
})
154+
155+
It("should present the Context from the HTTP request, if any", func() {
156+
req := &http.Request{
157+
Header: http.Header{"Content-Type": []string{"application/json"}},
158+
Method: http.MethodPost,
159+
Body: nopCloser{Reader: bytes.NewBufferString(`{"spec":{"token":"foobar"}}`)},
160+
}
161+
type ctxkey int
162+
const key ctxkey = 1
163+
const value = "from-ctx"
164+
webhook := &Webhook{
165+
Handler: &fakeHandler{
166+
fn: func(ctx context.Context, req Request) Response {
167+
<-ctx.Done()
168+
return Authenticated(ctx.Value(key).(string), authenticationv1.UserInfo{})
169+
},
170+
},
171+
log: logf.RuntimeLog.WithName("webhook"),
172+
}
173+
174+
expected := fmt.Sprintf(`{%s,"metadata":{"creationTimestamp":null},"spec":{},"status":{"authenticated":true,"user":{},"error":%q}}
175+
`, gvkJSONv1, value)
176+
177+
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), key, value))
178+
cancel()
179+
webhook.ServeHTTP(respRecorder, req.WithContext(ctx))
180+
Expect(respRecorder.Body.String()).To(Equal(expected))
181+
})
182+
183+
It("should mutate the Context from the HTTP request, if func supplied", func() {
184+
req := &http.Request{
185+
Header: http.Header{"Content-Type": []string{"application/json"}},
186+
Method: http.MethodPost,
187+
Body: nopCloser{Reader: bytes.NewBufferString(`{"spec":{"token":"foobar"}}`)},
188+
}
189+
type ctxkey int
190+
const key ctxkey = 1
191+
webhook := &Webhook{
192+
Handler: &fakeHandler{
193+
fn: func(ctx context.Context, req Request) Response {
194+
return Authenticated(ctx.Value(key).(string), authenticationv1.UserInfo{})
195+
},
196+
},
197+
WithContextFunc: func(ctx context.Context, r *http.Request) context.Context {
198+
return context.WithValue(ctx, key, r.Header["Content-Type"][0])
199+
},
200+
log: logf.RuntimeLog.WithName("webhook"),
201+
}
202+
203+
expected := fmt.Sprintf(`{%s,"metadata":{"creationTimestamp":null},"spec":{},"status":{"authenticated":true,"user":{},"error":%q}}
204+
`, gvkJSONv1, "application/json")
205+
206+
ctx, cancel := context.WithCancel(context.Background())
207+
cancel()
208+
webhook.ServeHTTP(respRecorder, req.WithContext(ctx))
209+
Expect(respRecorder.Body.String()).To(Equal(expected))
210+
})
211+
})
212+
})
213+
214+
type nopCloser struct {
215+
io.Reader
216+
}
217+
218+
func (nopCloser) Close() error { return nil }
219+
220+
type fakeHandler struct {
221+
invoked bool
222+
fn func(context.Context, Request) Response
223+
injectedString string
224+
}
225+
226+
func (h *fakeHandler) InjectString(s string) error {
227+
h.injectedString = s
228+
return nil
229+
}
230+
231+
func (h *fakeHandler) Handle(ctx context.Context, req Request) Response {
232+
h.invoked = true
233+
if h.fn != nil {
234+
return h.fn(ctx, req)
235+
}
236+
return Response{TokenReview: authenticationv1.TokenReview{
237+
Status: authenticationv1.TokenReviewStatus{
238+
Authenticated: true,
239+
},
240+
}}
241+
}
+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
Copyright 2021 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 authentication
18+
19+
import (
20+
authenticationv1 "k8s.io/api/authentication/v1"
21+
)
22+
23+
// Authenticated constructs a response indicating that the given token
24+
// is valid.
25+
func Authenticated(reason string, user authenticationv1.UserInfo) Response {
26+
return ReviewResponse(true, user, reason)
27+
}
28+
29+
// Unauthenticated constructs a response indicating that the given token
30+
// is not valid.
31+
func Unauthenticated(reason string, user authenticationv1.UserInfo) Response {
32+
return ReviewResponse(false, authenticationv1.UserInfo{}, reason)
33+
}
34+
35+
// Errored creates a new Response for error-handling a request.
36+
func Errored(err error) Response {
37+
return Response{
38+
TokenReview: authenticationv1.TokenReview{
39+
Spec: authenticationv1.TokenReviewSpec{},
40+
Status: authenticationv1.TokenReviewStatus{
41+
Authenticated: false,
42+
Error: err.Error(),
43+
},
44+
},
45+
}
46+
}
47+
48+
// ReviewResponse returns a response for admitting a request.
49+
func ReviewResponse(authenticated bool, user authenticationv1.UserInfo, err string, audiences ...string) Response {
50+
resp := Response{
51+
TokenReview: authenticationv1.TokenReview{
52+
Status: authenticationv1.TokenReviewStatus{
53+
Authenticated: authenticated,
54+
User: user,
55+
Audiences: audiences,
56+
},
57+
},
58+
}
59+
if len(err) > 0 {
60+
resp.TokenReview.Status.Error = err
61+
}
62+
return resp
63+
}
+160
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
Copyright 2021 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 authentication
18+
19+
import (
20+
"errors"
21+
22+
. "github.com/onsi/ginkgo"
23+
. "github.com/onsi/gomega"
24+
25+
authenticationv1 "k8s.io/api/authentication/v1"
26+
)
27+
28+
var _ = Describe("Authentication Webhook Response Helpers", func() {
29+
Describe("Authenticated", func() {
30+
It("should return an 'allowed' response", func() {
31+
Expect(Authenticated("", authenticationv1.UserInfo{})).To(Equal(
32+
Response{
33+
TokenReview: authenticationv1.TokenReview{
34+
Status: authenticationv1.TokenReviewStatus{
35+
Authenticated: true,
36+
User: authenticationv1.UserInfo{},
37+
},
38+
},
39+
},
40+
))
41+
})
42+
43+
It("should populate a status with a reason when a reason is given", func() {
44+
Expect(Authenticated("acceptable", authenticationv1.UserInfo{})).To(Equal(
45+
Response{
46+
TokenReview: authenticationv1.TokenReview{
47+
Status: authenticationv1.TokenReviewStatus{
48+
Authenticated: true,
49+
User: authenticationv1.UserInfo{},
50+
Error: "acceptable",
51+
},
52+
},
53+
},
54+
))
55+
})
56+
})
57+
58+
Describe("Unauthenticated", func() {
59+
It("should return a 'not allowed' response", func() {
60+
Expect(Unauthenticated("", authenticationv1.UserInfo{})).To(Equal(
61+
Response{
62+
TokenReview: authenticationv1.TokenReview{
63+
Status: authenticationv1.TokenReviewStatus{
64+
Authenticated: false,
65+
User: authenticationv1.UserInfo{},
66+
Error: "",
67+
},
68+
},
69+
},
70+
))
71+
})
72+
73+
It("should populate a status with a reason when a reason is given", func() {
74+
Expect(Unauthenticated("UNACCEPTABLE!", authenticationv1.UserInfo{})).To(Equal(
75+
Response{
76+
TokenReview: authenticationv1.TokenReview{
77+
Status: authenticationv1.TokenReviewStatus{
78+
Authenticated: false,
79+
User: authenticationv1.UserInfo{},
80+
Error: "UNACCEPTABLE!",
81+
},
82+
},
83+
},
84+
))
85+
})
86+
})
87+
88+
Describe("Errored", func() {
89+
It("should return a unauthenticated response with an error", func() {
90+
err := errors.New("this is an error")
91+
expected := Response{
92+
TokenReview: authenticationv1.TokenReview{
93+
Status: authenticationv1.TokenReviewStatus{
94+
Authenticated: false,
95+
User: authenticationv1.UserInfo{},
96+
Error: err.Error(),
97+
},
98+
},
99+
}
100+
resp := Errored(err)
101+
Expect(resp).To(Equal(expected))
102+
})
103+
})
104+
105+
Describe("ReviewResponse", func() {
106+
It("should populate a status with a Error when a reason is given", func() {
107+
By("checking that a message is populated for 'allowed' responses")
108+
Expect(ReviewResponse(true, authenticationv1.UserInfo{}, "acceptable")).To(Equal(
109+
Response{
110+
TokenReview: authenticationv1.TokenReview{
111+
Status: authenticationv1.TokenReviewStatus{
112+
Authenticated: true,
113+
User: authenticationv1.UserInfo{},
114+
Error: "acceptable",
115+
},
116+
},
117+
},
118+
))
119+
120+
By("checking that a message is populated for 'Unauthenticated' responses")
121+
Expect(ReviewResponse(false, authenticationv1.UserInfo{}, "UNACCEPTABLE!")).To(Equal(
122+
Response{
123+
TokenReview: authenticationv1.TokenReview{
124+
Status: authenticationv1.TokenReviewStatus{
125+
Authenticated: false,
126+
User: authenticationv1.UserInfo{},
127+
Error: "UNACCEPTABLE!",
128+
},
129+
},
130+
},
131+
))
132+
})
133+
134+
It("should return an authentication decision", func() {
135+
By("checking that it returns an 'allowed' response when allowed is true")
136+
Expect(ReviewResponse(true, authenticationv1.UserInfo{}, "")).To(Equal(
137+
Response{
138+
TokenReview: authenticationv1.TokenReview{
139+
Status: authenticationv1.TokenReviewStatus{
140+
Authenticated: true,
141+
User: authenticationv1.UserInfo{},
142+
},
143+
},
144+
},
145+
))
146+
147+
By("checking that it returns an 'Unauthenticated' response when allowed is false")
148+
Expect(ReviewResponse(false, authenticationv1.UserInfo{}, "")).To(Equal(
149+
Response{
150+
TokenReview: authenticationv1.TokenReview{
151+
Status: authenticationv1.TokenReviewStatus{
152+
Authenticated: false,
153+
User: authenticationv1.UserInfo{},
154+
},
155+
},
156+
},
157+
))
158+
})
159+
})
160+
})

‎pkg/webhook/authentication/webhook.go

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
Copyright 2021 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 authentication
18+
19+
import (
20+
"context"
21+
"errors"
22+
"net/http"
23+
24+
"github.com/go-logr/logr"
25+
authenticationv1 "k8s.io/api/authentication/v1"
26+
27+
"sigs.k8s.io/controller-runtime/pkg/runtime/inject"
28+
)
29+
30+
var (
31+
errUnableToEncodeResponse = errors.New("unable to encode response")
32+
)
33+
34+
// Request defines the input for an authentication handler.
35+
// It contains information to identify the object in
36+
// question (group, version, kind, resource, subresource,
37+
// name, namespace), as well as the operation in question
38+
// (e.g. Get, Create, etc), and the object itself.
39+
type Request struct {
40+
authenticationv1.TokenReview
41+
}
42+
43+
// Response is the output of an authentication handler.
44+
// It contains a response indicating if a given
45+
// operation is allowed
46+
type Response struct {
47+
authenticationv1.TokenReview
48+
}
49+
50+
// Complete populates any fields that are yet to be set in
51+
// the underlying TokenResponse, It mutates the response.
52+
func (r *Response) Complete(req Request) error {
53+
r.UID = req.UID
54+
55+
return nil
56+
}
57+
58+
// Handler can handle an TokenReview.
59+
type Handler interface {
60+
// Handle yields a response to an TokenReview.
61+
//
62+
// The supplied context is extracted from the received http.Request, allowing wrapping
63+
// http.Handlers to inject values into and control cancelation of downstream request processing.
64+
Handle(context.Context, Request) Response
65+
}
66+
67+
// HandlerFunc implements Handler interface using a single function.
68+
type HandlerFunc func(context.Context, Request) Response
69+
70+
var _ Handler = HandlerFunc(nil)
71+
72+
// Handle process the TokenReview by invoking the underlying function.
73+
func (f HandlerFunc) Handle(ctx context.Context, req Request) Response {
74+
return f(ctx, req)
75+
}
76+
77+
// Webhook represents each individual webhook.
78+
type Webhook struct {
79+
// Handler actually processes an authentication request returning whether it was authenticated or unauthenticated,
80+
// and potentially patches to apply to the handler.
81+
Handler Handler
82+
83+
// WithContextFunc will allow you to take the http.Request.Context() and
84+
// add any additional information such as passing the request path or
85+
// headers thus allowing you to read them from within the handler
86+
WithContextFunc func(context.Context, *http.Request) context.Context
87+
88+
log logr.Logger
89+
}
90+
91+
// InjectLogger gets a handle to a logging instance, hopefully with more info about this particular webhook.
92+
func (w *Webhook) InjectLogger(l logr.Logger) error {
93+
w.log = l
94+
return nil
95+
}
96+
97+
// Handle processes TokenReview.
98+
func (w *Webhook) Handle(ctx context.Context, req Request) Response {
99+
resp := w.Handler.Handle(ctx, req)
100+
if err := resp.Complete(req); err != nil {
101+
w.log.Error(err, "unable to encode response")
102+
return Errored(errUnableToEncodeResponse)
103+
}
104+
105+
return resp
106+
}
107+
108+
// InjectFunc injects the field setter into the webhook.
109+
func (w *Webhook) InjectFunc(f inject.Func) error {
110+
// inject directly into the handlers. It would be more correct
111+
// to do this in a sync.Once in Handle (since we don't have some
112+
// other start/finalize-type method), but it's more efficient to
113+
// do it here, presumably.
114+
115+
var setFields inject.Func
116+
setFields = func(target interface{}) error {
117+
if err := f(target); err != nil {
118+
return err
119+
}
120+
121+
if _, err := inject.InjectorInto(setFields, target); err != nil {
122+
return err
123+
}
124+
125+
return nil
126+
}
127+
128+
return setFields(w.Handler)
129+
}
+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
Copyright 2021 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 authentication
18+
19+
import (
20+
"context"
21+
22+
. "github.com/onsi/ginkgo"
23+
. "github.com/onsi/gomega"
24+
25+
authenticationv1 "k8s.io/api/authentication/v1"
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
machinerytypes "k8s.io/apimachinery/pkg/types"
28+
29+
logf "sigs.k8s.io/controller-runtime/pkg/internal/log"
30+
"sigs.k8s.io/controller-runtime/pkg/runtime/inject"
31+
)
32+
33+
var _ = Describe("Authentication Webhooks", func() {
34+
allowHandler := func() *Webhook {
35+
handler := &fakeHandler{
36+
fn: func(ctx context.Context, req Request) Response {
37+
return Response{
38+
TokenReview: authenticationv1.TokenReview{
39+
Status: authenticationv1.TokenReviewStatus{
40+
Authenticated: true,
41+
},
42+
},
43+
}
44+
},
45+
}
46+
webhook := &Webhook{
47+
Handler: handler,
48+
log: logf.RuntimeLog.WithName("webhook"),
49+
}
50+
51+
return webhook
52+
}
53+
54+
It("should invoke the handler to get a response", func() {
55+
By("setting up a webhook with an allow handler")
56+
webhook := allowHandler()
57+
58+
By("invoking the webhook")
59+
resp := webhook.Handle(context.Background(), Request{})
60+
61+
By("checking that it allowed the request")
62+
Expect(resp.Status.Authenticated).To(BeTrue())
63+
})
64+
65+
It("should ensure that the response's UID is set to the request's UID", func() {
66+
By("setting up a webhook")
67+
webhook := allowHandler()
68+
69+
By("invoking the webhook")
70+
resp := webhook.Handle(context.Background(), Request{TokenReview: authenticationv1.TokenReview{ObjectMeta: metav1.ObjectMeta{UID: "foobar"}}})
71+
72+
By("checking that the response share's the request's UID")
73+
Expect(resp.UID).To(Equal(machinerytypes.UID("foobar")))
74+
})
75+
76+
It("should populate the status on a response if one is not provided", func() {
77+
By("setting up a webhook")
78+
webhook := allowHandler()
79+
80+
By("invoking the webhook")
81+
resp := webhook.Handle(context.Background(), Request{})
82+
83+
By("checking that the response share's the request's UID")
84+
Expect(resp.Status).To(Equal(authenticationv1.TokenReviewStatus{Authenticated: true}))
85+
})
86+
87+
It("shouldn't overwrite the status on a response", func() {
88+
By("setting up a webhook that sets a status")
89+
webhook := &Webhook{
90+
Handler: HandlerFunc(func(ctx context.Context, req Request) Response {
91+
return Response{
92+
TokenReview: authenticationv1.TokenReview{
93+
Status: authenticationv1.TokenReviewStatus{
94+
Authenticated: true,
95+
Error: "Ground Control to Major Tom",
96+
},
97+
},
98+
}
99+
}),
100+
log: logf.RuntimeLog.WithName("webhook"),
101+
}
102+
103+
By("invoking the webhook")
104+
resp := webhook.Handle(context.Background(), Request{})
105+
106+
By("checking that the message is intact")
107+
Expect(resp.Status).NotTo(BeNil())
108+
Expect(resp.Status.Authenticated).To(BeTrue())
109+
Expect(resp.Status.Error).To(Equal("Ground Control to Major Tom"))
110+
})
111+
112+
Describe("dependency injection", func() {
113+
It("should set dependencies passed in on the handler", func() {
114+
By("setting up a webhook and injecting it with a injection func that injects a string")
115+
setFields := func(target interface{}) error {
116+
inj, ok := target.(stringInjector)
117+
if !ok {
118+
return nil
119+
}
120+
121+
return inj.InjectString("something")
122+
}
123+
handler := &fakeHandler{}
124+
webhook := &Webhook{
125+
Handler: handler,
126+
log: logf.RuntimeLog.WithName("webhook"),
127+
}
128+
Expect(setFields(webhook)).To(Succeed())
129+
Expect(inject.InjectorInto(setFields, webhook)).To(BeTrue())
130+
131+
By("checking that the string was injected")
132+
Expect(handler.injectedString).To(Equal("something"))
133+
})
134+
135+
})
136+
})
137+
138+
type stringInjector interface {
139+
InjectString(s string) error
140+
}

0 commit comments

Comments
 (0)
Please sign in to comment.