Merge pull request #121223 from ritazh/authz-cel

[StructuredAuthorizationConfig] - CEL integration
This commit is contained in:
Kubernetes Prow Robot 2023-10-31 13:13:56 +01:00 committed by GitHub
commit 064e86b3d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1159 additions and 47 deletions

View File

@ -19,6 +19,7 @@ package authorizer
import (
"errors"
"fmt"
utilnet "k8s.io/apimachinery/pkg/util/net"
"k8s.io/apimachinery/pkg/util/wait"
authzconfig "k8s.io/apiserver/pkg/apis/apiserver"
@ -122,6 +123,7 @@ func (config Config) New() (authorizer.Authorizer, authorizer.RuleResolver, erro
configuredAuthorizer.Webhook.AuthorizedTTL.Duration,
configuredAuthorizer.Webhook.UnauthorizedTTL.Duration,
*config.WebhookRetryBackoff,
configuredAuthorizer.Webhook.MatchConditions,
)
if err != nil {
return nil, nil, err

View File

@ -17,8 +17,8 @@ limitations under the License.
package validation
import (
"errors"
"fmt"
utilvalidation "k8s.io/apimachinery/pkg/util/validation"
"net/url"
"os"
"path/filepath"
@ -27,10 +27,15 @@ import (
v1 "k8s.io/api/authorization/v1"
"k8s.io/api/authorization/v1beta1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/sets"
utilvalidation "k8s.io/apimachinery/pkg/util/validation"
"k8s.io/apimachinery/pkg/util/validation/field"
api "k8s.io/apiserver/pkg/apis/apiserver"
authorizationcel "k8s.io/apiserver/pkg/authorization/cel"
"k8s.io/apiserver/pkg/cel"
"k8s.io/apiserver/pkg/cel/environment"
"k8s.io/apiserver/pkg/features"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/client-go/util/cert"
)
@ -334,24 +339,81 @@ func ValidateWebhookConfiguration(fldPath *field.Path, c *api.WebhookConfigurati
allErrs = append(allErrs, field.NotSupported(fldPath.Child("connectionInfo", "type"), c.ConnectionInfo, []string{api.AuthorizationWebhookConnectionInfoTypeInCluster, api.AuthorizationWebhookConnectionInfoTypeKubeConfigFile}))
}
// TODO: Remove this check and ensure that correct validations below for MatchConditions are added
// for i, condition := range c.MatchConditions {
// fldPath := fldPath.Child("matchConditions").Index(i).Child("expression")
// if len(strings.TrimSpace(condition.Expression)) == 0 {
// allErrs = append(allErrs, field.Required(fldPath, ""))
// } else {
// allErrs = append(allErrs, ValidateWebhookMatchCondition(fldPath, sampleSAR, condition.Expression)...)
// }
// }
if len(c.MatchConditions) != 0 {
allErrs = append(allErrs, field.NotSupported(fldPath.Child("matchConditions"), c.MatchConditions, []string{}))
_, errs := compileMatchConditions(c.MatchConditions, fldPath, utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthorizationConfiguration))
allErrs = append(allErrs, errs...)
return allErrs
}
// ValidateAndCompileMatchConditions validates a given webhook's matchConditions.
// This is exported for use in authz package.
func ValidateAndCompileMatchConditions(matchConditions []api.WebhookMatchCondition) (*authorizationcel.CELMatcher, field.ErrorList) {
return compileMatchConditions(matchConditions, nil, utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthorizationConfiguration))
}
func compileMatchConditions(matchConditions []api.WebhookMatchCondition, fldPath *field.Path, structuredAuthzFeatureEnabled bool) (*authorizationcel.CELMatcher, field.ErrorList) {
var allErrs field.ErrorList
// should fail when match conditions are used without feature enabled
if len(matchConditions) > 0 && !structuredAuthzFeatureEnabled {
allErrs = append(allErrs, field.Invalid(fldPath.Child("matchConditions"), "", "matchConditions are not supported when StructuredAuthorizationConfiguration feature gate is disabled"))
}
if len(matchConditions) > 64 {
allErrs = append(allErrs, field.TooMany(fldPath.Child("matchConditions"), len(matchConditions), 64))
return nil, allErrs
}
return allErrs
compiler := authorizationcel.NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion()))
seenExpressions := sets.NewString()
var compilationResults []authorizationcel.CompilationResult
for i, condition := range matchConditions {
fldPath := fldPath.Child("matchConditions").Index(i).Child("expression")
if len(strings.TrimSpace(condition.Expression)) == 0 {
allErrs = append(allErrs, field.Required(fldPath, ""))
continue
}
if seenExpressions.Has(condition.Expression) {
allErrs = append(allErrs, field.Duplicate(fldPath, condition.Expression))
continue
}
seenExpressions.Insert(condition.Expression)
compilationResult, err := compileMatchConditionsExpression(fldPath, compiler, condition.Expression)
if err != nil {
allErrs = append(allErrs, err)
continue
}
compilationResults = append(compilationResults, compilationResult)
}
if len(compilationResults) == 0 {
return nil, allErrs
}
return &authorizationcel.CELMatcher{
CompilationResults: compilationResults,
}, allErrs
}
func ValidateWebhookMatchCondition(fldPath *field.Path, sampleSAR runtime.Object, expression string) field.ErrorList {
allErrs := field.ErrorList{}
// TODO: typecheck CEL expression
return allErrs
func compileMatchConditionsExpression(fldPath *field.Path, compiler authorizationcel.Compiler, expression string) (authorizationcel.CompilationResult, *field.Error) {
authzExpression := &authorizationcel.SubjectAccessReviewMatchCondition{
Expression: expression,
}
compilationResult, err := compiler.CompileCELExpression(authzExpression)
if err != nil {
return compilationResult, convertCELErrorToValidationError(fldPath, authzExpression, err)
}
return compilationResult, nil
}
func convertCELErrorToValidationError(fldPath *field.Path, expression authorizationcel.ExpressionAccessor, err error) *field.Error {
var celErr *cel.Error
if errors.As(err, &celErr) {
switch celErr.Type {
case cel.ErrorTypeRequired:
return field.Required(fldPath, celErr.Detail)
case cel.ErrorTypeInvalid:
return field.Invalid(fldPath, expression.GetExpression(), celErr.Detail)
default:
return field.InternalError(fldPath, celErr)
}
}
return field.InternalError(fldPath, fmt.Errorf("error is not cel error: %w", err))
}

View File

@ -32,7 +32,10 @@ import (
"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"
)
@ -428,6 +431,8 @@ type (
)
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")
@ -557,6 +562,39 @@ func TestValidateAuthorizationConfiguration(t *testing.T) {
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{
@ -1156,8 +1194,6 @@ func TestValidateAuthorizationConfiguration(t *testing.T) {
knownTypes: sets.NewString(string("Webhook")),
repeatableTypes: sets.NewString(string("Webhook")),
},
// TODO: When the CEL expression validator is implemented, add a few test cases to typecheck the expression
}
for _, test := range tests {
@ -1166,20 +1202,120 @@ func TestValidateAuthorizationConfiguration(t *testing.T) {
if len(errList) != len(test.expectedErrList) {
t.Errorf("expected %d errs, got %d, errors %v", len(test.expectedErrList), len(errList), errList)
}
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)
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)
}
})
}
}

