k8s.io/apiserver@v0.31.1/plugin/pkg/authorizer/webhook/webhook_v1beta1_test.go (about)

     1  /*
     2  Copyright 2016 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 webhook
    18  
    19  import (
    20  	"context"
    21  	"crypto/tls"
    22  	"crypto/x509"
    23  	"encoding/json"
    24  	"fmt"
    25  	"io/ioutil"
    26  	"net/http"
    27  	"net/http/httptest"
    28  	"net/url"
    29  	"os"
    30  	"path/filepath"
    31  	"reflect"
    32  	"strings"
    33  	"testing"
    34  	"text/template"
    35  	"time"
    36  
    37  	"github.com/google/go-cmp/cmp"
    38  
    39  	authorizationv1beta1 "k8s.io/api/authorization/v1beta1"
    40  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    41  	authzconfig "k8s.io/apiserver/pkg/apis/apiserver"
    42  	"k8s.io/apiserver/pkg/authentication/user"
    43  	"k8s.io/apiserver/pkg/authorization/authorizer"
    44  	webhookutil "k8s.io/apiserver/pkg/util/webhook"
    45  	v1 "k8s.io/client-go/tools/clientcmd/api/v1"
    46  )
    47  
    48  func TestV1beta1NewFromConfig(t *testing.T) {
    49  	dir, err := ioutil.TempDir("", "")
    50  	if err != nil {
    51  		t.Fatal(err)
    52  	}
    53  	defer os.RemoveAll(dir)
    54  
    55  	data := struct {
    56  		CA   string
    57  		Cert string
    58  		Key  string
    59  	}{
    60  		CA:   filepath.Join(dir, "ca.pem"),
    61  		Cert: filepath.Join(dir, "clientcert.pem"),
    62  		Key:  filepath.Join(dir, "clientkey.pem"),
    63  	}
    64  
    65  	files := []struct {
    66  		name string
    67  		data []byte
    68  	}{
    69  		{data.CA, caCert},
    70  		{data.Cert, clientCert},
    71  		{data.Key, clientKey},
    72  	}
    73  	for _, file := range files {
    74  		if err := ioutil.WriteFile(file.name, file.data, 0400); err != nil {
    75  			t.Fatal(err)
    76  		}
    77  	}
    78  
    79  	tests := []struct {
    80  		msg        string
    81  		configTmpl string
    82  		wantErr    bool
    83  	}{
    84  		{
    85  			msg: "a single cluster and single user",
    86  			configTmpl: `
    87  clusters:
    88  - cluster:
    89      certificate-authority: {{ .CA }}
    90      server: https://authz.example.com
    91    name: foobar
    92  users:
    93  - name: a cluster
    94    user:
    95      client-certificate: {{ .Cert }}
    96      client-key: {{ .Key }}
    97  `,
    98  			wantErr: true,
    99  		},
   100  		{
   101  			msg: "multiple clusters with no context",
   102  			configTmpl: `
   103  clusters:
   104  - cluster:
   105      certificate-authority: {{ .CA }}
   106      server: https://authz.example.com
   107    name: foobar
   108  - cluster:
   109      certificate-authority: a bad certificate path
   110      server: https://authz.example.com
   111    name: barfoo
   112  users:
   113  - name: a name
   114    user:
   115      client-certificate: {{ .Cert }}
   116      client-key: {{ .Key }}
   117  `,
   118  			wantErr: true,
   119  		},
   120  		{
   121  			msg: "multiple clusters with a context",
   122  			configTmpl: `
   123  clusters:
   124  - cluster:
   125      certificate-authority: a bad certificate path
   126      server: https://authz.example.com
   127    name: foobar
   128  - cluster:
   129      certificate-authority: {{ .CA }}
   130      server: https://authz.example.com
   131    name: barfoo
   132  users:
   133  - name: a name
   134    user:
   135      client-certificate: {{ .Cert }}
   136      client-key: {{ .Key }}
   137  contexts:
   138  - name: default
   139    context:
   140      cluster: barfoo
   141      user: a name
   142  current-context: default
   143  `,
   144  			wantErr: false,
   145  		},
   146  		{
   147  			msg: "cluster with bad certificate path specified",
   148  			configTmpl: `
   149  clusters:
   150  - cluster:
   151      certificate-authority: a bad certificate path
   152      server: https://authz.example.com
   153    name: foobar
   154  - cluster:
   155      certificate-authority: {{ .CA }}
   156      server: https://authz.example.com
   157    name: barfoo
   158  users:
   159  - name: a name
   160    user:
   161      client-certificate: {{ .Cert }}
   162      client-key: {{ .Key }}
   163  contexts:
   164  - name: default
   165    context:
   166      cluster: foobar
   167      user: a name
   168  current-context: default
   169  `,
   170  			wantErr: true,
   171  		},
   172  	}
   173  
   174  	for _, tt := range tests {
   175  		// Use a closure so defer statements trigger between loop iterations.
   176  		err := func() error {
   177  			tempfile, err := ioutil.TempFile("", "")
   178  			if err != nil {
   179  				return err
   180  			}
   181  			p := tempfile.Name()
   182  			defer os.Remove(p)
   183  
   184  			tmpl, err := template.New("test").Parse(tt.configTmpl)
   185  			if err != nil {
   186  				return fmt.Errorf("failed to parse test template: %v", err)
   187  			}
   188  			if err := tmpl.Execute(tempfile, data); err != nil {
   189  				return fmt.Errorf("failed to execute test template: %v", err)
   190  			}
   191  			// Create a new authorizer
   192  			clientConfig, err := webhookutil.LoadKubeconfig(p, nil)
   193  			if err != nil {
   194  				return err
   195  			}
   196  			sarClient, err := subjectAccessReviewInterfaceFromConfig(clientConfig, "v1beta1", testRetryBackoff)
   197  			if err != nil {
   198  				return fmt.Errorf("error building sar client: %v", err)
   199  			}
   200  			_, err = newWithBackoff(sarClient, 0, 0, testRetryBackoff, authorizer.DecisionNoOpinion, []authzconfig.WebhookMatchCondition{}, noopAuthorizerMetrics(), "")
   201  			return err
   202  		}()
   203  		if err != nil && !tt.wantErr {
   204  			t.Errorf("failed to load plugin from config %q: %v", tt.msg, err)
   205  		}
   206  		if err == nil && tt.wantErr {
   207  			t.Errorf("wanted an error when loading config, did not get one: %q", tt.msg)
   208  		}
   209  	}
   210  }
   211  
   212  // V1beta1Service mocks a remote service.
   213  type V1beta1Service interface {
   214  	Review(*authorizationv1beta1.SubjectAccessReview)
   215  	HTTPStatusCode() int
   216  }
   217  
   218  // NewV1beta1TestServer wraps a V1beta1Service as an httptest.Server.
   219  func NewV1beta1TestServer(s V1beta1Service, cert, key, caCert []byte) (*httptest.Server, error) {
   220  	const webhookPath = "/testserver"
   221  	var tlsConfig *tls.Config
   222  	if cert != nil {
   223  		cert, err := tls.X509KeyPair(cert, key)
   224  		if err != nil {
   225  			return nil, err
   226  		}
   227  		tlsConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
   228  	}
   229  
   230  	if caCert != nil {
   231  		rootCAs := x509.NewCertPool()
   232  		rootCAs.AppendCertsFromPEM(caCert)
   233  		if tlsConfig == nil {
   234  			tlsConfig = &tls.Config{}
   235  		}
   236  		tlsConfig.ClientCAs = rootCAs
   237  		tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
   238  	}
   239  
   240  	serveHTTP := func(w http.ResponseWriter, r *http.Request) {
   241  		if r.Method != "POST" {
   242  			http.Error(w, fmt.Sprintf("unexpected method: %v", r.Method), http.StatusMethodNotAllowed)
   243  			return
   244  		}
   245  		if r.URL.Path != webhookPath {
   246  			http.Error(w, fmt.Sprintf("unexpected path: %v", r.URL.Path), http.StatusNotFound)
   247  			return
   248  		}
   249  
   250  		var review authorizationv1beta1.SubjectAccessReview
   251  		bodyData, _ := ioutil.ReadAll(r.Body)
   252  		if err := json.Unmarshal(bodyData, &review); err != nil {
   253  			http.Error(w, fmt.Sprintf("failed to decode body: %v", err), http.StatusBadRequest)
   254  			return
   255  		}
   256  
   257  		// ensure we received the serialized review as expected
   258  		if review.APIVersion != "authorization.k8s.io/v1beta1" {
   259  			http.Error(w, fmt.Sprintf("wrong api version: %s", string(bodyData)), http.StatusBadRequest)
   260  			return
   261  		}
   262  		// once we have a successful request, always call the review to record that we were called
   263  		s.Review(&review)
   264  		if s.HTTPStatusCode() < 200 || s.HTTPStatusCode() >= 300 {
   265  			http.Error(w, "HTTP Error", s.HTTPStatusCode())
   266  			return
   267  		}
   268  		type status struct {
   269  			Allowed         bool   `json:"allowed"`
   270  			Reason          string `json:"reason"`
   271  			EvaluationError string `json:"evaluationError"`
   272  		}
   273  		resp := struct {
   274  			APIVersion string `json:"apiVersion"`
   275  			Status     status `json:"status"`
   276  		}{
   277  			APIVersion: authorizationv1beta1.SchemeGroupVersion.String(),
   278  			Status:     status{review.Status.Allowed, review.Status.Reason, review.Status.EvaluationError},
   279  		}
   280  		w.Header().Set("Content-Type", "application/json")
   281  		json.NewEncoder(w).Encode(resp)
   282  	}
   283  
   284  	server := httptest.NewUnstartedServer(http.HandlerFunc(serveHTTP))
   285  	server.TLS = tlsConfig
   286  	server.StartTLS()
   287  
   288  	// Adjust the path to point to our custom path
   289  	serverURL, _ := url.Parse(server.URL)
   290  	serverURL.Path = webhookPath
   291  	server.URL = serverURL.String()
   292  
   293  	return server, nil
   294  }
   295  
   296  // A service that can be set to allow all or deny all authorization requests.
   297  type mockV1beta1Service struct {
   298  	allow      bool
   299  	statusCode int
   300  	called     int
   301  }
   302  
   303  func (m *mockV1beta1Service) Review(r *authorizationv1beta1.SubjectAccessReview) {
   304  	m.called++
   305  	r.Status.Allowed = m.allow
   306  }
   307  func (m *mockV1beta1Service) Allow()              { m.allow = true }
   308  func (m *mockV1beta1Service) Deny()               { m.allow = false }
   309  func (m *mockV1beta1Service) HTTPStatusCode() int { return m.statusCode }
   310  
   311  // newV1beta1Authorizer creates a temporary kubeconfig file from the provided arguments and attempts to load
   312  // a new WebhookAuthorizer from it.
   313  func newV1beta1Authorizer(callbackURL string, clientCert, clientKey, ca []byte, cacheTime time.Duration) (*WebhookAuthorizer, error) {
   314  	tempfile, err := ioutil.TempFile("", "")
   315  	if err != nil {
   316  		return nil, err
   317  	}
   318  	p := tempfile.Name()
   319  	defer os.Remove(p)
   320  	config := v1.Config{
   321  		Clusters: []v1.NamedCluster{
   322  			{
   323  				Cluster: v1.Cluster{Server: callbackURL, CertificateAuthorityData: ca},
   324  			},
   325  		},
   326  		AuthInfos: []v1.NamedAuthInfo{
   327  			{
   328  				AuthInfo: v1.AuthInfo{ClientCertificateData: clientCert, ClientKeyData: clientKey},
   329  			},
   330  		},
   331  	}
   332  	if err := json.NewEncoder(tempfile).Encode(config); err != nil {
   333  		return nil, err
   334  	}
   335  	clientConfig, err := webhookutil.LoadKubeconfig(p, nil)
   336  	if err != nil {
   337  		return nil, err
   338  	}
   339  	sarClient, err := subjectAccessReviewInterfaceFromConfig(clientConfig, "v1beta1", testRetryBackoff)
   340  	if err != nil {
   341  		return nil, fmt.Errorf("error building sar client: %v", err)
   342  	}
   343  	return newWithBackoff(sarClient, cacheTime, cacheTime, testRetryBackoff, authorizer.DecisionNoOpinion, []authzconfig.WebhookMatchCondition{}, noopAuthorizerMetrics(), "")
   344  }
   345  
   346  func TestV1beta1TLSConfig(t *testing.T) {
   347  	tests := []struct {
   348  		test                            string
   349  		clientCert, clientKey, clientCA []byte
   350  		serverCert, serverKey, serverCA []byte
   351  		wantAuth, wantErr               bool
   352  	}{
   353  		{
   354  			test:       "TLS setup between client and server",
   355  			clientCert: clientCert, clientKey: clientKey, clientCA: caCert,
   356  			serverCert: serverCert, serverKey: serverKey, serverCA: caCert,
   357  			wantAuth: true,
   358  		},
   359  		{
   360  			test:       "Server does not require client auth",
   361  			clientCA:   caCert,
   362  			serverCert: serverCert, serverKey: serverKey,
   363  			wantAuth: true,
   364  		},
   365  		{
   366  			test:       "Server does not require client auth, client provides it",
   367  			clientCert: clientCert, clientKey: clientKey, clientCA: caCert,
   368  			serverCert: serverCert, serverKey: serverKey,
   369  			wantAuth: true,
   370  		},
   371  		{
   372  			test:       "Client does not trust server",
   373  			clientCert: clientCert, clientKey: clientKey,
   374  			serverCert: serverCert, serverKey: serverKey,
   375  			wantErr: true,
   376  		},
   377  		{
   378  			test:       "Server does not trust client",
   379  			clientCert: clientCert, clientKey: clientKey, clientCA: caCert,
   380  			serverCert: serverCert, serverKey: serverKey, serverCA: badCACert,
   381  			wantErr: true,
   382  		},
   383  		{
   384  			// Plugin does not support insecure configurations.
   385  			test:    "Server is using insecure connection",
   386  			wantErr: true,
   387  		},
   388  	}
   389  	for _, tt := range tests {
   390  		// Use a closure so defer statements trigger between loop iterations.
   391  		func() {
   392  			service := new(mockV1beta1Service)
   393  			service.statusCode = 200
   394  
   395  			server, err := NewV1beta1TestServer(service, tt.serverCert, tt.serverKey, tt.serverCA)
   396  			if err != nil {
   397  				t.Errorf("%s: failed to create server: %v", tt.test, err)
   398  				return
   399  			}
   400  			defer server.Close()
   401  
   402  			wh, err := newV1beta1Authorizer(server.URL, tt.clientCert, tt.clientKey, tt.clientCA, 0)
   403  			if err != nil {
   404  				t.Errorf("%s: failed to create client: %v", tt.test, err)
   405  				return
   406  			}
   407  
   408  			attr := authorizer.AttributesRecord{User: &user.DefaultInfo{}}
   409  
   410  			// Allow all and see if we get an error.
   411  			service.Allow()
   412  			decision, _, err := wh.Authorize(context.Background(), attr)
   413  			if tt.wantAuth {
   414  				if decision != authorizer.DecisionAllow {
   415  					t.Errorf("expected successful authorization")
   416  				}
   417  			} else {
   418  				if decision == authorizer.DecisionAllow {
   419  					t.Errorf("expected failed authorization")
   420  				}
   421  			}
   422  			if tt.wantErr {
   423  				if err == nil {
   424  					t.Errorf("expected error making authorization request: %v", err)
   425  				}
   426  				return
   427  			}
   428  			if err != nil {
   429  				t.Errorf("%s: failed to authorize with AllowAll policy: %v", tt.test, err)
   430  				return
   431  			}
   432  
   433  			service.Deny()
   434  			if decision, _, _ := wh.Authorize(context.Background(), attr); decision == authorizer.DecisionAllow {
   435  				t.Errorf("%s: incorrectly authorized with DenyAll policy", tt.test)
   436  			}
   437  		}()
   438  	}
   439  }
   440  
   441  // recorderV1beta1Service records all access review requests.
   442  type recorderV1beta1Service struct {
   443  	last authorizationv1beta1.SubjectAccessReview
   444  	err  error
   445  }
   446  
   447  func (rec *recorderV1beta1Service) Review(r *authorizationv1beta1.SubjectAccessReview) {
   448  	rec.last = authorizationv1beta1.SubjectAccessReview{}
   449  	rec.last = *r
   450  	r.Status.Allowed = true
   451  }
   452  
   453  func (rec *recorderV1beta1Service) Last() (authorizationv1beta1.SubjectAccessReview, error) {
   454  	return rec.last, rec.err
   455  }
   456  
   457  func (rec *recorderV1beta1Service) HTTPStatusCode() int { return 200 }
   458  
   459  func TestV1beta1Webhook(t *testing.T) {
   460  	serv := new(recorderV1beta1Service)
   461  	s, err := NewV1beta1TestServer(serv, serverCert, serverKey, caCert)
   462  	if err != nil {
   463  		t.Fatal(err)
   464  	}
   465  	defer s.Close()
   466  
   467  	wh, err := newV1beta1Authorizer(s.URL, clientCert, clientKey, caCert, 0)
   468  	if err != nil {
   469  		t.Fatal(err)
   470  	}
   471  
   472  	expTypeMeta := metav1.TypeMeta{
   473  		APIVersion: "authorization.k8s.io/v1beta1",
   474  		Kind:       "SubjectAccessReview",
   475  	}
   476  
   477  	tests := []struct {
   478  		attr authorizer.Attributes
   479  		want authorizationv1beta1.SubjectAccessReview
   480  	}{
   481  		{
   482  			attr: authorizer.AttributesRecord{User: &user.DefaultInfo{}},
   483  			want: authorizationv1beta1.SubjectAccessReview{
   484  				TypeMeta: expTypeMeta,
   485  				Spec: authorizationv1beta1.SubjectAccessReviewSpec{
   486  					NonResourceAttributes: &authorizationv1beta1.NonResourceAttributes{},
   487  				},
   488  			},
   489  		},
   490  		{
   491  			attr: authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "jane"}},
   492  			want: authorizationv1beta1.SubjectAccessReview{
   493  				TypeMeta: expTypeMeta,
   494  				Spec: authorizationv1beta1.SubjectAccessReviewSpec{
   495  					User:                  "jane",
   496  					NonResourceAttributes: &authorizationv1beta1.NonResourceAttributes{},
   497  				},
   498  			},
   499  		},
   500  		{
   501  			attr: authorizer.AttributesRecord{
   502  				User: &user.DefaultInfo{
   503  					Name:   "jane",
   504  					UID:    "1",
   505  					Groups: []string{"group1", "group2"},
   506  				},
   507  				Verb:            "GET",
   508  				Namespace:       "kittensandponies",
   509  				APIGroup:        "group3",
   510  				APIVersion:      "v7beta3",
   511  				Resource:        "pods",
   512  				Subresource:     "proxy",
   513  				Name:            "my-pod",
   514  				ResourceRequest: true,
   515  				Path:            "/foo",
   516  			},
   517  			want: authorizationv1beta1.SubjectAccessReview{
   518  				TypeMeta: expTypeMeta,
   519  				Spec: authorizationv1beta1.SubjectAccessReviewSpec{
   520  					User:   "jane",
   521  					UID:    "1",
   522  					Groups: []string{"group1", "group2"},
   523  					ResourceAttributes: &authorizationv1beta1.ResourceAttributes{
   524  						Verb:        "GET",
   525  						Namespace:   "kittensandponies",
   526  						Group:       "group3",
   527  						Version:     "v7beta3",
   528  						Resource:    "pods",
   529  						Subresource: "proxy",
   530  						Name:        "my-pod",
   531  					},
   532  				},
   533  			},
   534  		},
   535  	}
   536  
   537  	for i, tt := range tests {
   538  		decision, _, err := wh.Authorize(context.Background(), tt.attr)
   539  		if err != nil {
   540  			t.Fatal(err)
   541  		}
   542  		if decision != authorizer.DecisionAllow {
   543  			t.Errorf("case %d: authorization failed", i)
   544  			continue
   545  		}
   546  
   547  		gotAttr, err := serv.Last()
   548  		if err != nil {
   549  			t.Errorf("case %d: failed to deserialize webhook request: %v", i, err)
   550  			continue
   551  		}
   552  		if !reflect.DeepEqual(gotAttr, tt.want) {
   553  			t.Errorf("case %d: got != want:\n%s", i, cmp.Diff(gotAttr, tt.want))
   554  		}
   555  	}
   556  }
   557  
   558  // TestWebhookCache verifies that error responses from the server are not
   559  // cached, but successful responses are.
   560  func TestV1beta1WebhookCache(t *testing.T) {
   561  	serv := new(mockV1beta1Service)
   562  	s, err := NewV1beta1TestServer(serv, serverCert, serverKey, caCert)
   563  	if err != nil {
   564  		t.Fatal(err)
   565  	}
   566  	defer s.Close()
   567  
   568  	// Create an authorizer that caches successful responses "forever" (100 days).
   569  	wh, err := newV1beta1Authorizer(s.URL, clientCert, clientKey, caCert, 2400*time.Hour)
   570  	if err != nil {
   571  		t.Fatal(err)
   572  	}
   573  
   574  	aliceAttr := authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "alice"}}
   575  	bobAttr := authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "bob"}}
   576  	aliceRidiculousAttr := authorizer.AttributesRecord{
   577  		User:            &user.DefaultInfo{Name: "alice"},
   578  		ResourceRequest: true,
   579  		Verb:            strings.Repeat("v", 2000),
   580  		APIGroup:        strings.Repeat("g", 2000),
   581  		APIVersion:      strings.Repeat("a", 2000),
   582  		Resource:        strings.Repeat("r", 2000),
   583  		Name:            strings.Repeat("n", 2000),
   584  	}
   585  	bobRidiculousAttr := authorizer.AttributesRecord{
   586  		User:            &user.DefaultInfo{Name: "bob"},
   587  		ResourceRequest: true,
   588  		Verb:            strings.Repeat("v", 2000),
   589  		APIGroup:        strings.Repeat("g", 2000),
   590  		APIVersion:      strings.Repeat("a", 2000),
   591  		Resource:        strings.Repeat("r", 2000),
   592  		Name:            strings.Repeat("n", 2000),
   593  	}
   594  
   595  	type webhookCacheTestCase struct {
   596  		name string
   597  
   598  		attr authorizer.AttributesRecord
   599  
   600  		allow      bool
   601  		statusCode int
   602  
   603  		expectedErr        bool
   604  		expectedAuthorized bool
   605  		expectedCalls      int
   606  	}
   607  
   608  	tests := []webhookCacheTestCase{
   609  		// server error and 429's retry
   610  		{name: "server errors retry", attr: aliceAttr, allow: false, statusCode: 500, expectedErr: true, expectedAuthorized: false, expectedCalls: 5},
   611  		{name: "429s retry", attr: aliceAttr, allow: false, statusCode: 429, expectedErr: true, expectedAuthorized: false, expectedCalls: 5},
   612  		// regular errors return errors but do not retry
   613  		{name: "404 doesnt retry", attr: aliceAttr, allow: false, statusCode: 404, expectedErr: true, expectedAuthorized: false, expectedCalls: 1},
   614  		{name: "403 doesnt retry", attr: aliceAttr, allow: false, statusCode: 403, expectedErr: true, expectedAuthorized: false, expectedCalls: 1},
   615  		{name: "401 doesnt retry", attr: aliceAttr, allow: false, statusCode: 401, expectedErr: true, expectedAuthorized: false, expectedCalls: 1},
   616  		// successful responses are cached
   617  		{name: "alice successful request", attr: aliceAttr, allow: true, statusCode: 200, expectedErr: false, expectedAuthorized: true, expectedCalls: 1},
   618  		// later requests within the cache window don't hit the backend
   619  		{name: "alice cached request", attr: aliceAttr, allow: false, statusCode: 500, expectedErr: false, expectedAuthorized: true, expectedCalls: 0},
   620  
   621  		// a request with different attributes doesn't hit the cache
   622  		{name: "bob failed request", attr: bobAttr, allow: false, statusCode: 500, expectedErr: true, expectedAuthorized: false, expectedCalls: 5},
   623  		// successful response for other attributes is cached
   624  		{name: "bob unauthorized request", attr: bobAttr, allow: false, statusCode: 200, expectedErr: false, expectedAuthorized: false, expectedCalls: 1},
   625  		// later requests within the cache window don't hit the backend
   626  		{name: "bob unauthorized cached request", attr: bobAttr, allow: false, statusCode: 500, expectedErr: false, expectedAuthorized: false, expectedCalls: 0},
   627  		// ridiculous unauthorized requests are not cached.
   628  		{name: "ridiculous unauthorized request", attr: bobRidiculousAttr, allow: false, statusCode: 200, expectedErr: false, expectedAuthorized: false, expectedCalls: 1},
   629  		// later ridiculous requests within the cache window still hit the backend
   630  		{name: "ridiculous unauthorized request again", attr: bobRidiculousAttr, allow: false, statusCode: 200, expectedErr: false, expectedAuthorized: false, expectedCalls: 1},
   631  		// ridiculous authorized requests are not cached.
   632  		{name: "ridiculous authorized request", attr: aliceRidiculousAttr, allow: true, statusCode: 200, expectedErr: false, expectedAuthorized: true, expectedCalls: 1},
   633  		// later ridiculous requests within the cache window still hit the backend
   634  		{name: "ridiculous authorized request again", attr: aliceRidiculousAttr, allow: true, statusCode: 200, expectedErr: false, expectedAuthorized: true, expectedCalls: 1},
   635  	}
   636  
   637  	for i, test := range tests {
   638  		t.Run(test.name, func(t *testing.T) {
   639  			serv.called = 0
   640  			serv.allow = test.allow
   641  			serv.statusCode = test.statusCode
   642  			authorized, _, err := wh.Authorize(context.Background(), test.attr)
   643  			if test.expectedErr && err == nil {
   644  				t.Fatalf("%d: Expected error", i)
   645  			} else if !test.expectedErr && err != nil {
   646  				t.Fatalf("%d: unexpected error: %v", i, err)
   647  			}
   648  
   649  			if test.expectedAuthorized != (authorized == authorizer.DecisionAllow) {
   650  				t.Errorf("%d: expected authorized=%v, got %v", i, test.expectedAuthorized, authorized)
   651  			}
   652  
   653  			if test.expectedCalls != serv.called {
   654  				t.Errorf("%d: expected %d calls, got %d", i, test.expectedCalls, serv.called)
   655  			}
   656  		})
   657  	}
   658  }