blob: 7fecf7796f863ddd96b416e0a31fc3c2b243e60b [file] [log] [blame]
/*
Copyright 2016 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 webhook
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"testing"
"text/template"
"time"
"k8s.io/api/authorization/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/diff"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/client-go/tools/clientcmd/api/v1"
)
func TestNewFromConfig(t *testing.T) {
dir, err := ioutil.TempDir("", "")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
data := struct {
CA string
Cert string
Key string
}{
CA: filepath.Join(dir, "ca.pem"),
Cert: filepath.Join(dir, "clientcert.pem"),
Key: filepath.Join(dir, "clientkey.pem"),
}
files := []struct {
name string
data []byte
}{
{data.CA, caCert},
{data.Cert, clientCert},
{data.Key, clientKey},
}
for _, file := range files {
if err := ioutil.WriteFile(file.name, file.data, 0400); err != nil {
t.Fatal(err)
}
}
tests := []struct {
msg string
configTmpl string
wantErr bool
}{
{
msg: "a single cluster and single user",
configTmpl: `
clusters:
- cluster:
certificate-authority: {{ .CA }}
server: https://authz.example.com
name: foobar
users:
- name: a cluster
user:
client-certificate: {{ .Cert }}
client-key: {{ .Key }}
`,
wantErr: true,
},
{
msg: "multiple clusters with no context",
configTmpl: `
clusters:
- cluster:
certificate-authority: {{ .CA }}
server: https://authz.example.com
name: foobar
- cluster:
certificate-authority: a bad certificate path
server: https://authz.example.com
name: barfoo
users:
- name: a name
user:
client-certificate: {{ .Cert }}
client-key: {{ .Key }}
`,
wantErr: true,
},
{
msg: "multiple clusters with a context",
configTmpl: `
clusters:
- cluster:
certificate-authority: a bad certificate path
server: https://authz.example.com
name: foobar
- cluster:
certificate-authority: {{ .CA }}
server: https://authz.example.com
name: barfoo
users:
- name: a name
user:
client-certificate: {{ .Cert }}
client-key: {{ .Key }}
contexts:
- name: default
context:
cluster: barfoo
user: a name
current-context: default
`,
wantErr: false,
},
{
msg: "cluster with bad certificate path specified",
configTmpl: `
clusters:
- cluster:
certificate-authority: a bad certificate path
server: https://authz.example.com
name: foobar
- cluster:
certificate-authority: {{ .CA }}
server: https://authz.example.com
name: barfoo
users:
- name: a name
user:
client-certificate: {{ .Cert }}
client-key: {{ .Key }}
contexts:
- name: default
context:
cluster: foobar
user: a name
current-context: default
`,
wantErr: true,
},
}
for _, tt := range tests {
// Use a closure so defer statements trigger between loop iterations.
err := func() error {
tempfile, err := ioutil.TempFile("", "")
if err != nil {
return err
}
p := tempfile.Name()
defer os.Remove(p)
tmpl, err := template.New("test").Parse(tt.configTmpl)
if err != nil {
return fmt.Errorf("failed to parse test template: %v", err)
}
if err := tmpl.Execute(tempfile, data); err != nil {
return fmt.Errorf("failed to execute test template: %v", err)
}
// Create a new authorizer
sarClient, err := subjectAccessReviewInterfaceFromKubeconfig(p)
if err != nil {
return fmt.Errorf("error building sar client: %v", err)
}
_, err = newWithBackoff(sarClient, 0, 0, 0)
return err
}()
if err != nil && !tt.wantErr {
t.Errorf("failed to load plugin from config %q: %v", tt.msg, err)
}
if err == nil && tt.wantErr {
t.Errorf("wanted an error when loading config, did not get one: %q", tt.msg)
}
}
}
// Service mocks a remote service.
type Service interface {
Review(*v1beta1.SubjectAccessReview)
HTTPStatusCode() int
}
// NewTestServer wraps a Service as an httptest.Server.
func NewTestServer(s Service, cert, key, caCert []byte) (*httptest.Server, error) {
const webhookPath = "/testserver"
var tlsConfig *tls.Config
if cert != nil {
cert, err := tls.X509KeyPair(cert, key)
if err != nil {
return nil, err
}
tlsConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
}
if caCert != nil {
rootCAs := x509.NewCertPool()
rootCAs.AppendCertsFromPEM(caCert)
if tlsConfig == nil {
tlsConfig = &tls.Config{}
}
tlsConfig.ClientCAs = rootCAs
tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
}
serveHTTP := func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, fmt.Sprintf("unexpected method: %v", r.Method), http.StatusMethodNotAllowed)
return
}
if r.URL.Path != webhookPath {
http.Error(w, fmt.Sprintf("unexpected path: %v", r.URL.Path), http.StatusNotFound)
return
}
var review v1beta1.SubjectAccessReview
bodyData, _ := ioutil.ReadAll(r.Body)
if err := json.Unmarshal(bodyData, &review); err != nil {
http.Error(w, fmt.Sprintf("failed to decode body: %v", err), http.StatusBadRequest)
return
}
// ensure we received the serialized review as expected
if review.APIVersion != "authorization.k8s.io/v1beta1" {
http.Error(w, fmt.Sprintf("wrong api version: %s", string(bodyData)), http.StatusBadRequest)
return
}
// once we have a successful request, always call the review to record that we were called
s.Review(&review)
if s.HTTPStatusCode() < 200 || s.HTTPStatusCode() >= 300 {
http.Error(w, "HTTP Error", s.HTTPStatusCode())
return
}
type status struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason"`
EvaluationError string `json:"evaluationError"`
}
resp := struct {
APIVersion string `json:"apiVersion"`
Status status `json:"status"`
}{
APIVersion: v1beta1.SchemeGroupVersion.String(),
Status: status{review.Status.Allowed, review.Status.Reason, review.Status.EvaluationError},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
server := httptest.NewUnstartedServer(http.HandlerFunc(serveHTTP))
server.TLS = tlsConfig
server.StartTLS()
// Adjust the path to point to our custom path
serverURL, _ := url.Parse(server.URL)
serverURL.Path = webhookPath
server.URL = serverURL.String()
return server, nil
}
// A service that can be set to allow all or deny all authorization requests.
type mockService struct {
allow bool
statusCode int
called int
}
func (m *mockService) Review(r *v1beta1.SubjectAccessReview) {
m.called++
r.Status.Allowed = m.allow
}
func (m *mockService) Allow() { m.allow = true }
func (m *mockService) Deny() { m.allow = false }
func (m *mockService) HTTPStatusCode() int { return m.statusCode }
// newAuthorizer creates a temporary kubeconfig file from the provided arguments and attempts to load
// a new WebhookAuthorizer from it.
func newAuthorizer(callbackURL string, clientCert, clientKey, ca []byte, cacheTime time.Duration) (*WebhookAuthorizer, error) {
tempfile, err := ioutil.TempFile("", "")
if err != nil {
return nil, err
}
p := tempfile.Name()
defer os.Remove(p)
config := v1.Config{
Clusters: []v1.NamedCluster{
{
Cluster: v1.Cluster{Server: callbackURL, CertificateAuthorityData: ca},
},
},
AuthInfos: []v1.NamedAuthInfo{
{
AuthInfo: v1.AuthInfo{ClientCertificateData: clientCert, ClientKeyData: clientKey},
},
},
}
if err := json.NewEncoder(tempfile).Encode(config); err != nil {
return nil, err
}
sarClient, err := subjectAccessReviewInterfaceFromKubeconfig(p)
if err != nil {
return nil, fmt.Errorf("error building sar client: %v", err)
}
return newWithBackoff(sarClient, cacheTime, cacheTime, 0)
}
func TestTLSConfig(t *testing.T) {
tests := []struct {
test string
clientCert, clientKey, clientCA []byte
serverCert, serverKey, serverCA []byte
wantAuth, wantErr bool
}{
{
test: "TLS setup between client and server",
clientCert: clientCert, clientKey: clientKey, clientCA: caCert,
serverCert: serverCert, serverKey: serverKey, serverCA: caCert,
wantAuth: true,
},
{
test: "Server does not require client auth",
clientCA: caCert,
serverCert: serverCert, serverKey: serverKey,
wantAuth: true,
},
{
test: "Server does not require client auth, client provides it",
clientCert: clientCert, clientKey: clientKey, clientCA: caCert,
serverCert: serverCert, serverKey: serverKey,
wantAuth: true,
},
{
test: "Client does not trust server",
clientCert: clientCert, clientKey: clientKey,
serverCert: serverCert, serverKey: serverKey,
wantErr: true,
},
{
test: "Server does not trust client",
clientCert: clientCert, clientKey: clientKey, clientCA: caCert,
serverCert: serverCert, serverKey: serverKey, serverCA: badCACert,
wantErr: true,
},
{
// Plugin does not support insecure configurations.
test: "Server is using insecure connection",
wantErr: true,
},
}
for _, tt := range tests {
// Use a closure so defer statements trigger between loop iterations.
func() {
service := new(mockService)
service.statusCode = 200
server, err := NewTestServer(service, tt.serverCert, tt.serverKey, tt.serverCA)
if err != nil {
t.Errorf("%s: failed to create server: %v", tt.test, err)
return
}
defer server.Close()
wh, err := newAuthorizer(server.URL, tt.clientCert, tt.clientKey, tt.clientCA, 0)
if err != nil {
t.Errorf("%s: failed to create client: %v", tt.test, err)
return
}
attr := authorizer.AttributesRecord{User: &user.DefaultInfo{}}
// Allow all and see if we get an error.
service.Allow()
decision, _, err := wh.Authorize(attr)
if tt.wantAuth {
if decision != authorizer.DecisionAllow {
t.Errorf("expected successful authorization")
}
} else {
if decision == authorizer.DecisionAllow {
t.Errorf("expected failed authorization")
}
}
if tt.wantErr {
if err == nil {
t.Errorf("expected error making authorization request: %v", err)
}
return
}
if err != nil {
t.Errorf("%s: failed to authorize with AllowAll policy: %v", tt.test, err)
return
}
service.Deny()
if decision, _, _ := wh.Authorize(attr); decision == authorizer.DecisionAllow {
t.Errorf("%s: incorrectly authorized with DenyAll policy", tt.test)
}
}()
}
}
// recorderService records all access review requests.
type recorderService struct {
last v1beta1.SubjectAccessReview
err error
}
func (rec *recorderService) Review(r *v1beta1.SubjectAccessReview) {
rec.last = v1beta1.SubjectAccessReview{}
rec.last = *r
r.Status.Allowed = true
}
func (rec *recorderService) Last() (v1beta1.SubjectAccessReview, error) {
return rec.last, rec.err
}
func (rec *recorderService) HTTPStatusCode() int { return 200 }
func TestWebhook(t *testing.T) {
serv := new(recorderService)
s, err := NewTestServer(serv, serverCert, serverKey, caCert)
if err != nil {
t.Fatal(err)
}
defer s.Close()
wh, err := newAuthorizer(s.URL, clientCert, clientKey, caCert, 0)
if err != nil {
t.Fatal(err)
}
expTypeMeta := metav1.TypeMeta{
APIVersion: "authorization.k8s.io/v1beta1",
Kind: "SubjectAccessReview",
}
tests := []struct {
attr authorizer.Attributes
want v1beta1.SubjectAccessReview
}{
{
attr: authorizer.AttributesRecord{User: &user.DefaultInfo{}},
want: v1beta1.SubjectAccessReview{
TypeMeta: expTypeMeta,
Spec: v1beta1.SubjectAccessReviewSpec{
NonResourceAttributes: &v1beta1.NonResourceAttributes{},
},
},
},
{
attr: authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "jane"}},
want: v1beta1.SubjectAccessReview{
TypeMeta: expTypeMeta,
Spec: v1beta1.SubjectAccessReviewSpec{
User: "jane",
NonResourceAttributes: &v1beta1.NonResourceAttributes{},
},
},
},
{
attr: authorizer.AttributesRecord{
User: &user.DefaultInfo{
Name: "jane",
UID: "1",
Groups: []string{"group1", "group2"},
},
Verb: "GET",
Namespace: "kittensandponies",
APIGroup: "group3",
APIVersion: "v7beta3",
Resource: "pods",
Subresource: "proxy",
Name: "my-pod",
ResourceRequest: true,
Path: "/foo",
},
want: v1beta1.SubjectAccessReview{
TypeMeta: expTypeMeta,
Spec: v1beta1.SubjectAccessReviewSpec{
User: "jane",
UID: "1",
Groups: []string{"group1", "group2"},
ResourceAttributes: &v1beta1.ResourceAttributes{
Verb: "GET",
Namespace: "kittensandponies",
Group: "group3",
Version: "v7beta3",
Resource: "pods",
Subresource: "proxy",
Name: "my-pod",
},
},
},
},
}
for i, tt := range tests {
decision, _, err := wh.Authorize(tt.attr)
if err != nil {
t.Fatal(err)
}
if decision != authorizer.DecisionAllow {
t.Errorf("case %d: authorization failed", i)
continue
}
gotAttr, err := serv.Last()
if err != nil {
t.Errorf("case %d: failed to deserialize webhook request: %v", i, err)
continue
}
if !reflect.DeepEqual(gotAttr, tt.want) {
t.Errorf("case %d: got != want:\n%s", i, diff.ObjectGoPrintDiff(gotAttr, tt.want))
}
}
}
type webhookCacheTestCase struct {
attr authorizer.AttributesRecord
allow bool
statusCode int
expectedErr bool
expectedAuthorized bool
expectedCalls int
}
func testWebhookCacheCases(t *testing.T, serv *mockService, wh *WebhookAuthorizer, tests []webhookCacheTestCase) {
for i, test := range tests {
serv.called = 0
serv.allow = test.allow
serv.statusCode = test.statusCode
authorized, _, err := wh.Authorize(test.attr)
if test.expectedErr && err == nil {
t.Errorf("%d: Expected error", i)
continue
} else if !test.expectedErr && err != nil {
t.Errorf("%d: unexpected error: %v", i, err)
continue
}
if test.expectedAuthorized != (authorized == authorizer.DecisionAllow) {
t.Errorf("%d: expected authorized=%v, got %v", i, test.expectedAuthorized, authorized)
}
if test.expectedCalls != serv.called {
t.Errorf("%d: expected %d calls, got %d", i, test.expectedCalls, serv.called)
}
}
}
// TestWebhookCache verifies that error responses from the server are not
// cached, but successful responses are.
func TestWebhookCache(t *testing.T) {
serv := new(mockService)
s, err := NewTestServer(serv, serverCert, serverKey, caCert)
if err != nil {
t.Fatal(err)
}
defer s.Close()
// Create an authorizer that caches successful responses "forever" (100 days).
wh, err := newAuthorizer(s.URL, clientCert, clientKey, caCert, 2400*time.Hour)
if err != nil {
t.Fatal(err)
}
aliceAttr := authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "alice"}}
bobAttr := authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "bob"}}
tests := []webhookCacheTestCase{
// server error and 429's retry
{attr: aliceAttr, allow: false, statusCode: 500, expectedErr: true, expectedAuthorized: false, expectedCalls: 5},
{attr: aliceAttr, allow: false, statusCode: 429, expectedErr: true, expectedAuthorized: false, expectedCalls: 5},
// regular errors return errors but do not retry
{attr: aliceAttr, allow: false, statusCode: 404, expectedErr: true, expectedAuthorized: false, expectedCalls: 1},
{attr: aliceAttr, allow: false, statusCode: 403, expectedErr: true, expectedAuthorized: false, expectedCalls: 1},
{attr: aliceAttr, allow: false, statusCode: 401, expectedErr: true, expectedAuthorized: false, expectedCalls: 1},
// successful responses are cached
{attr: aliceAttr, allow: true, statusCode: 200, expectedErr: false, expectedAuthorized: true, expectedCalls: 1},
// later requests within the cache window don't hit the backend
{attr: aliceAttr, allow: false, statusCode: 500, expectedErr: false, expectedAuthorized: true, expectedCalls: 0},
// a request with different attributes doesn't hit the cache
{attr: bobAttr, allow: false, statusCode: 500, expectedErr: true, expectedAuthorized: false, expectedCalls: 5},
// successful response for other attributes is cached
{attr: bobAttr, allow: true, statusCode: 200, expectedErr: false, expectedAuthorized: true, expectedCalls: 1},
// later requests within the cache window don't hit the backend
{attr: bobAttr, allow: false, statusCode: 500, expectedErr: false, expectedAuthorized: true, expectedCalls: 0},
}
testWebhookCacheCases(t, serv, wh, tests)
}