apiserver/pkg/apis/apiserver/validation/validation_test.go

1322 lines
45 KiB
Go

/*
Copyright 2023 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 validation
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/pem"
"os"
"testing"
"time"
"github.com/google/go-cmp/cmp"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/errors"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation/field"
api "k8s.io/apiserver/pkg/apis/apiserver"
"k8s.io/apiserver/pkg/features"
utilfeature "k8s.io/apiserver/pkg/util/feature"
certutil "k8s.io/client-go/util/cert"
featuregatetesting "k8s.io/component-base/featuregate/testing"
"k8s.io/utils/pointer"
)
func TestValidateAuthenticationConfiguration(t *testing.T) {
testCases := []struct {
name string
in *api.AuthenticationConfiguration
want string
}{
{
name: "jwt authenticator is empty",
in: &api.AuthenticationConfiguration{},
want: "jwt: Required value: at least one jwt is required",
},
{
name: ">1 jwt authenticator",
in: &api.AuthenticationConfiguration{
JWT: []api.JWTAuthenticator{
{Issuer: api.Issuer{URL: "https://issuer-url", Audiences: []string{"audience"}}},
{Issuer: api.Issuer{URL: "https://issuer-url", Audiences: []string{"audience"}}},
},
},
want: "jwt: Too many: 2: must have at most 1 items",
},
{
name: "failed issuer validation",
in: &api.AuthenticationConfiguration{
JWT: []api.JWTAuthenticator{
{
Issuer: api.Issuer{
URL: "invalid-url",
Audiences: []string{"audience"},
},
ClaimMappings: api.ClaimMappings{
Username: api.PrefixedClaimOrExpression{
Claim: "claim",
Prefix: pointer.String("prefix"),
},
},
},
},
},
want: `jwt[0].issuer.url: Invalid value: "invalid-url": URL scheme must be https`,
},
{
name: "failed claimValidationRule validation",
in: &api.AuthenticationConfiguration{
JWT: []api.JWTAuthenticator{
{
Issuer: api.Issuer{
URL: "https://issuer-url",
Audiences: []string{"audience"},
},
ClaimValidationRules: []api.ClaimValidationRule{
{
Claim: "foo",
RequiredValue: "bar",
},
{
Claim: "foo",
RequiredValue: "baz",
},
},
ClaimMappings: api.ClaimMappings{
Username: api.PrefixedClaimOrExpression{
Claim: "claim",
Prefix: pointer.String("prefix"),
},
},
},
},
},
want: `jwt[0].claimValidationRules[1].claim: Duplicate value: "foo"`,
},
{
name: "failed claimMapping validation",
in: &api.AuthenticationConfiguration{
JWT: []api.JWTAuthenticator{
{
Issuer: api.Issuer{
URL: "https://issuer-url",
Audiences: []string{"audience"},
},
ClaimValidationRules: []api.ClaimValidationRule{
{
Claim: "foo",
RequiredValue: "bar",
},
},
ClaimMappings: api.ClaimMappings{
Username: api.PrefixedClaimOrExpression{
Prefix: pointer.String("prefix"),
},
},
},
},
},
want: "jwt[0].claimMappings.username.claim: Required value: claim name is required",
},
{
name: "valid authentication configuration",
in: &api.AuthenticationConfiguration{
JWT: []api.JWTAuthenticator{
{
Issuer: api.Issuer{
URL: "https://issuer-url",
Audiences: []string{"audience"},
},
ClaimValidationRules: []api.ClaimValidationRule{
{
Claim: "foo",
RequiredValue: "bar",
},
},
ClaimMappings: api.ClaimMappings{
Username: api.PrefixedClaimOrExpression{
Claim: "sub",
Prefix: pointer.String("prefix"),
},
},
},
},
},
want: "",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := ValidateAuthenticationConfiguration(tt.in).ToAggregate()
if d := cmp.Diff(tt.want, errString(got)); d != "" {
t.Fatalf("AuthenticationConfiguration validation mismatch (-want +got):\n%s", d)
}
})
}
}
func TestValidateURL(t *testing.T) {
fldPath := field.NewPath("issuer", "url")
testCases := []struct {
name string
in string
want string
}{
{
name: "url is empty",
in: "",
want: "issuer.url: Required value: URL is required",
},
{
name: "url parse error",
in: "https://issuer-url:invalid-port",
want: `issuer.url: Invalid value: "https://issuer-url:invalid-port": parse "https://issuer-url:invalid-port": invalid port ":invalid-port" after host`,
},
{
name: "url is not https",
in: "http://issuer-url",
want: `issuer.url: Invalid value: "http://issuer-url": URL scheme must be https`,
},
{
name: "url user info is not allowed",
in: "https://user:pass@issuer-url",
want: `issuer.url: Invalid value: "https://user:pass@issuer-url": URL must not contain a username or password`,
},
{
name: "url raw query is not allowed",
in: "https://issuer-url?query",
want: `issuer.url: Invalid value: "https://issuer-url?query": URL must not contain a query`,
},
{
name: "url fragment is not allowed",
in: "https://issuer-url#fragment",
want: `issuer.url: Invalid value: "https://issuer-url#fragment": URL must not contain a fragment`,
},
{
name: "valid url",
in: "https://issuer-url",
want: "",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := validateURL(tt.in, fldPath).ToAggregate()
if d := cmp.Diff(tt.want, errString(got)); d != "" {
t.Fatalf("URL validation mismatch (-want +got):\n%s", d)
}
})
}
}
func TestValidateAudiences(t *testing.T) {
fldPath := field.NewPath("issuer", "audiences")
testCases := []struct {
name string
in []string
want string
}{
{
name: "audiences is empty",
in: []string{},
want: "issuer.audiences: Required value: at least one issuer.audiences is required",
},
{
name: "at most one audiences is allowed",
in: []string{"audience1", "audience2"},
want: "issuer.audiences: Too many: 2: must have at most 1 items",
},
{
name: "audience is empty",
in: []string{""},
want: "issuer.audiences[0]: Required value: audience can't be empty",
},
{
name: "valid audience",
in: []string{"audience"},
want: "",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := validateAudiences(tt.in, fldPath).ToAggregate()
if d := cmp.Diff(tt.want, errString(got)); d != "" {
t.Fatalf("Audiences validation mismatch (-want +got):\n%s", d)
}
})
}
}
func TestValidateCertificateAuthority(t *testing.T) {
fldPath := field.NewPath("issuer", "certificateAuthority")
testCases := []struct {
name string
in func() string
want string
}{
{
name: "invalid certificate authority",
in: func() string { return "invalid" },
want: `issuer.certificateAuthority: Invalid value: "<omitted>": data does not contain any valid RSA or ECDSA certificates`,
},
{
name: "certificate authority is empty",
in: func() string { return "" },
want: "",
},
{
name: "valid certificate authority",
in: func() string {
caPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
caCert, err := certutil.NewSelfSignedCACert(certutil.Config{CommonName: "test-ca"}, caPrivateKey)
if err != nil {
t.Fatal(err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}))
},
want: "",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := validateCertificateAuthority(tt.in(), fldPath).ToAggregate()
if d := cmp.Diff(tt.want, errString(got)); d != "" {
t.Fatalf("CertificateAuthority validation mismatch (-want +got):\n%s", d)
}
})
}
}
func TestClaimValidationRules(t *testing.T) {
fldPath := field.NewPath("issuer", "claimValidationRules")
testCases := []struct {
name string
in []api.ClaimValidationRule
want string
}{
{
name: "claim validation rule claim is empty",
in: []api.ClaimValidationRule{{Claim: ""}},
want: "issuer.claimValidationRules[0].claim: Required value: claim name is required",
},
{
name: "duplicate claim",
in: []api.ClaimValidationRule{{
Claim: "claim", RequiredValue: "value1"},
{Claim: "claim", RequiredValue: "value2"},
},
want: `issuer.claimValidationRules[1].claim: Duplicate value: "claim"`,
},
{
name: "valid claim validation rule",
in: []api.ClaimValidationRule{{Claim: "claim", RequiredValue: "value"}},
want: "",
},
{
name: "valid claim validation rule with multiple rules",
in: []api.ClaimValidationRule{
{Claim: "claim1", RequiredValue: "value1"},
{Claim: "claim2", RequiredValue: "value2"},
},
want: "",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := validateClaimValidationRules(tt.in, fldPath).ToAggregate()
if d := cmp.Diff(tt.want, errString(got)); d != "" {
t.Fatalf("ClaimValidationRules validation mismatch (-want +got):\n%s", d)
}
})
}
}
func TestValidateClaimMappings(t *testing.T) {
fldPath := field.NewPath("issuer", "claimMappings")
testCases := []struct {
name string
in api.ClaimMappings
want string
}{
{
name: "username claim is empty",
in: api.ClaimMappings{Username: api.PrefixedClaimOrExpression{Claim: "", Prefix: pointer.String("prefix")}},
want: "issuer.claimMappings.username.claim: Required value: claim name is required",
},
{
name: "username prefix is empty",
in: api.ClaimMappings{Username: api.PrefixedClaimOrExpression{Claim: "claim"}},
want: "issuer.claimMappings.username.prefix: Required value: prefix is required",
},
{
name: "groups prefix is empty",
in: api.ClaimMappings{
Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")},
Groups: api.PrefixedClaimOrExpression{Claim: "claim"},
},
want: "issuer.claimMappings.groups.prefix: Required value: prefix is required when claim is set",
},
{
name: "groups prefix set but claim is empty",
in: api.ClaimMappings{
Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")},
Groups: api.PrefixedClaimOrExpression{Prefix: pointer.String("prefix")},
},
want: "issuer.claimMappings.groups.claim: Required value: non-empty claim name is required when prefix is set",
},
{
name: "valid claim mappings",
in: api.ClaimMappings{
Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")},
Groups: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")},
},
want: "",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := validateClaimMappings(tt.in, fldPath).ToAggregate()
if d := cmp.Diff(tt.want, errString(got)); d != "" {
t.Fatalf("ClaimMappings validation mismatch (-want +got):\n%s", d)
}
})
}
}
func errString(errs errors.Aggregate) string {
if errs != nil {
return errs.Error()
}
return ""
}
type (
test struct {
name string
configuration api.AuthorizationConfiguration
expectedErrList field.ErrorList
knownTypes sets.String
repeatableTypes sets.String
}
)
func TestValidateAuthorizationConfiguration(t *testing.T) {
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthorizationConfiguration, true)()
badKubeConfigFile := "../some/relative/path/kubeconfig"
tempKubeConfigFile, err := os.CreateTemp("/tmp", "kubeconfig")
if err != nil {
t.Fatalf("failed to set up temp file: %v", err)
}
tempKubeConfigFilePath := tempKubeConfigFile.Name()
defer os.Remove(tempKubeConfigFilePath)
tests := []test{
{
name: "atleast one authorizer should be defined",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{},
},
expectedErrList: field.ErrorList{field.Required(field.NewPath("authorizers"), "at least one authorization mode must be defined")},
knownTypes: sets.NewString(),
repeatableTypes: sets.NewString(),
},
{
name: "type and name are required if an authorizer is defined",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{},
},
},
expectedErrList: field.ErrorList{field.Required(field.NewPath("type"), "")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "authorizer names should be of non-zero length",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Foo",
Name: "",
},
},
},
expectedErrList: field.ErrorList{field.Required(field.NewPath("name"), "")},
knownTypes: sets.NewString(string("Foo")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "authorizer names should be unique",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Foo",
Name: "foo",
},
{
Type: "Bar",
Name: "foo",
},
},
},
expectedErrList: field.ErrorList{field.Duplicate(field.NewPath("name"), "foo")},
knownTypes: sets.NewString(string("Foo"), string("Bar")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "authorizer names should be DNS1123 labels",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Foo",
Name: "myauthorizer",
},
},
},
expectedErrList: field.ErrorList{},
knownTypes: sets.NewString(string("Foo")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "authorizer names should be DNS1123 subdomains",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Foo",
Name: "foo.example.domain",
},
},
},
expectedErrList: field.ErrorList{},
knownTypes: sets.NewString(string("Foo")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "authorizer names should not be invalid DNS1123 labels or subdomains",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Foo",
Name: "FOO.example.domain",
},
},
},
expectedErrList: field.ErrorList{field.Invalid(field.NewPath("name"), "FOO.example.domain", "")},
knownTypes: sets.NewString(string("Foo")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "bare minimum configuration with Webhook",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "NoOpinion",
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "bare minimum configuration with Webhook and MatchConditions",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "NoOpinion",
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
MatchConditions: []api.WebhookMatchCondition{
{
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kube-system'",
},
{
Expression: "request.user == 'admin'",
},
},
},
},
},
},
expectedErrList: field.ErrorList{},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "bare minimum configuration with multiple webhooks",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "NoOpinion",
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
{
Type: "Webhook",
Name: "second-webhook",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "NoOpinion",
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "configuration with unknown types",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Foo",
},
},
},
expectedErrList: field.ErrorList{field.NotSupported(field.NewPath("type"), "Foo", []string{"..."})},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "configuration with not repeatable types",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Foo",
Name: "foo-1",
},
{
Type: "Foo",
Name: "foo-2",
},
},
},
expectedErrList: field.ErrorList{field.Duplicate(field.NewPath("type"), "Foo")},
knownTypes: sets.NewString(string("Foo")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "when type=Webhook, webhook needs to be defined",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
},
},
},
expectedErrList: field.ErrorList{field.Required(field.NewPath("webhook"), "required when type=Webhook")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "when type!=Webhook, webhooks needs to be nil",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Foo",
Name: "foo",
Webhook: &api.WebhookConfiguration{},
},
},
},
expectedErrList: field.ErrorList{field.Invalid(field.NewPath("webhook"), "non-null", "may only be specified when type=Webhook")},
knownTypes: sets.NewString(string("Foo")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "timeout should be specified",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
FailurePolicy: "NoOpinion",
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.Required(field.NewPath("timeout"), "")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
//
{
name: "timeout shouldn't be zero",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
FailurePolicy: "NoOpinion",
Timeout: metav1.Duration{Duration: 0 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.Required(field.NewPath("timeout"), "")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "timeout shouldn't be negative",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
FailurePolicy: "NoOpinion",
Timeout: metav1.Duration{Duration: -30 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.Invalid(field.NewPath("timeout"), time.Duration(-30*time.Second).String(), "must be > 0s and <= 30s")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "timeout shouldn't be greater than 30seconds",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
FailurePolicy: "NoOpinion",
Timeout: metav1.Duration{Duration: 60 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.Invalid(field.NewPath("timeout"), time.Duration(60*time.Second).String(), "must be > 0s and <= 30s")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "authorizedTTL should be defined ",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
FailurePolicy: "NoOpinion",
Timeout: metav1.Duration{Duration: 5 * time.Second},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.Required(field.NewPath("authorizedTTL"), "")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "authorizedTTL shouldn't be negative",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
FailurePolicy: "NoOpinion",
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: -30 * time.Second},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.Invalid(field.NewPath("authorizedTTL"), time.Duration(-30*time.Second).String(), "must be > 0s")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "unauthorizedTTL should be defined ",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
FailurePolicy: "NoOpinion",
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.Required(field.NewPath("unauthorizedTTL"), "")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "unauthorizedTTL shouldn't be negative",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
FailurePolicy: "NoOpinion",
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: -30 * time.Second},
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.Invalid(field.NewPath("unauthorizedTTL"), time.Duration(-30*time.Second).String(), "must be > 0s")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "SAR should be defined",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
MatchConditionSubjectAccessReviewVersion: "v1",
FailurePolicy: "NoOpinion",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.Required(field.NewPath("subjectAccessReviewVersion"), "")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "SAR should be one of v1 and v1beta1",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "NoOpinion",
SubjectAccessReviewVersion: "v2beta1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.NotSupported(field.NewPath("subjectAccessReviewVersion"), "v2beta1", []string{"v1", "v1beta1"})},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "MatchConditionSAR should be defined",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "NoOpinion",
SubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.Required(field.NewPath("matchConditionSubjectAccessReviewVersion"), "")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "MatchConditionSAR must not be anything other than v1",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "NoOpinion",
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1beta1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.NotSupported(field.NewPath("matchConditionSubjectAccessReviewVersion"), "v1beta1", []string{"v1"})},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "failurePolicy should be defined",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.Required(field.NewPath("failurePolicy"), "")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "failurePolicy should be one of \"NoOpinion\" or \"Deny\"",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "AlwaysAllow",
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{field.NotSupported(field.NewPath("failurePolicy"), "AlwaysAllow", []string{"NoOpinion", "Deny"})},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "connectionInfo should be defined",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "NoOpinion",
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
},
},
},
},
expectedErrList: field.ErrorList{field.Required(field.NewPath("connectionInfo"), "")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "connectionInfo should be one of InClusterConfig or KubeConfigFile",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "NoOpinion",
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "ExternalClusterConfig",
},
},
},
},
},
expectedErrList: field.ErrorList{
field.NotSupported(field.NewPath("connectionInfo"), api.WebhookConnectionInfo{Type: "ExternalClusterConfig"}, []string{"InClusterConfig", "KubeConfigFile"}),
},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "if connectionInfo=InClusterConfig, then kubeConfigFile should be nil",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "NoOpinion",
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "InClusterConfig",
KubeConfigFile: new(string),
},
},
},
},
},
expectedErrList: field.ErrorList{
field.Invalid(field.NewPath("connectionInfo", "kubeConfigFile"), "", "can only be set when type=KubeConfigFile"),
},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "if connectionInfo=KubeConfigFile, then KubeConfigFile should be defined",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "NoOpinion",
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "KubeConfigFile",
},
},
},
},
},
expectedErrList: field.ErrorList{field.Required(field.NewPath("kubeConfigFile"), "")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "if connectionInfo=KubeConfigFile, then KubeConfigFile should be defined, must be an absolute path, should exist, shouldn't be a symlink",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "NoOpinion",
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "KubeConfigFile",
KubeConfigFile: &badKubeConfigFile,
},
},
},
},
},
expectedErrList: field.ErrorList{field.Invalid(field.NewPath("kubeConfigFile"), badKubeConfigFile, "must be an absolute path")},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
{
name: "if connectionInfo=KubeConfigFile, an existent file needs to be passed",
configuration: api.AuthorizationConfiguration{
Authorizers: []api.AuthorizerConfiguration{
{
Type: "Webhook",
Name: "default",
Webhook: &api.WebhookConfiguration{
Timeout: metav1.Duration{Duration: 5 * time.Second},
AuthorizedTTL: metav1.Duration{Duration: 5 * time.Minute},
UnauthorizedTTL: metav1.Duration{Duration: 30 * time.Second},
FailurePolicy: "NoOpinion",
SubjectAccessReviewVersion: "v1",
MatchConditionSubjectAccessReviewVersion: "v1",
ConnectionInfo: api.WebhookConnectionInfo{
Type: "KubeConfigFile",
KubeConfigFile: &tempKubeConfigFilePath,
},
},
},
},
},
expectedErrList: field.ErrorList{},
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
errList := ValidateAuthorizationConfiguration(nil, &test.configuration, test.knownTypes, test.repeatableTypes)
if len(errList) != len(test.expectedErrList) {
t.Errorf("expected %d errs, got %d, errors %v", len(test.expectedErrList), len(errList), errList)
}
if len(errList) == len(test.expectedErrList) {
for i, expected := range test.expectedErrList {
if expected.Type.String() != errList[i].Type.String() {
t.Errorf("expected err type %s, got %s",
expected.Type.String(),
errList[i].Type.String())
}
if expected.BadValue != errList[i].BadValue {
t.Errorf("expected bad value '%s', got '%s'",
expected.BadValue,
errList[i].BadValue)
}
}
}
})
}
}
func TestValidateAndCompileMatchConditions(t *testing.T) {
testCases := []struct {
name string
matchConditions []api.WebhookMatchCondition
featureEnabled bool
expectedErr string
}{
{
name: "match conditions are used With feature enabled",
matchConditions: []api.WebhookMatchCondition{
{
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kube-system'",
},
{
Expression: "request.user == 'admin'",
},
},
featureEnabled: true,
expectedErr: "",
},
{
name: "should fail when match conditions are used without feature enabled",
matchConditions: []api.WebhookMatchCondition{
{
Expression: "has(request.resourceAttributes) && request.resourceAttributes.namespace == 'kube-system'",
},
{
Expression: "request.user == 'admin'",
},
},
featureEnabled: false,
expectedErr: `matchConditions: Invalid value: "": matchConditions are not supported when StructuredAuthorizationConfiguration feature gate is disabled`,
},
{
name: "no matchConditions should not require feature enablement",
matchConditions: []api.WebhookMatchCondition{},
featureEnabled: false,
expectedErr: "",
},
{
name: "match conditions with invalid expressions",
matchConditions: []api.WebhookMatchCondition{
{
Expression: " ",
},
},
featureEnabled: true,
expectedErr: "matchConditions[0].expression: Required value",
},
{
name: "match conditions with duplicate expressions",
matchConditions: []api.WebhookMatchCondition{
{
Expression: "request.user == 'admin'",
},
{
Expression: "request.user == 'admin'",
},
},
featureEnabled: true,
expectedErr: `matchConditions[1].expression: Duplicate value: "request.user == 'admin'"`,
},
{
name: "match conditions with undeclared reference",
matchConditions: []api.WebhookMatchCondition{
{
Expression: "test",
},
},
featureEnabled: true,
expectedErr: "matchConditions[0].expression: Invalid value: \"test\": compilation failed: ERROR: <input>:1:1: undeclared reference to 'test' (in container '')\n | test\n | ^",
},
{
name: "match conditions with bad return type",
matchConditions: []api.WebhookMatchCondition{
{
Expression: "request.user = 'test'",
},
},
featureEnabled: true,
expectedErr: "matchConditions[0].expression: Invalid value: \"request.user = 'test'\": compilation failed: ERROR: <input>:1:14: Syntax error: token recognition error at: '= '\n | request.user = 'test'\n | .............^\nERROR: <input>:1:16: Syntax error: extraneous input ''test'' expecting <EOF>\n | request.user = 'test'\n | ...............^",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthorizationConfiguration, tt.featureEnabled)()
celMatcher, errList := ValidateAndCompileMatchConditions(tt.matchConditions)
if len(tt.expectedErr) == 0 && len(tt.matchConditions) > 0 && len(errList) == 0 && celMatcher == nil {
t.Errorf("celMatcher should not be nil when there are matchCondition and no error returned")
}
got := errList.ToAggregate()
if d := cmp.Diff(tt.expectedErr, errString(got)); d != "" {
t.Fatalf("ValidateAndCompileMatchConditions validation mismatch (-want +got):\n%s", d)
}
})
}
}