View File

@ -0,0 +1,178 @@
/*
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 cel
import (
"fmt"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types/ref"
"k8s.io/apimachinery/pkg/util/version"
apiservercel "k8s.io/apiserver/pkg/cel"
"k8s.io/apiserver/pkg/cel/environment"
)
const (
subjectAccessReviewRequestVarName = "request"
)
// CompilationResult represents a compiled authorization cel expression.
type CompilationResult struct {
Program cel.Program
ExpressionAccessor ExpressionAccessor
}
// EvaluationResult contains the minimal required fields and metadata of a cel evaluation
type EvaluationResult struct {
EvalResult ref.Val
ExpressionAccessor ExpressionAccessor
}
// Compiler is an interface for compiling CEL expressions with the desired environment mode.
type Compiler interface {
CompileCELExpression(expressionAccessor ExpressionAccessor) (CompilationResult, error)
}
type compiler struct {
envSet *environment.EnvSet
}
// NewCompiler returns a new Compiler.
func NewCompiler(env *environment.EnvSet) Compiler {
return &compiler{
envSet: mustBuildEnv(env),
}
}
func (c compiler) CompileCELExpression(expressionAccessor ExpressionAccessor) (CompilationResult, error) {
resultError := func(errorString string, errType apiservercel.ErrorType) (CompilationResult, error) {
err := &apiservercel.Error{
Type: errType,
Detail: errorString,
}
return CompilationResult{
ExpressionAccessor: expressionAccessor,
}, err
}
env, err := c.envSet.Env(environment.StoredExpressions)
if err != nil {
return resultError(fmt.Sprintf("unexpected error loading CEL environment: %v", err), apiservercel.ErrorTypeInternal)
}
ast, issues := env.Compile(expressionAccessor.GetExpression())
if issues != nil {
return resultError("compilation failed: "+issues.String(), apiservercel.ErrorTypeInvalid)
}
found := false
returnTypes := expressionAccessor.ReturnTypes()
for _, returnType := range returnTypes {
if ast.OutputType() == returnType {
found = true
break
}
}
if !found {
var reason string
if len(returnTypes) == 1 {
reason = fmt.Sprintf("must evaluate to %v but got %v", returnTypes[0].String(), ast.OutputType())
} else {
reason = fmt.Sprintf("must evaluate to one of %v", returnTypes)
}
return resultError(reason, apiservercel.ErrorTypeInvalid)
}
_, err = cel.AstToCheckedExpr(ast)
if err != nil {
// should be impossible since env.Compile returned no issues
return resultError("unexpected compilation error: "+err.Error(), apiservercel.ErrorTypeInternal)
}
prog, err := env.Program(ast)
if err != nil {
return resultError("program instantiation failed: "+err.Error(), apiservercel.ErrorTypeInternal)
}
return CompilationResult{
Program: prog,
ExpressionAccessor: expressionAccessor,
}, nil
}
func mustBuildEnv(baseEnv *environment.EnvSet) *environment.EnvSet {
field := func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField {
return apiservercel.NewDeclField(name, declType, required, nil, nil)
}
fields := func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField {
result := make(map[string]*apiservercel.DeclField, len(fields))
for _, f := range fields {
result[f.Name] = f
}
return result
}
subjectAccessReviewSpecRequestType := buildRequestType(field, fields)
extended, err := baseEnv.Extend(
environment.VersionedOptions{
// we record this as 1.0 since it was available in the
// first version that supported this feature
IntroducedVersion: version.MajorMinor(1, 0),
EnvOptions: []cel.EnvOption{
cel.Variable(subjectAccessReviewRequestVarName, subjectAccessReviewSpecRequestType.CelType()),
},
DeclTypes: []*apiservercel.DeclType{
subjectAccessReviewSpecRequestType,
},
},
)
if err != nil {
panic(fmt.Sprintf("environment misconfigured: %v", err))
}
return extended
}
// buildRequestType generates a DeclType for SubjectAccessReviewSpec.
func buildRequestType(field func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField, fields func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField) *apiservercel.DeclType {
resourceAttributesType := buildResourceAttributesType(field, fields)
nonResourceAttributesType := buildNonResourceAttributesType(field, fields)
return apiservercel.NewObjectType("kubernetes.SubjectAccessReviewSpec", fields(
field("resourceAttributes", resourceAttributesType, false),
field("nonResourceAttributes", nonResourceAttributesType, false),
field("user", apiservercel.StringType, false),
field("groups", apiservercel.NewListType(apiservercel.StringType, -1), false),
field("extra", apiservercel.NewMapType(apiservercel.StringType, apiservercel.NewListType(apiservercel.StringType, -1), -1), false),
field("uid", apiservercel.StringType, false),
))
}
// buildResourceAttributesType generates a DeclType for ResourceAttributes.
func buildResourceAttributesType(field func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField, fields func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField) *apiservercel.DeclType {
return apiservercel.NewObjectType("kubernetes.ResourceAttributes", fields(
field("namespace", apiservercel.StringType, false),
field("verb", apiservercel.StringType, false),
field("group", apiservercel.StringType, false),
field("version", apiservercel.StringType, false),
field("resource", apiservercel.StringType, false),
field("subresource", apiservercel.StringType, false),
field("name", apiservercel.StringType, false),
))
}
// buildNonResourceAttributesType generates a DeclType for NonResourceAttributes.
func buildNonResourceAttributesType(field func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField, fields func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField) *apiservercel.DeclType {
return apiservercel.NewObjectType("kubernetes.NonResourceAttributes", fields(
field("path", apiservercel.StringType, false),
field("verb", apiservercel.StringType, false),
))
}

View File

@ -0,0 +1,158 @@
/*
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 cel
import (
"fmt"
"reflect"
"strings"
"testing"
v1 "k8s.io/api/authorization/v1"
apiservercel "k8s.io/apiserver/pkg/cel"
"k8s.io/apiserver/pkg/cel/environment"
)
func TestCompileCELExpression(t *testing.T) {
cases := []struct {
name string
expression string
expectedError string
}{
{
name: "SubjectAccessReviewSpec user comparison",
expression: "request.user == 'bob'",
},
{
name: "undefined fields",
expression: "request.time == 'now'",
expectedError: "undefined field",
},
{
name: "Syntax errors",
expression: "request++'",
expectedError: "Syntax error",
},
{
name: "bad return type",
expression: "request.user",
expectedError: "must evaluate to bool",
},
{
name: "undeclared reference",
expression: "x.user",
expectedError: "undeclared reference",
},
}
compiler := NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion()))
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := compiler.CompileCELExpression(&SubjectAccessReviewMatchCondition{
Expression: tc.expression,
})
if len(tc.expectedError) > 0 && (err == nil || !strings.Contains(err.Error(), tc.expectedError)) {
t.Fatalf("expected error: %s compiling expression %s, got: %v", tc.expectedError, tc.expression, err)
}
if len(tc.expectedError) == 0 && err != nil {
t.Fatalf("unexpected error %v compiling expression %s", err, tc.expression)
}
})
}
}
func TestBuildRequestType(t *testing.T) {
f := func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField {
return apiservercel.NewDeclField(name, declType, required, nil, nil)
}
fs := func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField {
result := make(map[string]*apiservercel.DeclField, len(fields))
for _, f := range fields {
result[f.Name] = f
}
return result
}
requestDeclType := buildRequestType(f, fs)
requestType := reflect.TypeOf(v1.SubjectAccessReviewSpec{})
if len(requestDeclType.Fields) != requestType.NumField() {
t.Fatalf("expected %d fields for SubjectAccessReviewSpec, got %d", requestType.NumField(), len(requestDeclType.Fields))
}
resourceAttributesDeclType := buildResourceAttributesType(f, fs)
resourceAttributeType := reflect.TypeOf(v1.ResourceAttributes{})
if len(resourceAttributesDeclType.Fields) != resourceAttributeType.NumField() {
t.Fatalf("expected %d fields for ResourceAttributes, got %d", resourceAttributeType.NumField(), len(resourceAttributesDeclType.Fields))
}
nonResourceAttributesDeclType := buildNonResourceAttributesType(f, fs)
nonResourceAttributeType := reflect.TypeOf(v1.NonResourceAttributes{})
if len(nonResourceAttributesDeclType.Fields) != nonResourceAttributeType.NumField() {
t.Fatalf("expected %d fields for NonResourceAttributes, got %d", nonResourceAttributeType.NumField(), len(nonResourceAttributesDeclType.Fields))
}
if err := compareFieldsForType(t, requestType, requestDeclType, f, fs); err != nil {
t.Error(err)
}
}
func compareFieldsForType(t *testing.T, nativeType reflect.Type, declType *apiservercel.DeclType, field func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField, fields func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField) error {
for i := 0; i < nativeType.NumField(); i++ {
nativeField := nativeType.Field(i)
jsonTagParts := strings.Split(nativeField.Tag.Get("json"), ",")
if len(jsonTagParts) < 1 {
t.Fatal("expected json tag to be present")
}
fieldName := jsonTagParts[0]
declField, ok := declType.Fields[fieldName]
if !ok {
t.Fatalf("expected field %q to be present", nativeField.Name)
}
declFieldType := nativeTypeToCELType(t, nativeField.Type, field, fields)
if declFieldType != nil && declFieldType.CelType().Equal(declField.Type.CelType()).Value() != true {
return fmt.Errorf("expected native field %q to have type %v, got %v", nativeField.Name, nativeField.Type, declField.Type)
}
}
return nil
}
func nativeTypeToCELType(t *testing.T, nativeType reflect.Type, field func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField, fields func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField) *apiservercel.DeclType {
switch nativeType {
case reflect.TypeOf(""):
return apiservercel.StringType
case reflect.TypeOf([]string{}):
return apiservercel.NewListType(apiservercel.StringType, -1)
case reflect.TypeOf(map[string]v1.ExtraValue{}):
return apiservercel.NewMapType(apiservercel.StringType, apiservercel.NewListType(apiservercel.StringType, -1), -1)
case reflect.TypeOf(&v1.ResourceAttributes{}):
resourceAttributesDeclType := buildResourceAttributesType(field, fields)
if err := compareFieldsForType(t, reflect.TypeOf(v1.ResourceAttributes{}), resourceAttributesDeclType, field, fields); err != nil {
t.Error(err)
return nil
}
return resourceAttributesDeclType
case reflect.TypeOf(&v1.NonResourceAttributes{}):
nonResourceAttributesDeclType := buildNonResourceAttributesType(field, fields)
if err := compareFieldsForType(t, reflect.TypeOf(v1.NonResourceAttributes{}), nonResourceAttributesDeclType, field, fields); err != nil {
t.Error(err)
return nil
}
return nonResourceAttributesDeclType
default:
t.Fatalf("unsupported type %v", nativeType)
}
return nil
}

View File

@ -0,0 +1,41 @@
/*
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 cel
import (
celgo "github.com/google/cel-go/cel"
)
type ExpressionAccessor interface {
GetExpression() string
ReturnTypes() []*celgo.Type
}
var _ ExpressionAccessor = &SubjectAccessReviewMatchCondition{}
// SubjectAccessReviewMatchCondition is a CEL expression that maps a SubjectAccessReview request to a list of values.
type SubjectAccessReviewMatchCondition struct {
Expression string
}
func (v *SubjectAccessReviewMatchCondition) GetExpression() string {
return v.Expression
}
func (v *SubjectAccessReviewMatchCondition) ReturnTypes() []*celgo.Type {
return []*celgo.Type{celgo.BoolType}
}

View File

@ -0,0 +1,81 @@
/*
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 cel
import (
"context"
"fmt"
celgo "github.com/google/cel-go/cel"
authorizationv1 "k8s.io/api/authorization/v1"
"k8s.io/apimachinery/pkg/runtime"
utilerrors "k8s.io/apimachinery/pkg/util/errors"
)
type CELMatcher struct {
CompilationResults []CompilationResult
}
// eval evaluates the given SubjectAccessReview against all cel matchCondition expression
func (c *CELMatcher) Eval(ctx context.Context, r *authorizationv1.SubjectAccessReview) (bool, error) {
var evalErrors []error
specValObject, err := convertObjectToUnstructured(&r.Spec)
if err != nil {
return false, fmt.Errorf("authz celMatcher eval error: convert SubjectAccessReviewSpec object to unstructured failed: %w", err)
}
va := map[string]interface{}{
"request": specValObject,
}
for _, compilationResult := range c.CompilationResults {
evalResult, _, err := compilationResult.Program.ContextEval(ctx, va)
if err != nil {
evalErrors = append(evalErrors, fmt.Errorf("cel evaluation error: expression '%v' resulted in error: %w", compilationResult.ExpressionAccessor.GetExpression(), err))
continue
}
if evalResult.Type() != celgo.BoolType {
evalErrors = append(evalErrors, fmt.Errorf("cel evaluation error: expression '%v' eval result type should be bool but got %W", compilationResult.ExpressionAccessor.GetExpression(), evalResult.Type()))
continue
}
match, ok := evalResult.Value().(bool)
if !ok {
evalErrors = append(evalErrors, fmt.Errorf("cel evaluation error: expression '%v' eval result value should be bool but got %W", compilationResult.ExpressionAccessor.GetExpression(), evalResult.Value()))
continue
}
// If at least one matchCondition successfully evaluates to FALSE,
// return early
if !match {
return false, nil
}
}
// if there is any error, return
if len(evalErrors) > 0 {
return false, utilerrors.NewAggregate(evalErrors)
}
// return ALL matchConditions evaluate to TRUE successfully without error
return true, nil
}
func convertObjectToUnstructured(obj *authorizationv1.SubjectAccessReviewSpec) (map[string]interface{}, error) {
if obj == nil {
return nil, nil
}
ret, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
if err != nil {
return nil, err
}
return ret, nil
}

View File

@ -20,6 +20,7 @@ import (
"context"
"testing"
"k8s.io/apiserver/pkg/apis/apiserver"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
@ -79,7 +80,7 @@ func TestAuthorizerMetrics(t *testing.T) {
RecordRequestTotal: fakeAuthzMetrics.RequestTotal,
RecordRequestLatency: fakeAuthzMetrics.RequestLatency,
}
wh, err := newV1Authorizer(server.URL, scenario.clientCert, scenario.clientKey, scenario.clientCA, 0, authzMetrics)
wh, err := newV1Authorizer(server.URL, scenario.clientCert, scenario.clientKey, scenario.clientCA, 0, authzMetrics, []apiserver.WebhookMatchCondition{})
if err != nil {
t.Error("failed to create client")
return

View File

@ -31,8 +31,13 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/cache"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/apiserver/pkg/apis/apiserver"
apiservervalidation "k8s.io/apiserver/pkg/apis/apiserver/validation"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
authorizationcel "k8s.io/apiserver/pkg/authorization/cel"
"k8s.io/apiserver/pkg/features"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/apiserver/pkg/util/webhook"
"k8s.io/client-go/kubernetes/scheme"
authorizationv1client "k8s.io/client-go/kubernetes/typed/authorization/v1"
@ -66,11 +71,12 @@ type WebhookAuthorizer struct {
retryBackoff wait.Backoff
decisionOnError authorizer.Decision
metrics AuthorizerMetrics
celMatcher *authorizationcel.CELMatcher
}
// NewFromInterface creates a WebhookAuthorizer using the given subjectAccessReview client
func NewFromInterface(subjectAccessReview authorizationv1client.AuthorizationV1Interface, authorizedTTL, unauthorizedTTL time.Duration, retryBackoff wait.Backoff, metrics AuthorizerMetrics) (*WebhookAuthorizer, error) {
return newWithBackoff(&subjectAccessReviewV1Client{subjectAccessReview.RESTClient()}, authorizedTTL, unauthorizedTTL, retryBackoff, metrics)
return newWithBackoff(&subjectAccessReviewV1Client{subjectAccessReview.RESTClient()}, authorizedTTL, unauthorizedTTL, retryBackoff, nil, metrics)
}
// New creates a new WebhookAuthorizer from the provided kubeconfig file.
@ -92,19 +98,24 @@ func NewFromInterface(subjectAccessReview authorizationv1client.AuthorizationV1I
//
// For additional HTTP configuration, refer to the kubeconfig documentation
// https://kubernetes.io/docs/user-guide/kubeconfig-file/.
func New(config *rest.Config, version string, authorizedTTL, unauthorizedTTL time.Duration, retryBackoff wait.Backoff) (*WebhookAuthorizer, error) {
func New(config *rest.Config, version string, authorizedTTL, unauthorizedTTL time.Duration, retryBackoff wait.Backoff, matchConditions []apiserver.WebhookMatchCondition) (*WebhookAuthorizer, error) {
subjectAccessReview, err := subjectAccessReviewInterfaceFromConfig(config, version, retryBackoff)
if err != nil {
return nil, err
}
return newWithBackoff(subjectAccessReview, authorizedTTL, unauthorizedTTL, retryBackoff, AuthorizerMetrics{
return newWithBackoff(subjectAccessReview, authorizedTTL, unauthorizedTTL, retryBackoff, matchConditions, AuthorizerMetrics{
RecordRequestTotal: noopMetrics{}.RecordRequestTotal,
RecordRequestLatency: noopMetrics{}.RecordRequestLatency,
})
}
// newWithBackoff allows tests to skip the sleep.
func newWithBackoff(subjectAccessReview subjectAccessReviewer, authorizedTTL, unauthorizedTTL time.Duration, retryBackoff wait.Backoff, metrics AuthorizerMetrics) (*WebhookAuthorizer, error) {
func newWithBackoff(subjectAccessReview subjectAccessReviewer, authorizedTTL, unauthorizedTTL time.Duration, retryBackoff wait.Backoff, matchConditions []apiserver.WebhookMatchCondition, metrics AuthorizerMetrics) (*WebhookAuthorizer, error) {
// compile all expressions once in validation and save the results to be used for eval later
cm, fieldErr := apiservervalidation.ValidateAndCompileMatchConditions(matchConditions)
if err := fieldErr.ToAggregate(); err != nil {
return nil, err
}
return &WebhookAuthorizer{
subjectAccessReview: subjectAccessReview,
responseCache: cache.NewLRUExpireCache(8192),
@ -113,6 +124,7 @@ func newWithBackoff(subjectAccessReview subjectAccessReviewer, authorizedTTL, un
retryBackoff: retryBackoff,
decisionOnError: authorizer.DecisionNoOpinion,
metrics: metrics,
celMatcher: cm,
}, nil
}
@ -190,6 +202,24 @@ func (w *WebhookAuthorizer) Authorize(ctx context.Context, attr authorizer.Attri
Verb: attr.GetVerb(),
}
}
// skipping match when feature is not enabled
if utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthorizationConfiguration) {
// Process Match Conditions before calling the webhook
matches, err := w.match(ctx, r)
// If at least one matchCondition evaluates to an error (but none are FALSE):
// If failurePolicy=Deny, then the webhook rejects the request
// If failurePolicy=NoOpinion, then the error is ignored and the webhook is skipped
if err != nil {
return w.decisionOnError, "", err
}
// If at least one matchCondition successfully evaluates to FALSE,
// then the webhook is skipped.
if !matches {
return authorizer.DecisionNoOpinion, "", nil
}
}
// If all evaluated successfully and ALL matchConditions evaluate to TRUE,
// then the webhook is called.
key, err := json.Marshal(r.Spec)
if err != nil {
return w.decisionOnError, "", err
@ -256,6 +286,18 @@ func (w *WebhookAuthorizer) RulesFor(user user.Info, namespace string) ([]author
return resourceRules, nonResourceRules, incomplete, fmt.Errorf("webhook authorizer does not support user rule resolution")
}
// Match is used to evaluate the SubjectAccessReviewSpec against
// the authorizer's matchConditions in the form of cel expressions
// to return match or no match found, which then is used to
// determine if the webhook should be skipped.
func (w *WebhookAuthorizer) match(ctx context.Context, r *authorizationv1.SubjectAccessReview) (bool, error) {
// A nil celMatcher or zero saved CompilationResults matches all requests.
if w.celMatcher == nil || w.celMatcher.CompilationResults == nil {
return true, nil
}
return w.celMatcher.Eval(ctx, r)
}
func convertToSARExtra(extra map[string][]string) map[string]authorizationv1.ExtraValue {
if extra == nil {
return nil

View File

@ -40,10 +40,14 @@ import (
authorizationv1 "k8s.io/api/authorization/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/apiserver/pkg/apis/apiserver"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/features"
utilfeature "k8s.io/apiserver/pkg/util/feature"
webhookutil "k8s.io/apiserver/pkg/util/webhook"
v1 "k8s.io/client-go/tools/clientcmd/api/v1"
featuregatetesting "k8s.io/component-base/featuregate/testing"
)
var testRetryBackoff = wait.Backoff{
@ -205,7 +209,7 @@ current-context: default
if err != nil {
return fmt.Errorf("error building sar client: %v", err)
}
_, err = newWithBackoff(sarClient, 0, 0, testRetryBackoff, noopAuthorizerMetrics())
_, err = newWithBackoff(sarClient, 0, 0, testRetryBackoff, []apiserver.WebhookMatchCondition{}, noopAuthorizerMetrics())
return err
}()
if err != nil && !tt.wantErr {
@ -318,7 +322,7 @@ 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 AuthorizerMetrics) (*WebhookAuthorizer, error) {
func newV1Authorizer(callbackURL string, clientCert, clientKey, ca []byte, cacheTime time.Duration, metrics AuthorizerMetrics, expressions []apiserver.WebhookMatchCondition) (*WebhookAuthorizer, error) {
tempfile, err := ioutil.TempFile("", "")
if err != nil {
return nil, err
@ -348,7 +352,7 @@ func newV1Authorizer(callbackURL string, clientCert, clientKey, ca []byte, cache
if err != nil {
return nil, fmt.Errorf("error building sar client: %v", err)
}
return newWithBackoff(sarClient, cacheTime, cacheTime, testRetryBackoff, metrics)
return newWithBackoff(sarClient, cacheTime, cacheTime, testRetryBackoff, expressions, metrics)
}
func TestV1TLSConfig(t *testing.T) {
@ -407,7 +411,7 @@ func TestV1TLSConfig(t *testing.T) {
}
defer server.Close()
wh, err := newV1Authorizer(server.URL, tt.clientCert, tt.clientKey, tt.clientCA, 0, noopAuthorizerMetrics())
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
@ -472,7 +476,7 @@ func TestV1Webhook(t *testing.T) {
}
defer s.Close()
wh, err := newV1Authorizer(s.URL, clientCert, clientKey, caCert, 0, noopAuthorizerMetrics())
wh, err := newV1Authorizer(s.URL, clientCert, clientKey, caCert, 0, noopAuthorizerMetrics(), []apiserver.WebhookMatchCondition{})
if err != nil {
t.Fatal(err)
}
@ -572,15 +576,20 @@ func TestV1WebhookCache(t *testing.T) {
t.Fatal(err)
}
defer s.Close()
defer 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())
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"}}
bobAttr := authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "bob"}}
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,
@ -589,6 +598,7 @@ func TestV1WebhookCache(t *testing.T) {
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"},
@ -598,6 +608,7 @@ func TestV1WebhookCache(t *testing.T) {
APIVersion: strings.Repeat("a", 2000),
Resource: strings.Repeat("r", 2000),
Name: strings.Repeat("n", 2000),
Namespace: "kittensandponies",
}
type webhookCacheTestCase struct {
@ -665,6 +676,404 @@ func TestV1WebhookCache(t *testing.T) {
}
}
// 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()
type webhookMatchConditionsTestCase struct {
name string
attr authorizer.AttributesRecord
allow bool
expectedCompileErr bool
expectedEvalErr bool
expectedDecision authorizer.Decision
expressions []apiserver.WebhookMatchCondition
featureEnabled bool
}
aliceAttr := authorizer.AttributesRecord{
User: &user.DefaultInfo{
Name: "alice",
UID: "1",
Groups: []string{"group1", "group2"},
},
ResourceRequest: true,
Namespace: "kittensandponies",
Verb: "get",
}
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: aliceAttr,
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'",
},
},
featureEnabled: true,
},
}
for i, test := range tests {
t.Run(test.name, func(t *testing.T) {
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthorizationConfiguration, test.featureEnabled)()
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)
}
}
})
}
}
// TestV1WebhookMatchConditions verifies cel expressions are compiled and evaluated correctly
func TestV1WebhookMatchConditions(t *testing.T) {
defer 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"},
},
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: "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.user == 'bob'' resulted in error: no such key: user, cel evaluation error: expression 'has(request.nonResourceAttributes) && request.nonResourceAttributes.verb == 'get'' resulted in error: no such key: verb]",
expressions: []apiserver.WebhookMatchCondition{
{
Expression: "request.user == 'bob'",
},
{
Expression: "has(request.nonResourceAttributes) && request.nonResourceAttributes.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() AuthorizerMetrics {
return AuthorizerMetrics{
RecordRequestTotal: noopMetrics{}.RecordRequestTotal,

View File

@ -37,6 +37,7 @@ import (
"github.com/google/go-cmp/cmp"
authorizationv1beta1 "k8s.io/api/authorization/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
authzconfig "k8s.io/apiserver/pkg/apis/apiserver"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
webhookutil "k8s.io/apiserver/pkg/util/webhook"
@ -195,7 +196,7 @@ current-context: default
if err != nil {
return fmt.Errorf("error building sar client: %v", err)
}
_, err = newWithBackoff(sarClient, 0, 0, testRetryBackoff, noopAuthorizerMetrics())
_, err = newWithBackoff(sarClient, 0, 0, testRetryBackoff, []authzconfig.WebhookMatchCondition{}, noopAuthorizerMetrics())
return err
}()
if err != nil && !tt.wantErr {
@ -338,7 +339,7 @@ func newV1beta1Authorizer(callbackURL string, clientCert, clientKey, ca []byte,
if err != nil {
return nil, fmt.Errorf("error building sar client: %v", err)
}
return newWithBackoff(sarClient, cacheTime, cacheTime, testRetryBackoff, noopAuthorizerMetrics())
return newWithBackoff(sarClient, cacheTime, cacheTime, testRetryBackoff, []authzconfig.WebhookMatchCondition{}, noopAuthorizerMetrics())
}
func TestV1beta1TLSConfig(t *testing.T) {

1
vendor/modules.txt vendored
View File

@ -1515,6 +1515,7 @@ k8s.io/apiserver/pkg/authentication/token/union
k8s.io/apiserver/pkg/authentication/user
k8s.io/apiserver/pkg/authorization/authorizer
k8s.io/apiserver/pkg/authorization/authorizerfactory
k8s.io/apiserver/pkg/authorization/cel
k8s.io/apiserver/pkg/authorization/path
k8s.io/apiserver/pkg/authorization/union
k8s.io/apiserver/pkg/cel