1460 lines
45 KiB
Go
1460 lines
45 KiB
Go
/*
|
|
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"
|
|
|
|
utiltesting "k8s.io/client-go/util/testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
|
|
authorizationv1 "k8s.io/api/authorization/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/fields"
|
|
"k8s.io/apimachinery/pkg/labels"
|
|
"k8s.io/apimachinery/pkg/selection"
|
|
"k8s.io/apimachinery/pkg/util/wait"
|
|
"k8s.io/apiserver/pkg/apis/apiserver"
|
|
"k8s.io/apiserver/pkg/authentication/user"
|
|
"k8s.io/apiserver/pkg/authorization/authorizer"
|
|
celmetrics "k8s.io/apiserver/pkg/authorization/cel"
|
|
"k8s.io/apiserver/pkg/features"
|
|
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
|
webhookutil "k8s.io/apiserver/pkg/util/webhook"
|
|
"k8s.io/apiserver/plugin/pkg/authorizer/webhook/metrics"
|
|
v1 "k8s.io/client-go/tools/clientcmd/api/v1"
|
|
featuregatetesting "k8s.io/component-base/featuregate/testing"
|
|
"k8s.io/component-base/metrics/legacyregistry"
|
|
"k8s.io/component-base/metrics/testutil"
|
|
)
|
|
|
|
var testRetryBackoff = wait.Backoff{
|
|
Duration: 5 * time.Millisecond,
|
|
Factor: 1.5,
|
|
Jitter: 0.2,
|
|
Steps: 5,
|
|
}
|
|
|
|
func TestV1NewFromConfig(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 utiltesting.CloseAndRemove(t, tempfile)
|
|
|
|
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
|
|
clientConfig, err := webhookutil.LoadKubeconfig(p, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sarClient, err := subjectAccessReviewInterfaceFromConfig(clientConfig, "v1", testRetryBackoff)
|
|
if err != nil {
|
|
return fmt.Errorf("error building sar client: %v", err)
|
|
}
|
|
_, err = newWithBackoff(sarClient, 0, 0, testRetryBackoff, authorizer.DecisionNoOpinion, []apiserver.WebhookMatchCondition{}, noopAuthorizerMetrics(), "")
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// V1Service mocks a remote service.
|
|
type V1Service interface {
|
|
Review(*authorizationv1.SubjectAccessReview)
|
|
HTTPStatusCode() int
|
|
}
|
|
|
|
// 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 {
|
|
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 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)
|
|
return
|
|
}
|
|
|
|
// ensure we received the serialized review as expected
|
|
if review.APIVersion != "authorization.k8s.io/v1" {
|
|
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: authorizationv1.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 mockV1Service struct {
|
|
allow bool
|
|
statusCode int
|
|
called int
|
|
|
|
// reviewHook is called just before returning from the Review() method
|
|
reviewHook func(*authorizationv1.SubjectAccessReview)
|
|
}
|
|
|
|
func (m *mockV1Service) Review(r *authorizationv1.SubjectAccessReview) {
|
|
m.called++
|
|
r.Status.Allowed = m.allow
|
|
|
|
if m.reviewHook != nil {
|
|
m.reviewHook(r)
|
|
}
|
|
}
|
|
func (m *mockV1Service) Allow() { m.allow = true }
|
|
func (m *mockV1Service) Deny() { m.allow = false }
|
|
func (m *mockV1Service) HTTPStatusCode() int { return m.statusCode }
|
|
|
|
// newV1Authorizer creates a temporary kubeconfig file from the provided arguments and attempts to load
|
|
// a new WebhookAuthorizer from it.
|
|
func newV1Authorizer(callbackURL string, clientCert, clientKey, ca []byte, cacheTime time.Duration, metrics metrics.AuthorizerMetrics, expressions []apiserver.WebhookMatchCondition, authzName string) (*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
|
|
}
|
|
clientConfig, err := webhookutil.LoadKubeconfig(p, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sarClient, err := subjectAccessReviewInterfaceFromConfig(clientConfig, "v1", testRetryBackoff)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error building sar client: %v", err)
|
|
}
|
|
return newWithBackoff(sarClient, cacheTime, cacheTime, testRetryBackoff, authorizer.DecisionNoOpinion, expressions, metrics, authzName)
|
|
}
|
|
|
|
func TestV1TLSConfig(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(mockV1Service)
|
|
service.statusCode = 200
|
|
|
|
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 := newV1Authorizer(server.URL, tt.clientCert, tt.clientKey, tt.clientCA, 0, noopAuthorizerMetrics(), []apiserver.WebhookMatchCondition{}, "")
|
|
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)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// recorderV1Service records all access review requests.
|
|
type recorderV1Service struct {
|
|
last authorizationv1.SubjectAccessReview
|
|
err error
|
|
}
|
|
|
|
func (rec *recorderV1Service) Review(r *authorizationv1.SubjectAccessReview) {
|
|
rec.last = authorizationv1.SubjectAccessReview{}
|
|
rec.last = *r
|
|
r.Status.Allowed = true
|
|
}
|
|
|
|
func (rec *recorderV1Service) Last() (authorizationv1.SubjectAccessReview, error) {
|
|
return rec.last, rec.err
|
|
}
|
|
|
|
func (rec *recorderV1Service) HTTPStatusCode() int { return 200 }
|
|
|
|
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 := newV1Authorizer(s.URL, clientCert, clientKey, caCert, 0, noopAuthorizerMetrics(), []apiserver.WebhookMatchCondition{}, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
expTypeMeta := metav1.TypeMeta{
|
|
APIVersion: "authorization.k8s.io/v1",
|
|
Kind: "SubjectAccessReview",
|
|
}
|
|
|
|
tests := []struct {
|
|
attr authorizer.Attributes
|
|
want authorizationv1.SubjectAccessReview
|
|
}{
|
|
{
|
|
attr: authorizer.AttributesRecord{User: &user.DefaultInfo{}},
|
|
want: authorizationv1.SubjectAccessReview{
|
|
TypeMeta: expTypeMeta,
|
|
Spec: authorizationv1.SubjectAccessReviewSpec{
|
|
NonResourceAttributes: &authorizationv1.NonResourceAttributes{},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
attr: authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "jane"}},
|
|
want: authorizationv1.SubjectAccessReview{
|
|
TypeMeta: expTypeMeta,
|
|
Spec: authorizationv1.SubjectAccessReviewSpec{
|
|
User: "jane",
|
|
NonResourceAttributes: &authorizationv1.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: authorizationv1.SubjectAccessReview{
|
|
TypeMeta: expTypeMeta,
|
|
Spec: authorizationv1.SubjectAccessReviewSpec{
|
|
User: "jane",
|
|
UID: "1",
|
|
Groups: []string{"group1", "group2"},
|
|
ResourceAttributes: &authorizationv1.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, cmp.Diff(gotAttr, tt.want))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWebhookCache verifies that error responses from the server are not
|
|
// cached, but successful responses are.
|
|
func TestV1WebhookCache(t *testing.T) {
|
|
serv := new(mockV1Service)
|
|
s, err := NewV1TestServer(serv, serverCert, serverKey, caCert)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthorizationConfiguration, true)
|
|
expressions := []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kittensandponies'",
|
|
},
|
|
}
|
|
// Create an authorizer that caches successful responses "forever" (100 days).
|
|
wh, err := newV1Authorizer(s.URL, clientCert, clientKey, caCert, 2400*time.Hour, noopAuthorizerMetrics(), expressions, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
aliceAttr := authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "alice"}, ResourceRequest: true, Namespace: "kittensandponies"}
|
|
bobAttr := authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "bob"}, ResourceRequest: true, Namespace: "kittensandponies"}
|
|
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),
|
|
Namespace: "kittensandponies",
|
|
}
|
|
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),
|
|
Namespace: "kittensandponies",
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStructuredAuthzConfigFeatureEnablement verifies cel expressions can only be used when feature is enabled
|
|
func TestStructuredAuthzConfigFeatureEnablement(t *testing.T) {
|
|
|
|
service := new(mockV1Service)
|
|
service.statusCode = 200
|
|
service.Allow()
|
|
s, err := NewV1TestServer(service, serverCert, serverKey, caCert)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
|
|
labelRequirement, _ := labels.NewRequirement("baz", selection.Equals, []string{"qux"})
|
|
|
|
type webhookMatchConditionsTestCase struct {
|
|
name string
|
|
attr authorizer.AttributesRecord
|
|
allow bool
|
|
expectedCompileErr bool
|
|
expectedEvalErr bool
|
|
expectedDecision authorizer.Decision
|
|
expressions []apiserver.WebhookMatchCondition
|
|
featureEnabled bool
|
|
selectorEnabled bool
|
|
}
|
|
aliceAttr := authorizer.AttributesRecord{
|
|
User: &user.DefaultInfo{
|
|
Name: "alice",
|
|
UID: "1",
|
|
Groups: []string{"group1", "group2"},
|
|
Extra: map[string][]string{"key1": {"a", "b", "c"}},
|
|
},
|
|
ResourceRequest: true,
|
|
Namespace: "kittensandponies",
|
|
Verb: "get",
|
|
}
|
|
aliceWithSelectorsAttr := authorizer.AttributesRecord{
|
|
User: &user.DefaultInfo{
|
|
Name: "alice",
|
|
UID: "1",
|
|
Groups: []string{"group1", "group2"},
|
|
Extra: map[string][]string{"key1": {"a", "b", "c"}},
|
|
},
|
|
ResourceRequest: true,
|
|
Namespace: "kittensandponies",
|
|
Verb: "get",
|
|
FieldSelectorRequirements: fields.Requirements{fields.Requirement{Field: "foo", Operator: selection.Equals, Value: "bar"}},
|
|
LabelSelectorRequirements: labels.Requirements{*labelRequirement},
|
|
}
|
|
tests := []webhookMatchConditionsTestCase{
|
|
{
|
|
name: "no match condition does not require feature enablement",
|
|
attr: aliceAttr,
|
|
allow: true,
|
|
expectedCompileErr: false,
|
|
expectedDecision: authorizer.DecisionAllow,
|
|
expressions: []apiserver.WebhookMatchCondition{},
|
|
featureEnabled: false,
|
|
},
|
|
{
|
|
name: "should fail when match conditions are used without feature enabled",
|
|
attr: aliceAttr,
|
|
allow: false,
|
|
expectedCompileErr: true,
|
|
expectedDecision: authorizer.DecisionNoOpinion,
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
},
|
|
featureEnabled: false,
|
|
},
|
|
{
|
|
name: "feature enabled, match all against all expressions",
|
|
attr: aliceWithSelectorsAttr,
|
|
allow: true,
|
|
expectedCompileErr: false,
|
|
expectedDecision: authorizer.DecisionAllow,
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
{
|
|
Expression: "request.uid == '1'",
|
|
},
|
|
{
|
|
Expression: "('group1' in request.groups)",
|
|
},
|
|
{
|
|
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kittensandponies'",
|
|
},
|
|
{
|
|
Expression: "request.?resourceAttributes.fieldSelector.requirements.orValue([]).exists(r, r.key=='foo' && r.operator=='In' && ('bar' in r.values))",
|
|
},
|
|
{
|
|
Expression: "request.?resourceAttributes.labelSelector.requirements.orValue([]).exists(r, r.key=='baz' && r.operator=='In' && ('qux' in r.values))",
|
|
},
|
|
{
|
|
Expression: "request.resourceAttributes.?labelSelector.requirements.orValue([]).exists(r, r.key=='baz' && r.operator=='In' && ('qux' in r.values))",
|
|
},
|
|
{
|
|
Expression: "request.resourceAttributes.labelSelector.?requirements.orValue([]).exists(r, r.key=='baz' && r.operator=='In' && ('qux' in r.values))",
|
|
},
|
|
},
|
|
featureEnabled: true,
|
|
selectorEnabled: true,
|
|
},
|
|
}
|
|
|
|
for i, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthorizationConfiguration, test.featureEnabled)
|
|
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.AuthorizeWithSelectors, test.selectorEnabled)
|
|
wh, err := newV1Authorizer(s.URL, clientCert, clientKey, caCert, 0, noopAuthorizerMetrics(), test.expressions, "")
|
|
if test.expectedCompileErr && err == nil {
|
|
t.Fatalf("%d: Expected compile error", i)
|
|
} else if !test.expectedCompileErr && err != nil {
|
|
t.Fatalf("%d: unexpected error when creating a new WebhookAuthorizer: %v", i, err)
|
|
}
|
|
if err == nil {
|
|
authorized, _, err := wh.Authorize(context.Background(), test.attr)
|
|
if test.expectedEvalErr && err == nil {
|
|
t.Fatalf("%d: Expected eval error", i)
|
|
} else if !test.expectedEvalErr && err != nil {
|
|
t.Fatalf("%d: unexpected error when authorizing: %v", i, err)
|
|
}
|
|
|
|
if test.expectedDecision != authorized {
|
|
t.Errorf("%d: expected authorized=%v, got %v", i, test.expectedDecision, authorized)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebhookMetrics(t *testing.T) {
|
|
service := new(mockV1Service)
|
|
service.statusCode = 200
|
|
service.Allow()
|
|
s, err := NewV1TestServer(service, serverCert, serverKey, caCert)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthorizationConfiguration, true)
|
|
|
|
aliceAttr := authorizer.AttributesRecord{
|
|
User: &user.DefaultInfo{
|
|
Name: "alice",
|
|
UID: "1",
|
|
},
|
|
}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
attr authorizer.AttributesRecord
|
|
expressions1 []apiserver.WebhookMatchCondition
|
|
expressions2 []apiserver.WebhookMatchCondition
|
|
metrics []string
|
|
want string
|
|
}{
|
|
{
|
|
name: "should have one evaluation error from multiple failed match conditions",
|
|
attr: aliceAttr,
|
|
expressions1: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
{
|
|
Expression: "request.resourceAttributes.verb == 'get'",
|
|
},
|
|
{
|
|
Expression: "request.resourceAttributes.namespace == 'kittensandponies'",
|
|
},
|
|
},
|
|
expressions2: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
},
|
|
metrics: []string{
|
|
"apiserver_authorization_match_condition_evaluation_errors_total",
|
|
},
|
|
want: fmt.Sprintf(`
|
|
# HELP apiserver_authorization_match_condition_evaluation_errors_total [ALPHA] Total number of errors when an authorization webhook encounters a match condition error split by authorizer type and name.
|
|
# TYPE apiserver_authorization_match_condition_evaluation_errors_total counter
|
|
apiserver_authorization_match_condition_evaluation_errors_total{name="%s",type="%s"} 1
|
|
`, "wh1.example.com", "Webhook"),
|
|
},
|
|
{
|
|
name: "should have two webhook exclusions due to match condition",
|
|
attr: aliceAttr,
|
|
expressions1: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice2'",
|
|
},
|
|
{
|
|
Expression: "request.uid == '1'",
|
|
},
|
|
},
|
|
expressions2: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice1'",
|
|
},
|
|
},
|
|
metrics: []string{
|
|
"apiserver_authorization_match_condition_exclusions_total",
|
|
},
|
|
want: fmt.Sprintf(`
|
|
# HELP apiserver_authorization_match_condition_exclusions_total [ALPHA] Total number of exclusions when an authorization webhook is skipped because match conditions exclude it.
|
|
# TYPE apiserver_authorization_match_condition_exclusions_total counter
|
|
apiserver_authorization_match_condition_exclusions_total{name="%s",type="%s"} 1
|
|
apiserver_authorization_match_condition_exclusions_total{name="%s",type="%s"} 1
|
|
`, "wh1.example.com", "Webhook", "wh2.example.com", "Webhook"),
|
|
},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
celmetrics.ResetMetricsForTest()
|
|
defer celmetrics.ResetMetricsForTest()
|
|
wh1, err := newV1Authorizer(s.URL, clientCert, clientKey, caCert, 0, celAuthorizerMetrics(), tt.expressions1, "wh1.example.com")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wh2, err := newV1Authorizer(s.URL, clientCert, clientKey, caCert, 0, celAuthorizerMetrics(), tt.expressions2, "wh2.example.com")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err == nil {
|
|
_, _, _ = wh1.Authorize(context.Background(), tt.attr)
|
|
_, _, _ = wh2.Authorize(context.Background(), tt.attr)
|
|
}
|
|
|
|
if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(tt.want), tt.metrics...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func BenchmarkNoCELExpressionFeatureOff(b *testing.B) {
|
|
expressions := []apiserver.WebhookMatchCondition{}
|
|
b.Run("compile", func(b *testing.B) {
|
|
benchmarkNewWebhookAuthorizer(b, expressions, false)
|
|
})
|
|
b.Run("authorize", func(b *testing.B) {
|
|
benchmarkWebhookAuthorize(b, expressions, false)
|
|
})
|
|
}
|
|
|
|
func BenchmarkNoCELExpressionFeatureOn(b *testing.B) {
|
|
expressions := []apiserver.WebhookMatchCondition{}
|
|
b.Run("compile", func(b *testing.B) {
|
|
benchmarkNewWebhookAuthorizer(b, expressions, true)
|
|
})
|
|
b.Run("authorize", func(b *testing.B) {
|
|
benchmarkWebhookAuthorize(b, expressions, true)
|
|
})
|
|
}
|
|
func BenchmarkWithOneCELExpressions(b *testing.B) {
|
|
expressions := []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
}
|
|
b.Run("compile", func(b *testing.B) {
|
|
benchmarkNewWebhookAuthorizer(b, expressions, true)
|
|
})
|
|
b.Run("authorize", func(b *testing.B) {
|
|
benchmarkWebhookAuthorize(b, expressions, true)
|
|
})
|
|
}
|
|
func BenchmarkWithOneCELExpressionsFalse(b *testing.B) {
|
|
expressions := []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice2'",
|
|
},
|
|
}
|
|
b.Run("compile", func(b *testing.B) {
|
|
benchmarkNewWebhookAuthorizer(b, expressions, true)
|
|
})
|
|
b.Run("authorize", func(b *testing.B) {
|
|
benchmarkWebhookAuthorize(b, expressions, true)
|
|
})
|
|
}
|
|
func BenchmarkWithTwoCELExpressions(b *testing.B) {
|
|
expressions := []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
{
|
|
Expression: "request.uid == '1'",
|
|
},
|
|
}
|
|
b.Run("compile", func(b *testing.B) {
|
|
benchmarkNewWebhookAuthorizer(b, expressions, true)
|
|
})
|
|
b.Run("authorize", func(b *testing.B) {
|
|
benchmarkWebhookAuthorize(b, expressions, true)
|
|
})
|
|
}
|
|
func BenchmarkWithTwoCELExpressionsFalse(b *testing.B) {
|
|
expressions := []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
{
|
|
Expression: "request.uid == '2'",
|
|
},
|
|
}
|
|
b.Run("compile", func(b *testing.B) {
|
|
benchmarkNewWebhookAuthorizer(b, expressions, true)
|
|
})
|
|
b.Run("authorize", func(b *testing.B) {
|
|
benchmarkWebhookAuthorize(b, expressions, true)
|
|
})
|
|
}
|
|
func BenchmarkWithManyCELExpressions(b *testing.B) {
|
|
expressions := []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
{
|
|
Expression: "request.uid == '1'",
|
|
},
|
|
{
|
|
Expression: "('group1' in request.groups)",
|
|
},
|
|
{
|
|
Expression: "('key1' in request.extra)",
|
|
},
|
|
{
|
|
Expression: "!('key2' in request.extra)",
|
|
},
|
|
{
|
|
Expression: "('a' in request.extra['key1'])",
|
|
},
|
|
{
|
|
Expression: "!('z' in request.extra['key1'])",
|
|
},
|
|
{
|
|
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kittensandponies'",
|
|
},
|
|
}
|
|
b.Run("compile", func(b *testing.B) {
|
|
benchmarkNewWebhookAuthorizer(b, expressions, true)
|
|
})
|
|
b.Run("authorize", func(b *testing.B) {
|
|
benchmarkWebhookAuthorize(b, expressions, true)
|
|
})
|
|
}
|
|
func BenchmarkWithManyCELExpressionsFalse(b *testing.B) {
|
|
expressions := []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
{
|
|
Expression: "request.uid == '1'",
|
|
},
|
|
{
|
|
Expression: "('group1' in request.groups)",
|
|
},
|
|
{
|
|
Expression: "('key1' in request.extra)",
|
|
},
|
|
{
|
|
Expression: "!('key2' in request.extra)",
|
|
},
|
|
{
|
|
Expression: "('a' in request.extra['key1'])",
|
|
},
|
|
{
|
|
Expression: "!('z' in request.extra['key1'])",
|
|
},
|
|
{
|
|
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kittensandponies1'",
|
|
},
|
|
}
|
|
b.Run("compile", func(b *testing.B) {
|
|
benchmarkNewWebhookAuthorizer(b, expressions, true)
|
|
})
|
|
b.Run("authorize", func(b *testing.B) {
|
|
benchmarkWebhookAuthorize(b, expressions, true)
|
|
})
|
|
}
|
|
|
|
func benchmarkNewWebhookAuthorizer(b *testing.B, expressions []apiserver.WebhookMatchCondition, featureEnabled bool) {
|
|
service := new(mockV1Service)
|
|
service.statusCode = 200
|
|
service.Allow()
|
|
s, err := NewV1TestServer(service, serverCert, serverKey, caCert)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
featuregatetesting.SetFeatureGateDuringTest(b, utilfeature.DefaultFeatureGate, features.StructuredAuthorizationConfiguration, featureEnabled)
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
// Create an authorizer with or without expressions to compile
|
|
_, err := newV1Authorizer(s.URL, clientCert, clientKey, caCert, 0, noopAuthorizerMetrics(), expressions, "")
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
b.StopTimer()
|
|
}
|
|
|
|
func benchmarkWebhookAuthorize(b *testing.B, expressions []apiserver.WebhookMatchCondition, featureEnabled bool) {
|
|
attr := authorizer.AttributesRecord{
|
|
User: &user.DefaultInfo{
|
|
Name: "alice",
|
|
UID: "1",
|
|
Groups: []string{"group1", "group2"},
|
|
Extra: map[string][]string{"key1": {"a", "b", "c"}},
|
|
},
|
|
ResourceRequest: true,
|
|
Namespace: "kittensandponies",
|
|
Verb: "get",
|
|
}
|
|
service := new(mockV1Service)
|
|
service.statusCode = 200
|
|
service.Allow()
|
|
s, err := NewV1TestServer(service, serverCert, serverKey, caCert)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
featuregatetesting.SetFeatureGateDuringTest(b, utilfeature.DefaultFeatureGate, features.StructuredAuthorizationConfiguration, featureEnabled)
|
|
// Create an authorizer with or without expressions to compile
|
|
wh, err := newV1Authorizer(s.URL, clientCert, clientKey, caCert, 0, noopAuthorizerMetrics(), expressions, "")
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
// Call authorize may or may not require cel evaluations
|
|
_, _, err = wh.Authorize(context.Background(), attr)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
b.StopTimer()
|
|
}
|
|
|
|
// TestV1WebhookMatchConditions verifies cel expressions are compiled and evaluated correctly
|
|
func TestV1WebhookMatchConditions(t *testing.T) {
|
|
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthorizationConfiguration, true)
|
|
service := new(mockV1Service)
|
|
service.statusCode = 200
|
|
service.Allow()
|
|
s, err := NewV1TestServer(service, serverCert, serverKey, caCert)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
|
|
aliceAttr := authorizer.AttributesRecord{
|
|
User: &user.DefaultInfo{
|
|
Name: "alice",
|
|
UID: "1",
|
|
Groups: []string{"group1", "group2"},
|
|
Extra: map[string][]string{"key1": {"a", "b", "c"}},
|
|
},
|
|
ResourceRequest: true,
|
|
Namespace: "kittensandponies",
|
|
Verb: "get",
|
|
}
|
|
bobAttr := authorizer.AttributesRecord{
|
|
User: &user.DefaultInfo{
|
|
Name: "bob",
|
|
},
|
|
ResourceRequest: false,
|
|
Namespace: "kittensandponies",
|
|
Verb: "get",
|
|
}
|
|
alice2Attr := authorizer.AttributesRecord{
|
|
User: &user.DefaultInfo{
|
|
Name: "alice2",
|
|
},
|
|
}
|
|
type webhookMatchConditionsTestCase struct {
|
|
name string
|
|
attr authorizer.AttributesRecord
|
|
expectedCompileErr string
|
|
expectedEvalErr string
|
|
expectedDecision authorizer.Decision
|
|
expressions []apiserver.WebhookMatchCondition
|
|
}
|
|
|
|
tests := []webhookMatchConditionsTestCase{
|
|
{
|
|
name: "match all with no expressions",
|
|
attr: aliceAttr,
|
|
expectedCompileErr: "",
|
|
expectedDecision: authorizer.DecisionAllow,
|
|
expressions: []apiserver.WebhookMatchCondition{},
|
|
},
|
|
{
|
|
name: "match all against all expressions",
|
|
attr: aliceAttr,
|
|
expectedCompileErr: "",
|
|
expectedDecision: authorizer.DecisionAllow,
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
{
|
|
Expression: "request.uid == '1'",
|
|
},
|
|
{
|
|
Expression: "('group1' in request.groups)",
|
|
},
|
|
{
|
|
Expression: "('key1' in request.extra)",
|
|
},
|
|
{
|
|
Expression: "!('key2' in request.extra)",
|
|
},
|
|
{
|
|
Expression: "('a' in request.extra['key1'])",
|
|
},
|
|
{
|
|
Expression: "!('z' in request.extra['key1'])",
|
|
},
|
|
{
|
|
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kittensandponies'",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "match all except group, eval to one successful false, no error",
|
|
attr: aliceAttr,
|
|
expectedCompileErr: "",
|
|
expectedDecision: authorizer.DecisionNoOpinion,
|
|
expectedEvalErr: "",
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
{
|
|
Expression: "request.uid == '1'",
|
|
},
|
|
{
|
|
Expression: "('group3' in request.groups)",
|
|
},
|
|
{
|
|
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kittensandponies'",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "match condition with one compilation error",
|
|
attr: aliceAttr,
|
|
expectedCompileErr: "matchConditions[2].expression: Invalid value: \"('group3' in request.group)\": compilation failed: ERROR: <input>:1:21: undefined field 'group'\n | ('group3' in request.group)\n | ....................^",
|
|
expectedDecision: authorizer.DecisionNoOpinion,
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
{
|
|
Expression: "request.uid == '1'",
|
|
},
|
|
{
|
|
Expression: "('group3' in request.group)",
|
|
},
|
|
{
|
|
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kittensandponies'",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "match all except uid",
|
|
attr: aliceAttr,
|
|
expectedCompileErr: "",
|
|
expectedDecision: authorizer.DecisionNoOpinion,
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
{
|
|
Expression: "request.uid == '2'",
|
|
},
|
|
{
|
|
Expression: "('group1' in request.groups)",
|
|
},
|
|
{
|
|
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kittensandponies'",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "match on user name but not namespace",
|
|
attr: aliceAttr,
|
|
expectedCompileErr: "",
|
|
expectedDecision: authorizer.DecisionNoOpinion,
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
{
|
|
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kube-system'",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "mismatch on user name",
|
|
attr: bobAttr,
|
|
expectedCompileErr: "",
|
|
expectedDecision: authorizer.DecisionNoOpinion,
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice'",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "match on user name but not resourceAttributes",
|
|
attr: bobAttr,
|
|
expectedCompileErr: "",
|
|
expectedDecision: authorizer.DecisionNoOpinion,
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'bob'",
|
|
},
|
|
{
|
|
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kittensandponies'",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "expression failed to compile due to wrong return type",
|
|
attr: bobAttr,
|
|
expectedCompileErr: `matchConditions[0].expression: Invalid value: "request.user": must evaluate to bool but got string`,
|
|
expectedDecision: authorizer.DecisionNoOpinion,
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "eval failed due to errors, no successful fail",
|
|
attr: alice2Attr,
|
|
expectedCompileErr: "",
|
|
expectedEvalErr: "cel evaluation error: expression 'request.resourceAttributes.namespace == 'kittensandponies'' resulted in error: no such key: resourceAttributes",
|
|
expectedDecision: authorizer.DecisionNoOpinion,
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'alice2'",
|
|
},
|
|
{
|
|
Expression: "request.resourceAttributes.namespace == 'kittensandponies'",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "at least one matchCondition successfully evaluates to FALSE, error ignored",
|
|
attr: alice2Attr,
|
|
expectedCompileErr: "",
|
|
expectedEvalErr: "",
|
|
expectedDecision: authorizer.DecisionNoOpinion,
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user != 'alice2'",
|
|
},
|
|
{
|
|
Expression: "request.resourceAttributes.namespace == 'kittensandponies'",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "match on user name but failed to compile due to type check in nonResourceAttributes",
|
|
attr: bobAttr,
|
|
expectedCompileErr: "matchConditions[1].expression: Invalid value: \"request.nonResourceAttributes.verb == 2\": compilation failed: ERROR: <input>:1:36: found no matching overload for '_==_' applied to '(string, int)'\n | request.nonResourceAttributes.verb == 2\n | ...................................^",
|
|
expectedDecision: authorizer.DecisionNoOpinion,
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'bob'",
|
|
},
|
|
{
|
|
Expression: "request.nonResourceAttributes.verb == 2",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "match on user name and nonresourceAttributes",
|
|
attr: bobAttr,
|
|
expectedCompileErr: "",
|
|
expectedDecision: authorizer.DecisionAllow,
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.user == 'bob'",
|
|
},
|
|
{
|
|
Expression: "has(request.nonResourceAttributes) && request.nonResourceAttributes.verb == 'get'",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "match eval failed with bad SubjectAccessReviewSpec",
|
|
attr: authorizer.AttributesRecord{},
|
|
expectedCompileErr: "",
|
|
// default decisionOnError in newWithBackoff to skip
|
|
expectedDecision: authorizer.DecisionNoOpinion,
|
|
expectedEvalErr: "cel evaluation error: expression 'request.resourceAttributes.verb == 'get'' resulted in error: no such key: resourceAttributes",
|
|
expressions: []apiserver.WebhookMatchCondition{
|
|
{
|
|
Expression: "request.resourceAttributes.verb == 'get'",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for i, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
wh, err := newV1Authorizer(s.URL, clientCert, clientKey, caCert, 0, noopAuthorizerMetrics(), test.expressions, "")
|
|
if len(test.expectedCompileErr) > 0 && err == nil {
|
|
t.Fatalf("%d: Expected compile error", i)
|
|
} else if len(test.expectedCompileErr) == 0 && err != nil {
|
|
t.Fatalf("%d: unexpected error when creating a new WebhookAuthorizer: %v", i, err)
|
|
}
|
|
if err != nil {
|
|
if d := cmp.Diff(test.expectedCompileErr, err.Error()); d != "" {
|
|
t.Fatalf("newV1Authorizer mismatch (-want +got):\n%s", d)
|
|
}
|
|
}
|
|
if err == nil {
|
|
authorized, _, err := wh.Authorize(context.Background(), test.attr)
|
|
if len(test.expectedEvalErr) > 0 && err == nil {
|
|
t.Fatalf("%d: Expected eval error", i)
|
|
} else if len(test.expectedEvalErr) == 0 && err != nil {
|
|
t.Fatalf("%d: unexpected error when authorizing: %v", i, err)
|
|
}
|
|
|
|
if err != nil {
|
|
if d := cmp.Diff(test.expectedEvalErr, err.Error()); d != "" {
|
|
t.Fatalf("Authorize mismatch (-want +got):\n%s", d)
|
|
}
|
|
}
|
|
|
|
if test.expectedDecision != authorized {
|
|
t.Errorf("%d: expected authorized=%v, got %v", i, test.expectedDecision, authorized)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func noopAuthorizerMetrics() metrics.AuthorizerMetrics {
|
|
return metrics.NoopAuthorizerMetrics{}
|
|
}
|
|
|
|
func celAuthorizerMetrics() metrics.AuthorizerMetrics {
|
|
return celAuthorizerMetricsType{
|
|
MatcherMetrics: celmetrics.NewMatcherMetrics(),
|
|
}
|
|
}
|
|
|
|
type celAuthorizerMetricsType struct {
|
|
metrics.NoopRequestMetrics
|
|
metrics.NoopWebhookMetrics
|
|
celmetrics.MatcherMetrics
|
|
}
|