diff --git a/cmd/kube-apiserver/app/options/options_test.go b/cmd/kube-apiserver/app/options/options_test.go index c2c390f01fe..683127f273a 100644 --- a/cmd/kube-apiserver/app/options/options_test.go +++ b/cmd/kube-apiserver/app/options/options_test.go @@ -290,6 +290,7 @@ func TestAddFlags(t *testing.T) { WebhookConfigFile: "/webhook-config", WebhookCacheAuthorizedTTL: 180000000000, WebhookCacheUnauthorizedTTL: 60000000000, + WebhookVersion: "v1beta1", }, CloudProvider: &kubeoptions.CloudProviderOptions{ CloudConfigFile: "/cloud-config", diff --git a/cmd/kubelet/app/auth.go b/cmd/kubelet/app/auth.go index f75766daafc..22a0285d8b9 100644 --- a/cmd/kubelet/app/auth.go +++ b/cmd/kubelet/app/auth.go @@ -29,7 +29,7 @@ import ( "k8s.io/apiserver/pkg/server/dynamiccertificates" clientset "k8s.io/client-go/kubernetes" authenticationclient "k8s.io/client-go/kubernetes/typed/authentication/v1" - authorizationclient "k8s.io/client-go/kubernetes/typed/authorization/v1beta1" + authorizationclient "k8s.io/client-go/kubernetes/typed/authorization/v1" kubeletconfig "k8s.io/kubernetes/pkg/kubelet/apis/config" "k8s.io/kubernetes/pkg/kubelet/server" @@ -44,7 +44,7 @@ func BuildAuth(nodeName types.NodeName, client clientset.Interface, config kubel ) if client != nil && !reflect.ValueOf(client).IsNil() { tokenClient = client.AuthenticationV1().TokenReviews() - sarClient = client.AuthorizationV1beta1().SubjectAccessReviews() + sarClient = client.AuthorizationV1().SubjectAccessReviews() } authenticator, err := BuildAuthn(tokenClient, config.Authentication) diff --git a/pkg/kubeapiserver/authorizer/config.go b/pkg/kubeapiserver/authorizer/config.go index 8382be9c86f..a6542bfeaa0 100644 --- a/pkg/kubeapiserver/authorizer/config.go +++ b/pkg/kubeapiserver/authorizer/config.go @@ -46,6 +46,8 @@ type Config struct { // Kubeconfig file for Webhook authorization plugin. WebhookConfigFile string + // API version of subject access reviews to send to the webhook (e.g. "v1", "v1beta1") + WebhookVersion string // TTL for caching of authorized responses from the webhook server. WebhookCacheAuthorizedTTL time.Duration // TTL for caching of unauthorized responses from the webhook server. @@ -98,6 +100,7 @@ func (config Config) New() (authorizer.Authorizer, authorizer.RuleResolver, erro ruleResolvers = append(ruleResolvers, abacAuthorizer) case modes.ModeWebhook: webhookAuthorizer, err := webhook.New(config.WebhookConfigFile, + config.WebhookVersion, config.WebhookCacheAuthorizedTTL, config.WebhookCacheUnauthorizedTTL) if err != nil { diff --git a/pkg/kubeapiserver/options/authorization.go b/pkg/kubeapiserver/options/authorization.go index bbf04103c7c..53cccd852fc 100644 --- a/pkg/kubeapiserver/options/authorization.go +++ b/pkg/kubeapiserver/options/authorization.go @@ -33,6 +33,7 @@ type BuiltInAuthorizationOptions struct { Modes []string PolicyFile string WebhookConfigFile string + WebhookVersion string WebhookCacheAuthorizedTTL time.Duration WebhookCacheUnauthorizedTTL time.Duration } @@ -40,6 +41,7 @@ type BuiltInAuthorizationOptions struct { func NewBuiltInAuthorizationOptions() *BuiltInAuthorizationOptions { return &BuiltInAuthorizationOptions{ Modes: []string{authzmodes.ModeAlwaysAllow}, + WebhookVersion: "v1beta1", WebhookCacheAuthorizedTTL: 5 * time.Minute, WebhookCacheUnauthorizedTTL: 30 * time.Second, } @@ -99,6 +101,9 @@ func (s *BuiltInAuthorizationOptions) AddFlags(fs *pflag.FlagSet) { "File with webhook configuration in kubeconfig format, used with --authorization-mode=Webhook. "+ "The API server will query the remote service to determine access on the API server's secure port.") + fs.StringVar(&s.WebhookVersion, "authorization-webhook-version", s.WebhookVersion, ""+ + "The API version of the authorization.k8s.io SubjectAccessReview to send to and expect from the webhook.") + fs.DurationVar(&s.WebhookCacheAuthorizedTTL, "authorization-webhook-cache-authorized-ttl", s.WebhookCacheAuthorizedTTL, "The duration to cache 'authorized' responses from the webhook authorizer.") diff --git a/staging/src/k8s.io/apiserver/pkg/authorization/authorizerfactory/delegating.go b/staging/src/k8s.io/apiserver/pkg/authorization/authorizerfactory/delegating.go index c75c0a7552b..fa385e12554 100644 --- a/staging/src/k8s.io/apiserver/pkg/authorization/authorizerfactory/delegating.go +++ b/staging/src/k8s.io/apiserver/pkg/authorization/authorizerfactory/delegating.go @@ -21,7 +21,7 @@ import ( "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/plugin/pkg/authorizer/webhook" - authorizationclient "k8s.io/client-go/kubernetes/typed/authorization/v1beta1" + authorizationclient "k8s.io/client-go/kubernetes/typed/authorization/v1" ) // DelegatingAuthorizerConfig is the minimal configuration needed to create an authenticator diff --git a/staging/src/k8s.io/apiserver/pkg/server/options/authorization.go b/staging/src/k8s.io/apiserver/pkg/server/options/authorization.go index 5d81d9e8660..7284c261f18 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/options/authorization.go +++ b/staging/src/k8s.io/apiserver/pkg/server/options/authorization.go @@ -146,7 +146,7 @@ func (s *DelegatingAuthorizationOptions) toAuthorizer(client kubernetes.Interfac klog.Warningf("No authorization-kubeconfig provided, so SubjectAccessReview of authorization tokens won't work.") } else { cfg := authorizerfactory.DelegatingAuthorizerConfig{ - SubjectAccessReviewClient: client.AuthorizationV1beta1().SubjectAccessReviews(), + SubjectAccessReviewClient: client.AuthorizationV1().SubjectAccessReviews(), AllowCacheTTL: s.AllowCacheTTL, DenyCacheTTL: s.DenyCacheTTL, } diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/round_trip_test.go b/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/round_trip_test.go new file mode 100644 index 00000000000..885aa0e71aa --- /dev/null +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/round_trip_test.go @@ -0,0 +1,113 @@ +/* +Copyright 2019 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 implements the authorizer.Authorizer interface using HTTP webhooks. +package webhook + +import ( + "math/rand" + "reflect" + "testing" + "time" + + fuzz "github.com/google/gofuzz" + + authorizationv1 "k8s.io/api/authorization/v1" + authorizationv1beta1 "k8s.io/api/authorization/v1beta1" + "k8s.io/apimachinery/pkg/util/diff" +) + +func TestRoundTrip(t *testing.T) { + f := fuzz.New() + seed := time.Now().UnixNano() + t.Logf("seed = %v", seed) + f.RandSource(rand.New(rand.NewSource(seed))) + + for i := 0; i < 1000; i++ { + original := &authorizationv1.SubjectAccessReview{} + f.Fuzz(&original.Spec) + f.Fuzz(&original.Status) + converted := &authorizationv1beta1.SubjectAccessReview{ + Spec: v1SpecToV1beta1Spec(&original.Spec), + Status: v1StatusToV1beta1Status(original.Status), + } + roundtripped := &authorizationv1.SubjectAccessReview{ + Spec: v1beta1SpecToV1Spec(converted.Spec), + Status: v1beta1StatusToV1Status(&converted.Status), + } + if !reflect.DeepEqual(original, roundtripped) { + t.Errorf("diff %s", diff.ObjectReflectDiff(original, roundtripped)) + } + } +} + +// v1StatusToV1beta1Status is only needed to verify round-trip fidelity +func v1StatusToV1beta1Status(in authorizationv1.SubjectAccessReviewStatus) authorizationv1beta1.SubjectAccessReviewStatus { + return authorizationv1beta1.SubjectAccessReviewStatus{ + Allowed: in.Allowed, + Denied: in.Denied, + Reason: in.Reason, + EvaluationError: in.EvaluationError, + } +} + +// v1beta1SpecToV1Spec is only needed to verify round-trip fidelity +func v1beta1SpecToV1Spec(in authorizationv1beta1.SubjectAccessReviewSpec) authorizationv1.SubjectAccessReviewSpec { + return authorizationv1.SubjectAccessReviewSpec{ + ResourceAttributes: v1beta1ResourceAttributesToV1ResourceAttributes(in.ResourceAttributes), + NonResourceAttributes: v1beta1NonResourceAttributesToV1NonResourceAttributes(in.NonResourceAttributes), + User: in.User, + Groups: in.Groups, + Extra: v1beta1ExtraToV1Extra(in.Extra), + UID: in.UID, + } +} + +func v1beta1ResourceAttributesToV1ResourceAttributes(in *authorizationv1beta1.ResourceAttributes) *authorizationv1.ResourceAttributes { + if in == nil { + return nil + } + return &authorizationv1.ResourceAttributes{ + Namespace: in.Namespace, + Verb: in.Verb, + Group: in.Group, + Version: in.Version, + Resource: in.Resource, + Subresource: in.Subresource, + Name: in.Name, + } +} + +func v1beta1NonResourceAttributesToV1NonResourceAttributes(in *authorizationv1beta1.NonResourceAttributes) *authorizationv1.NonResourceAttributes { + if in == nil { + return nil + } + return &authorizationv1.NonResourceAttributes{ + Path: in.Path, + Verb: in.Verb, + } +} + +func v1beta1ExtraToV1Extra(in map[string]authorizationv1beta1.ExtraValue) map[string]authorizationv1.ExtraValue { + if in == nil { + return nil + } + ret := make(map[string]authorizationv1.ExtraValue, len(in)) + for k, v := range in { + ret[k] = authorizationv1.ExtraValue(v) + } + return ret +} diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/webhook.go b/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/webhook.go index 83ac25c3910..2a0678e8e3c 100644 --- a/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/webhook.go +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/webhook.go @@ -25,7 +25,8 @@ import ( "k8s.io/klog" - authorization "k8s.io/api/authorization/v1beta1" + authorizationv1 "k8s.io/api/authorization/v1" + authorizationv1beta1 "k8s.io/api/authorization/v1beta1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/cache" @@ -33,11 +34,7 @@ import ( "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/util/webhook" "k8s.io/client-go/kubernetes/scheme" - authorizationclient "k8s.io/client-go/kubernetes/typed/authorization/v1beta1" -) - -var ( - groupVersions = []schema.GroupVersion{authorization.SchemeGroupVersion} + authorizationv1client "k8s.io/client-go/kubernetes/typed/authorization/v1" ) const ( @@ -49,8 +46,12 @@ const ( // Ensure Webhook implements the authorizer.Authorizer interface. var _ authorizer.Authorizer = (*WebhookAuthorizer)(nil) +type subjectAccessReviewer interface { + CreateContext(context.Context, *authorizationv1.SubjectAccessReview) (*authorizationv1.SubjectAccessReview, error) +} + type WebhookAuthorizer struct { - subjectAccessReview authorizationclient.SubjectAccessReviewInterface + subjectAccessReview subjectAccessReviewer responseCache *cache.LRUExpireCache authorizedTTL time.Duration unauthorizedTTL time.Duration @@ -59,12 +60,11 @@ type WebhookAuthorizer struct { } // NewFromInterface creates a WebhookAuthorizer using the given subjectAccessReview client -func NewFromInterface(subjectAccessReview authorizationclient.SubjectAccessReviewInterface, authorizedTTL, unauthorizedTTL time.Duration) (*WebhookAuthorizer, error) { +func NewFromInterface(subjectAccessReview authorizationv1client.SubjectAccessReviewInterface, authorizedTTL, unauthorizedTTL time.Duration) (*WebhookAuthorizer, error) { return newWithBackoff(subjectAccessReview, authorizedTTL, unauthorizedTTL, retryBackoff) } // New creates a new WebhookAuthorizer from the provided kubeconfig file. -// // The config's cluster field is used to refer to the remote service, user refers to the returned authorizer. // // # clusters refers to the remote service. @@ -83,8 +83,8 @@ func NewFromInterface(subjectAccessReview authorizationclient.SubjectAccessRevie // // For additional HTTP configuration, refer to the kubeconfig documentation // https://kubernetes.io/docs/user-guide/kubeconfig-file/. -func New(kubeConfigFile string, authorizedTTL, unauthorizedTTL time.Duration) (*WebhookAuthorizer, error) { - subjectAccessReview, err := subjectAccessReviewInterfaceFromKubeconfig(kubeConfigFile) +func New(kubeConfigFile string, version string, authorizedTTL, unauthorizedTTL time.Duration) (*WebhookAuthorizer, error) { + subjectAccessReview, err := subjectAccessReviewInterfaceFromKubeconfig(kubeConfigFile, version) if err != nil { return nil, err } @@ -92,7 +92,7 @@ func New(kubeConfigFile string, authorizedTTL, unauthorizedTTL time.Duration) (* } // newWithBackoff allows tests to skip the sleep. -func newWithBackoff(subjectAccessReview authorizationclient.SubjectAccessReviewInterface, authorizedTTL, unauthorizedTTL, initialBackoff time.Duration) (*WebhookAuthorizer, error) { +func newWithBackoff(subjectAccessReview subjectAccessReviewer, authorizedTTL, unauthorizedTTL, initialBackoff time.Duration) (*WebhookAuthorizer, error) { return &WebhookAuthorizer{ subjectAccessReview: subjectAccessReview, responseCache: cache.NewLRUExpireCache(1024), @@ -151,9 +151,9 @@ func newWithBackoff(subjectAccessReview authorizationclient.SubjectAccessReviewI // encounter an error. We are failing open now to preserve backwards compatible // behavior. func (w *WebhookAuthorizer) Authorize(ctx context.Context, attr authorizer.Attributes) (decision authorizer.Decision, reason string, err error) { - r := &authorization.SubjectAccessReview{} + r := &authorizationv1.SubjectAccessReview{} if user := attr.GetUser(); user != nil { - r.Spec = authorization.SubjectAccessReviewSpec{ + r.Spec = authorizationv1.SubjectAccessReviewSpec{ User: user.GetName(), UID: user.GetUID(), Groups: user.GetGroups(), @@ -162,7 +162,7 @@ func (w *WebhookAuthorizer) Authorize(ctx context.Context, attr authorizer.Attri } if attr.IsResourceRequest() { - r.Spec.ResourceAttributes = &authorization.ResourceAttributes{ + r.Spec.ResourceAttributes = &authorizationv1.ResourceAttributes{ Namespace: attr.GetNamespace(), Verb: attr.GetVerb(), Group: attr.GetAPIGroup(), @@ -172,7 +172,7 @@ func (w *WebhookAuthorizer) Authorize(ctx context.Context, attr authorizer.Attri Name: attr.GetName(), } } else { - r.Spec.NonResourceAttributes = &authorization.NonResourceAttributes{ + r.Spec.NonResourceAttributes = &authorizationv1.NonResourceAttributes{ Path: attr.GetPath(), Verb: attr.GetVerb(), } @@ -182,10 +182,10 @@ func (w *WebhookAuthorizer) Authorize(ctx context.Context, attr authorizer.Attri return w.decisionOnError, "", err } if entry, ok := w.responseCache.Get(string(key)); ok { - r.Status = entry.(authorization.SubjectAccessReviewStatus) + r.Status = entry.(authorizationv1.SubjectAccessReviewStatus) } else { var ( - result *authorization.SubjectAccessReview + result *authorizationv1.SubjectAccessReview err error ) webhook.WithExponentialBackoff(ctx, w.initialBackoff, func() error { @@ -229,13 +229,13 @@ func (w *WebhookAuthorizer) RulesFor(user user.Info, namespace string) ([]author return resourceRules, nonResourceRules, incomplete, fmt.Errorf("webhook authorizer does not support user rule resolution") } -func convertToSARExtra(extra map[string][]string) map[string]authorization.ExtraValue { +func convertToSARExtra(extra map[string][]string) map[string]authorizationv1.ExtraValue { if extra == nil { return nil } - ret := map[string]authorization.ExtraValue{} + ret := map[string]authorizationv1.ExtraValue{} for k, v := range extra { - ret[k] = authorization.ExtraValue(v) + ret[k] = authorizationv1.ExtraValue(v) } return ret @@ -244,36 +244,69 @@ func convertToSARExtra(extra map[string][]string) map[string]authorization.Extra // subjectAccessReviewInterfaceFromKubeconfig builds a client from the specified kubeconfig file, // and returns a SubjectAccessReviewInterface that uses that client. Note that the client submits SubjectAccessReview // requests to the exact path specified in the kubeconfig file, so arbitrary non-API servers can be targeted. -func subjectAccessReviewInterfaceFromKubeconfig(kubeConfigFile string) (authorizationclient.SubjectAccessReviewInterface, error) { +func subjectAccessReviewInterfaceFromKubeconfig(kubeConfigFile string, version string) (subjectAccessReviewer, error) { localScheme := runtime.NewScheme() if err := scheme.AddToScheme(localScheme); err != nil { return nil, err } - if err := localScheme.SetVersionPriority(groupVersions...); err != nil { - return nil, err - } - gw, err := webhook.NewGenericWebhook(localScheme, scheme.Codecs, kubeConfigFile, groupVersions, 0) - if err != nil { - return nil, err + switch version { + case authorizationv1.SchemeGroupVersion.Version: + groupVersions := []schema.GroupVersion{authorizationv1.SchemeGroupVersion} + if err := localScheme.SetVersionPriority(groupVersions...); err != nil { + return nil, err + } + gw, err := webhook.NewGenericWebhook(localScheme, scheme.Codecs, kubeConfigFile, groupVersions, 0) + if err != nil { + return nil, err + } + return &subjectAccessReviewV1Client{gw}, nil + + case authorizationv1beta1.SchemeGroupVersion.Version: + groupVersions := []schema.GroupVersion{authorizationv1beta1.SchemeGroupVersion} + if err := localScheme.SetVersionPriority(groupVersions...); err != nil { + return nil, err + } + gw, err := webhook.NewGenericWebhook(localScheme, scheme.Codecs, kubeConfigFile, groupVersions, 0) + if err != nil { + return nil, err + } + return &subjectAccessReviewV1beta1Client{gw}, nil + + default: + return nil, fmt.Errorf( + "unsupported webhook authorizer version %q, supported versions are %q, %q", + version, + authorizationv1.SchemeGroupVersion.Version, + authorizationv1beta1.SchemeGroupVersion.Version, + ) } - return &subjectAccessReviewClient{gw}, nil } -type subjectAccessReviewClient struct { +type subjectAccessReviewV1Client struct { w *webhook.GenericWebhook } -func (t *subjectAccessReviewClient) Create(subjectAccessReview *authorization.SubjectAccessReview) (*authorization.SubjectAccessReview, error) { - return t.CreateContext(context.Background(), subjectAccessReview) -} - -func (t *subjectAccessReviewClient) CreateContext(ctx context.Context, subjectAccessReview *authorization.SubjectAccessReview) (*authorization.SubjectAccessReview, error) { - result := &authorization.SubjectAccessReview{} +func (t *subjectAccessReviewV1Client) CreateContext(ctx context.Context, subjectAccessReview *authorizationv1.SubjectAccessReview) (*authorizationv1.SubjectAccessReview, error) { + result := &authorizationv1.SubjectAccessReview{} err := t.w.RestClient.Post().Context(ctx).Body(subjectAccessReview).Do().Into(result) return result, err } +type subjectAccessReviewV1beta1Client struct { + w *webhook.GenericWebhook +} + +func (t *subjectAccessReviewV1beta1Client) CreateContext(ctx context.Context, subjectAccessReview *authorizationv1.SubjectAccessReview) (*authorizationv1.SubjectAccessReview, error) { + v1beta1Review := &authorizationv1beta1.SubjectAccessReview{Spec: v1SpecToV1beta1Spec(&subjectAccessReview.Spec)} + v1beta1Result := &authorizationv1beta1.SubjectAccessReview{} + err := t.w.RestClient.Post().Context(ctx).Body(v1beta1Review).Do().Into(v1beta1Result) + if err == nil { + subjectAccessReview.Status = v1beta1StatusToV1Status(&v1beta1Result.Status) + } + return subjectAccessReview, err +} + // shouldCache determines whether it is safe to cache the given request attributes. If the // requester-controlled attributes are too large, this may be a DoS attempt, so we skip the cache. func shouldCache(attr authorizer.Attributes) bool { @@ -287,3 +320,59 @@ func shouldCache(attr authorizer.Attributes) bool { int64(len(attr.GetPath())) return controlledAttrSize < maxControlledAttrCacheSize } + +func v1beta1StatusToV1Status(in *authorizationv1beta1.SubjectAccessReviewStatus) authorizationv1.SubjectAccessReviewStatus { + return authorizationv1.SubjectAccessReviewStatus{ + Allowed: in.Allowed, + Denied: in.Denied, + Reason: in.Reason, + EvaluationError: in.EvaluationError, + } +} + +func v1SpecToV1beta1Spec(in *authorizationv1.SubjectAccessReviewSpec) authorizationv1beta1.SubjectAccessReviewSpec { + return authorizationv1beta1.SubjectAccessReviewSpec{ + ResourceAttributes: v1ResourceAttributesToV1beta1ResourceAttributes(in.ResourceAttributes), + NonResourceAttributes: v1NonResourceAttributesToV1beta1NonResourceAttributes(in.NonResourceAttributes), + User: in.User, + Groups: in.Groups, + Extra: v1ExtraToV1beta1Extra(in.Extra), + UID: in.UID, + } +} + +func v1ResourceAttributesToV1beta1ResourceAttributes(in *authorizationv1.ResourceAttributes) *authorizationv1beta1.ResourceAttributes { + if in == nil { + return nil + } + return &authorizationv1beta1.ResourceAttributes{ + Namespace: in.Namespace, + Verb: in.Verb, + Group: in.Group, + Version: in.Version, + Resource: in.Resource, + Subresource: in.Subresource, + Name: in.Name, + } +} + +func v1NonResourceAttributesToV1beta1NonResourceAttributes(in *authorizationv1.NonResourceAttributes) *authorizationv1beta1.NonResourceAttributes { + if in == nil { + return nil + } + return &authorizationv1beta1.NonResourceAttributes{ + Path: in.Path, + Verb: in.Verb, + } +} + +func v1ExtraToV1beta1Extra(in map[string]authorizationv1.ExtraValue) map[string]authorizationv1beta1.ExtraValue { + if in == nil { + return nil + } + ret := make(map[string]authorizationv1beta1.ExtraValue, len(in)) + for k, v := range in { + ret[k] = authorizationv1beta1.ExtraValue(v) + } + return ret +} diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/webhook_test.go b/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/webhook_v1_test.go similarity index 85% rename from staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/webhook_test.go rename to staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/webhook_v1_test.go index fa43dc929b8..4fb870d24a6 100644 --- a/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/webhook_test.go +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/webhook_v1_test.go @@ -34,15 +34,15 @@ import ( "text/template" "time" - "k8s.io/api/authorization/v1beta1" + authorizationv1 "k8s.io/api/authorization/v1" 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" + v1 "k8s.io/client-go/tools/clientcmd/api/v1" ) -func TestNewFromConfig(t *testing.T) { +func TestV1NewFromConfig(t *testing.T) { dir, err := ioutil.TempDir("", "") if err != nil { t.Fatal(err) @@ -186,7 +186,7 @@ current-context: default return fmt.Errorf("failed to execute test template: %v", err) } // Create a new authorizer - sarClient, err := subjectAccessReviewInterfaceFromKubeconfig(p) + sarClient, err := subjectAccessReviewInterfaceFromKubeconfig(p, "v1") if err != nil { return fmt.Errorf("error building sar client: %v", err) } @@ -202,14 +202,14 @@ current-context: default } } -// Service mocks a remote service. -type Service interface { - Review(*v1beta1.SubjectAccessReview) +// V1Service mocks a remote service. +type V1Service interface { + Review(*authorizationv1.SubjectAccessReview) HTTPStatusCode() int } -// NewTestServer wraps a Service as an httptest.Server. -func NewTestServer(s Service, cert, key, caCert []byte) (*httptest.Server, error) { +// NewV1TestServer wraps a V1Service as an httptest.Server. +func NewV1TestServer(s V1Service, cert, key, caCert []byte) (*httptest.Server, error) { const webhookPath = "/testserver" var tlsConfig *tls.Config if cert != nil { @@ -240,7 +240,7 @@ func NewTestServer(s Service, cert, key, caCert []byte) (*httptest.Server, error return } - var review v1beta1.SubjectAccessReview + var review authorizationv1.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) @@ -248,7 +248,7 @@ func NewTestServer(s Service, cert, key, caCert []byte) (*httptest.Server, error } // ensure we received the serialized review as expected - if review.APIVersion != "authorization.k8s.io/v1beta1" { + if review.APIVersion != "authorization.k8s.io/v1" { http.Error(w, fmt.Sprintf("wrong api version: %s", string(bodyData)), http.StatusBadRequest) return } @@ -267,7 +267,7 @@ func NewTestServer(s Service, cert, key, caCert []byte) (*httptest.Server, error APIVersion string `json:"apiVersion"` Status status `json:"status"` }{ - APIVersion: v1beta1.SchemeGroupVersion.String(), + APIVersion: authorizationv1.SchemeGroupVersion.String(), Status: status{review.Status.Allowed, review.Status.Reason, review.Status.EvaluationError}, } w.Header().Set("Content-Type", "application/json") @@ -287,23 +287,23 @@ func NewTestServer(s Service, cert, key, caCert []byte) (*httptest.Server, error } // A service that can be set to allow all or deny all authorization requests. -type mockService struct { +type mockV1Service struct { allow bool statusCode int called int } -func (m *mockService) Review(r *v1beta1.SubjectAccessReview) { +func (m *mockV1Service) Review(r *authorizationv1.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 } +func (m *mockV1Service) Allow() { m.allow = true } +func (m *mockV1Service) Deny() { m.allow = false } +func (m *mockV1Service) HTTPStatusCode() int { return m.statusCode } -// newAuthorizer creates a temporary kubeconfig file from the provided arguments and attempts to load +// newV1Authorizer 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) { +func newV1Authorizer(callbackURL string, clientCert, clientKey, ca []byte, cacheTime time.Duration) (*WebhookAuthorizer, error) { tempfile, err := ioutil.TempFile("", "") if err != nil { return nil, err @@ -325,14 +325,14 @@ func newAuthorizer(callbackURL string, clientCert, clientKey, ca []byte, cacheTi if err := json.NewEncoder(tempfile).Encode(config); err != nil { return nil, err } - sarClient, err := subjectAccessReviewInterfaceFromKubeconfig(p) + sarClient, err := subjectAccessReviewInterfaceFromKubeconfig(p, "v1") if err != nil { return nil, fmt.Errorf("error building sar client: %v", err) } return newWithBackoff(sarClient, cacheTime, cacheTime, 0) } -func TestTLSConfig(t *testing.T) { +func TestV1TLSConfig(t *testing.T) { tests := []struct { test string clientCert, clientKey, clientCA []byte @@ -378,17 +378,17 @@ func TestTLSConfig(t *testing.T) { for _, tt := range tests { // Use a closure so defer statements trigger between loop iterations. func() { - service := new(mockService) + service := new(mockV1Service) service.statusCode = 200 - server, err := NewTestServer(service, tt.serverCert, tt.serverKey, tt.serverCA) + server, err := NewV1TestServer(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) + wh, err := newV1Authorizer(server.URL, tt.clientCert, tt.clientKey, tt.clientCA, 0) if err != nil { t.Errorf("%s: failed to create client: %v", tt.test, err) return @@ -427,62 +427,62 @@ func TestTLSConfig(t *testing.T) { } } -// recorderService records all access review requests. -type recorderService struct { - last v1beta1.SubjectAccessReview +// recorderV1Service records all access review requests. +type recorderV1Service struct { + last authorizationv1.SubjectAccessReview err error } -func (rec *recorderService) Review(r *v1beta1.SubjectAccessReview) { - rec.last = v1beta1.SubjectAccessReview{} +func (rec *recorderV1Service) Review(r *authorizationv1.SubjectAccessReview) { + rec.last = authorizationv1.SubjectAccessReview{} rec.last = *r r.Status.Allowed = true } -func (rec *recorderService) Last() (v1beta1.SubjectAccessReview, error) { +func (rec *recorderV1Service) Last() (authorizationv1.SubjectAccessReview, error) { return rec.last, rec.err } -func (rec *recorderService) HTTPStatusCode() int { return 200 } +func (rec *recorderV1Service) HTTPStatusCode() int { return 200 } -func TestWebhook(t *testing.T) { - serv := new(recorderService) - s, err := NewTestServer(serv, serverCert, serverKey, caCert) +func TestV1Webhook(t *testing.T) { + serv := new(recorderV1Service) + s, err := NewV1TestServer(serv, serverCert, serverKey, caCert) if err != nil { t.Fatal(err) } defer s.Close() - wh, err := newAuthorizer(s.URL, clientCert, clientKey, caCert, 0) + wh, err := newV1Authorizer(s.URL, clientCert, clientKey, caCert, 0) if err != nil { t.Fatal(err) } expTypeMeta := metav1.TypeMeta{ - APIVersion: "authorization.k8s.io/v1beta1", + APIVersion: "authorization.k8s.io/v1", Kind: "SubjectAccessReview", } tests := []struct { attr authorizer.Attributes - want v1beta1.SubjectAccessReview + want authorizationv1.SubjectAccessReview }{ { attr: authorizer.AttributesRecord{User: &user.DefaultInfo{}}, - want: v1beta1.SubjectAccessReview{ + want: authorizationv1.SubjectAccessReview{ TypeMeta: expTypeMeta, - Spec: v1beta1.SubjectAccessReviewSpec{ - NonResourceAttributes: &v1beta1.NonResourceAttributes{}, + Spec: authorizationv1.SubjectAccessReviewSpec{ + NonResourceAttributes: &authorizationv1.NonResourceAttributes{}, }, }, }, { attr: authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "jane"}}, - want: v1beta1.SubjectAccessReview{ + want: authorizationv1.SubjectAccessReview{ TypeMeta: expTypeMeta, - Spec: v1beta1.SubjectAccessReviewSpec{ + Spec: authorizationv1.SubjectAccessReviewSpec{ User: "jane", - NonResourceAttributes: &v1beta1.NonResourceAttributes{}, + NonResourceAttributes: &authorizationv1.NonResourceAttributes{}, }, }, }, @@ -503,13 +503,13 @@ func TestWebhook(t *testing.T) { ResourceRequest: true, Path: "/foo", }, - want: v1beta1.SubjectAccessReview{ + want: authorizationv1.SubjectAccessReview{ TypeMeta: expTypeMeta, - Spec: v1beta1.SubjectAccessReviewSpec{ + Spec: authorizationv1.SubjectAccessReviewSpec{ User: "jane", UID: "1", Groups: []string{"group1", "group2"}, - ResourceAttributes: &v1beta1.ResourceAttributes{ + ResourceAttributes: &authorizationv1.ResourceAttributes{ Verb: "GET", Namespace: "kittensandponies", Group: "group3", @@ -546,16 +546,16 @@ func TestWebhook(t *testing.T) { // 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) +func TestV1WebhookCache(t *testing.T) { + serv := new(mockV1Service) + s, err := NewV1TestServer(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) + wh, err := newV1Authorizer(s.URL, clientCert, clientKey, caCert, 2400*time.Hour) if err != nil { t.Fatal(err) } diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/webhook_v1beta1_test.go b/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/webhook_v1beta1_test.go new file mode 100644 index 00000000000..53841f743a5 --- /dev/null +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authorizer/webhook/webhook_v1beta1_test.go @@ -0,0 +1,647 @@ +/* +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 ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "text/template" + "time" + + authorizationv1beta1 "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" + v1 "k8s.io/client-go/tools/clientcmd/api/v1" +) + +func TestV1beta1NewFromConfig(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, "v1beta1") + 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) + } + } +} + +// V1beta1Service mocks a remote service. +type V1beta1Service interface { + Review(*authorizationv1beta1.SubjectAccessReview) + HTTPStatusCode() int +} + +// NewV1beta1TestServer wraps a V1beta1Service as an httptest.Server. +func NewV1beta1TestServer(s V1beta1Service, 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 authorizationv1beta1.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: authorizationv1beta1.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 mockV1beta1Service struct { + allow bool + statusCode int + called int +} + +func (m *mockV1beta1Service) Review(r *authorizationv1beta1.SubjectAccessReview) { + m.called++ + r.Status.Allowed = m.allow +} +func (m *mockV1beta1Service) Allow() { m.allow = true } +func (m *mockV1beta1Service) Deny() { m.allow = false } +func (m *mockV1beta1Service) HTTPStatusCode() int { return m.statusCode } + +// newV1beta1Authorizer creates a temporary kubeconfig file from the provided arguments and attempts to load +// a new WebhookAuthorizer from it. +func newV1beta1Authorizer(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, "v1beta1") + if err != nil { + return nil, fmt.Errorf("error building sar client: %v", err) + } + return newWithBackoff(sarClient, cacheTime, cacheTime, 0) +} + +func TestV1beta1TLSConfig(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(mockV1beta1Service) + service.statusCode = 200 + + server, err := NewV1beta1TestServer(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 := newV1beta1Authorizer(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(context.Background(), 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(context.Background(), attr); decision == authorizer.DecisionAllow { + t.Errorf("%s: incorrectly authorized with DenyAll policy", tt.test) + } + }() + } +} + +// recorderV1beta1Service records all access review requests. +type recorderV1beta1Service struct { + last authorizationv1beta1.SubjectAccessReview + err error +} + +func (rec *recorderV1beta1Service) Review(r *authorizationv1beta1.SubjectAccessReview) { + rec.last = authorizationv1beta1.SubjectAccessReview{} + rec.last = *r + r.Status.Allowed = true +} + +func (rec *recorderV1beta1Service) Last() (authorizationv1beta1.SubjectAccessReview, error) { + return rec.last, rec.err +} + +func (rec *recorderV1beta1Service) HTTPStatusCode() int { return 200 } + +func TestV1beta1Webhook(t *testing.T) { + serv := new(recorderV1beta1Service) + s, err := NewV1beta1TestServer(serv, serverCert, serverKey, caCert) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + wh, err := newV1beta1Authorizer(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 authorizationv1beta1.SubjectAccessReview + }{ + { + attr: authorizer.AttributesRecord{User: &user.DefaultInfo{}}, + want: authorizationv1beta1.SubjectAccessReview{ + TypeMeta: expTypeMeta, + Spec: authorizationv1beta1.SubjectAccessReviewSpec{ + NonResourceAttributes: &authorizationv1beta1.NonResourceAttributes{}, + }, + }, + }, + { + attr: authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "jane"}}, + want: authorizationv1beta1.SubjectAccessReview{ + TypeMeta: expTypeMeta, + Spec: authorizationv1beta1.SubjectAccessReviewSpec{ + User: "jane", + NonResourceAttributes: &authorizationv1beta1.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: authorizationv1beta1.SubjectAccessReview{ + TypeMeta: expTypeMeta, + Spec: authorizationv1beta1.SubjectAccessReviewSpec{ + User: "jane", + UID: "1", + Groups: []string{"group1", "group2"}, + ResourceAttributes: &authorizationv1beta1.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(context.Background(), 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)) + } + } +} + +// TestWebhookCache verifies that error responses from the server are not +// cached, but successful responses are. +func TestV1beta1WebhookCache(t *testing.T) { + serv := new(mockV1beta1Service) + s, err := NewV1beta1TestServer(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 := newV1beta1Authorizer(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"}} + aliceRidiculousAttr := authorizer.AttributesRecord{ + User: &user.DefaultInfo{Name: "alice"}, + ResourceRequest: true, + Verb: strings.Repeat("v", 2000), + APIGroup: strings.Repeat("g", 2000), + APIVersion: strings.Repeat("a", 2000), + Resource: strings.Repeat("r", 2000), + Name: strings.Repeat("n", 2000), + } + bobRidiculousAttr := authorizer.AttributesRecord{ + User: &user.DefaultInfo{Name: "bob"}, + ResourceRequest: true, + Verb: strings.Repeat("v", 2000), + APIGroup: strings.Repeat("g", 2000), + APIVersion: strings.Repeat("a", 2000), + Resource: strings.Repeat("r", 2000), + Name: strings.Repeat("n", 2000), + } + + type webhookCacheTestCase struct { + name string + + attr authorizer.AttributesRecord + + allow bool + statusCode int + + expectedErr bool + expectedAuthorized bool + expectedCalls int + } + + tests := []webhookCacheTestCase{ + // server error and 429's retry + {name: "server errors retry", attr: aliceAttr, allow: false, statusCode: 500, expectedErr: true, expectedAuthorized: false, expectedCalls: 5}, + {name: "429s retry", attr: aliceAttr, allow: false, statusCode: 429, expectedErr: true, expectedAuthorized: false, expectedCalls: 5}, + // regular errors return errors but do not retry + {name: "404 doesnt retry", attr: aliceAttr, allow: false, statusCode: 404, expectedErr: true, expectedAuthorized: false, expectedCalls: 1}, + {name: "403 doesnt retry", attr: aliceAttr, allow: false, statusCode: 403, expectedErr: true, expectedAuthorized: false, expectedCalls: 1}, + {name: "401 doesnt retry", attr: aliceAttr, allow: false, statusCode: 401, expectedErr: true, expectedAuthorized: false, expectedCalls: 1}, + // successful responses are cached + {name: "alice successful request", attr: aliceAttr, allow: true, statusCode: 200, expectedErr: false, expectedAuthorized: true, expectedCalls: 1}, + // later requests within the cache window don't hit the backend + {name: "alice cached request", attr: aliceAttr, allow: false, statusCode: 500, expectedErr: false, expectedAuthorized: true, expectedCalls: 0}, + + // a request with different attributes doesn't hit the cache + {name: "bob failed request", attr: bobAttr, allow: false, statusCode: 500, expectedErr: true, expectedAuthorized: false, expectedCalls: 5}, + // successful response for other attributes is cached + {name: "bob unauthorized request", attr: bobAttr, allow: false, statusCode: 200, expectedErr: false, expectedAuthorized: false, expectedCalls: 1}, + // later requests within the cache window don't hit the backend + {name: "bob unauthorized cached request", attr: bobAttr, allow: false, statusCode: 500, expectedErr: false, expectedAuthorized: false, expectedCalls: 0}, + // ridiculous unauthorized requests are not cached. + {name: "ridiculous unauthorized request", attr: bobRidiculousAttr, allow: false, statusCode: 200, expectedErr: false, expectedAuthorized: false, expectedCalls: 1}, + // later ridiculous requests within the cache window still hit the backend + {name: "ridiculous unauthorized request again", attr: bobRidiculousAttr, allow: false, statusCode: 200, expectedErr: false, expectedAuthorized: false, expectedCalls: 1}, + // ridiculous authorized requests are not cached. + {name: "ridiculous authorized request", attr: aliceRidiculousAttr, allow: true, statusCode: 200, expectedErr: false, expectedAuthorized: true, expectedCalls: 1}, + // later ridiculous requests within the cache window still hit the backend + {name: "ridiculous authorized request again", attr: aliceRidiculousAttr, allow: true, statusCode: 200, expectedErr: false, expectedAuthorized: true, expectedCalls: 1}, + } + + for i, test := range tests { + t.Run(test.name, func(t *testing.T) { + serv.called = 0 + serv.allow = test.allow + serv.statusCode = test.statusCode + authorized, _, err := wh.Authorize(context.Background(), test.attr) + if test.expectedErr && err == nil { + t.Fatalf("%d: Expected error", i) + } else if !test.expectedErr && err != nil { + t.Fatalf("%d: unexpected error: %v", i, err) + } + + 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) + } + }) + } +}