implement message expression.

This commit is contained in:
Jiahui Feng 2023-03-08 17:36:11 -08:00
parent f4ee476a3c
commit d8be7aa9ca
10 changed files with 545 additions and 66 deletions

View File

@ -782,26 +782,24 @@ func validateValidation(v *admissionregistration.Validation, paramKind *admissio
var allErrors field.ErrorList
trimmedExpression := strings.TrimSpace(v.Expression)
trimmedMsg := strings.TrimSpace(v.Message)
trimmedMessageExpression := strings.TrimSpace(v.MessageExpression)
if len(trimmedExpression) == 0 {
allErrors = append(allErrors, field.Required(fldPath.Child("expression"), "expression is not specified"))
} else {
result := plugincel.CompileCELExpression(&validatingadmissionpolicy.ValidationCondition{
Expression: trimmedExpression,
Message: v.Message,
Reason: v.Reason,
}, plugincel.OptionalVariableDeclarations{HasParams: paramKind != nil, HasAuthorizer: true}, celconfig.PerCallLimit)
if result.Error != nil {
switch result.Error.Type {
case cel.ErrorTypeRequired:
allErrors = append(allErrors, field.Required(fldPath.Child("expression"), result.Error.Detail))
case cel.ErrorTypeInvalid:
allErrors = append(allErrors, field.Invalid(fldPath.Child("expression"), v.Expression, result.Error.Detail))
case cel.ErrorTypeInternal:
allErrors = append(allErrors, field.InternalError(fldPath.Child("expression"), result.Error))
default:
allErrors = append(allErrors, field.InternalError(fldPath.Child("expression"), fmt.Errorf("unsupported error type: %w", result.Error)))
}
}
allErrors = append(allErrors, validateCELExpression(v.Expression, plugincel.OptionalVariableDeclarations{
HasParams: paramKind != nil,
HasAuthorizer: true,
}, fldPath.Child("expression"))...)
}
if len(v.MessageExpression) > 0 && len(trimmedMessageExpression) == 0 {
allErrors = append(allErrors, field.Invalid(fldPath.Child("messageExpression"), v.MessageExpression, "must be non-empty if specified"))
} else if len(trimmedMessageExpression) != 0 {
// use v.MessageExpression instead of trimmedMessageExpression so that
// the compiler output shows the correct column.
allErrors = append(allErrors, validateCELExpression(v.MessageExpression, plugincel.OptionalVariableDeclarations{
HasParams: paramKind != nil,
HasAuthorizer: false,
}, fldPath.Child("messageExpression"))...)
}
if len(v.Message) > 0 && len(trimmedMsg) == 0 {
allErrors = append(allErrors, field.Invalid(fldPath.Child("message"), v.Message, "message must be non-empty if specified"))
@ -816,6 +814,26 @@ func validateValidation(v *admissionregistration.Validation, paramKind *admissio
return allErrors
}
func validateCELExpression(expression string, variables plugincel.OptionalVariableDeclarations, fldPath *field.Path) field.ErrorList {
var allErrors field.ErrorList
result := plugincel.CompileCELExpression(&validatingadmissionpolicy.ValidationCondition{
Expression: expression,
}, variables, celconfig.PerCallLimit)
if result.Error != nil {
switch result.Error.Type {
case cel.ErrorTypeRequired:
allErrors = append(allErrors, field.Required(fldPath, result.Error.Detail))
case cel.ErrorTypeInvalid:
allErrors = append(allErrors, field.Invalid(fldPath, expression, result.Error.Detail))
case cel.ErrorTypeInternal:
allErrors = append(allErrors, field.InternalError(fldPath, result.Error))
default:
allErrors = append(allErrors, field.InternalError(fldPath, fmt.Errorf("unsupported error type: %w", result.Error)))
}
}
return allErrors
}
func validateAuditAnnotation(meta metav1.ObjectMeta, v *admissionregistration.AuditAnnotation, paramKind *admissionregistration.ParamKind, fldPath *field.Path) field.ErrorList {
var allErrors field.ErrorList
if len(meta.GetName()) != 0 {

View File

@ -2564,7 +2564,38 @@ func TestValidateValidatingAdmissionPolicy(t *testing.T) {
},
},
},
expectedError: `spec.validations[0].expression: Invalid value: "object.x in [1, 2, ": compilation failed: ERROR: <input>:1:19: Syntax error: missing ']' at '<EOF>`,
expectedError: `spec.validations[0].expression: Invalid value: "object.x in [1, 2, ": compilation failed: ERROR: <input>:1:20: Syntax error: missing ']' at '<EOF>`,
},
{
name: "invalid messageExpression",
config: &admissionregistration.ValidatingAdmissionPolicy{
ObjectMeta: metav1.ObjectMeta{
Name: "config",
},
Spec: admissionregistration.ValidatingAdmissionPolicySpec{
Validations: []admissionregistration.Validation{
{
Expression: "true",
MessageExpression: "object.x in [1, 2, ",
},
},
MatchConstraints: &admissionregistration.MatchResources{
ResourceRules: []admissionregistration.NamedRuleWithOperations{
{
RuleWithOperations: admissionregistration.RuleWithOperations{
Operations: []admissionregistration.OperationType{"CREATE"},
Rule: admissionregistration.Rule{
APIGroups: []string{"a"},
APIVersions: []string{"a"},
Resources: []string{"*/*"},
},
},
},
},
},
},
},
expectedError: `spec.validations[0].messageExpression: Invalid value: "object.x in [1, 2, ": compilation failed: ERROR: <input>:1:20: Syntax error: missing ']' at '<EOF>`,
},
{
name: "invalid auditAnnotations key due to key name",

View File

@ -18,7 +18,6 @@ package cel
import (
"context"
"errors"
"fmt"
"math"
"reflect"
@ -32,6 +31,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/cel"
"k8s.io/apiserver/pkg/cel/library"
)
@ -78,6 +78,9 @@ func (a *evaluationActivation) Parent() interpreter.Activation {
func (c *filterCompiler) Compile(expressionAccessors []ExpressionAccessor, options OptionalVariableDeclarations, perCallLimit uint64) Filter {
compilationResults := make([]CompilationResult, len(expressionAccessors))
for i, expressionAccessor := range expressionAccessors {
if expressionAccessor == nil {
continue
}
compilationResults[i] = CompileCELExpression(expressionAccessor, options, perCallLimit)
}
return NewFilter(compilationResults)
@ -119,24 +122,24 @@ func objectToResolveVal(r runtime.Object) (interface{}, error) {
// ForInput evaluates the compiled CEL expressions converting them into CELEvaluations
// errors per evaluation are returned on the Evaluation object
// runtimeCELCostBudget was added for testing purpose only. Callers should always use const RuntimeCELCostBudget from k8s.io/apiserver/pkg/apis/cel/config.go as input.
func (f *filter) ForInput(ctx context.Context, versionedAttr *admission.VersionedAttributes, request *admissionv1.AdmissionRequest, inputs OptionalVariableBindings, runtimeCELCostBudget int64) ([]EvaluationResult, error) {
func (f *filter) ForInput(ctx context.Context, versionedAttr *admission.VersionedAttributes, request *admissionv1.AdmissionRequest, inputs OptionalVariableBindings, runtimeCELCostBudget int64) ([]EvaluationResult, int64, error) {
// TODO: replace unstructured with ref.Val for CEL variables when native type support is available
evaluations := make([]EvaluationResult, len(f.compilationResults))
var err error
oldObjectVal, err := objectToResolveVal(versionedAttr.VersionedOldObject)
if err != nil {
return nil, err
return nil, -1, err
}
objectVal, err := objectToResolveVal(versionedAttr.VersionedObject)
if err != nil {
return nil, err
return nil, -1, err
}
var paramsVal, authorizerVal, requestResourceAuthorizerVal any
if inputs.VersionedParams != nil {
paramsVal, err = objectToResolveVal(inputs.VersionedParams)
if err != nil {
return nil, err
return nil, -1, err
}
}
@ -147,7 +150,7 @@ func (f *filter) ForInput(ctx context.Context, versionedAttr *admission.Versione
requestVal, err := convertObjectToUnstructured(request)
if err != nil {
return nil, err
return nil, -1, err
}
va := &evaluationActivation{
object: objectVal,
@ -161,13 +164,22 @@ func (f *filter) ForInput(ctx context.Context, versionedAttr *admission.Versione
remainingBudget := runtimeCELCostBudget
for i, compilationResult := range f.compilationResults {
var evaluation = &evaluations[i]
if compilationResult.ExpressionAccessor == nil { // in case of placeholder
continue
}
evaluation.ExpressionAccessor = compilationResult.ExpressionAccessor
if compilationResult.Error != nil {
evaluation.Error = errors.New(fmt.Sprintf("compilation error: %v", compilationResult.Error))
evaluation.Error = &cel.Error{
Type: cel.ErrorTypeInvalid,
Detail: fmt.Sprintf("compilation error: %v", compilationResult.Error),
}
continue
}
if compilationResult.Program == nil {
evaluation.Error = errors.New("unexpected internal error compiling expression")
evaluation.Error = &cel.Error{
Type: cel.ErrorTypeInternal,
Detail: fmt.Sprintf("unexpected internal error compiling expression"),
}
continue
}
t1 := time.Now()
@ -175,26 +187,38 @@ func (f *filter) ForInput(ctx context.Context, versionedAttr *admission.Versione
elapsed := time.Since(t1)
evaluation.Elapsed = elapsed
if evalDetails == nil {
return nil, errors.New(fmt.Sprintf("runtime cost could not be calculated for expression: %v, no further expression will be run", compilationResult.ExpressionAccessor.GetExpression()))
return nil, -1, &cel.Error{
Type: cel.ErrorTypeInternal,
Detail: fmt.Sprintf("runtime cost could not be calculated for expression: %v, no further expression will be run", compilationResult.ExpressionAccessor.GetExpression()),
}
} else {
rtCost := evalDetails.ActualCost()
if rtCost == nil {
return nil, errors.New(fmt.Sprintf("runtime cost could not be calculated for expression: %v, no further expression will be run", compilationResult.ExpressionAccessor.GetExpression()))
return nil, -1, &cel.Error{
Type: cel.ErrorTypeInvalid,
Detail: fmt.Sprintf("runtime cost could not be calculated for expression: %v, no further expression will be run", compilationResult.ExpressionAccessor.GetExpression()),
}
} else {
if *rtCost > math.MaxInt64 || int64(*rtCost) > remainingBudget {
return nil, errors.New(fmt.Sprintf("validation failed due to running out of cost budget, no further validation rules will be run"))
return nil, -1, &cel.Error{
Type: cel.ErrorTypeInvalid,
Detail: fmt.Sprintf("validation failed due to running out of cost budget, no further validation rules will be run"),
}
}
remainingBudget -= int64(*rtCost)
}
}
if err != nil {
evaluation.Error = errors.New(fmt.Sprintf("expression '%v' resulted in error: %v", compilationResult.ExpressionAccessor.GetExpression(), err))
evaluation.Error = &cel.Error{
Type: cel.ErrorTypeInvalid,
Detail: fmt.Sprintf("expression '%v' resulted in error: %v", compilationResult.ExpressionAccessor.GetExpression(), err),
}
} else {
evaluation.EvalResult = evalResult
}
}
return evaluations, nil
return evaluations, remainingBudget, nil
}
// TODO: to reuse https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/admission/plugin/webhook/request/admissionreview.go#L154

View File

@ -38,6 +38,7 @@ import (
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
apiservercel "k8s.io/apiserver/pkg/cel"
"k8s.io/utils/pointer"
)
type condition struct {
@ -661,7 +662,7 @@ func TestFilter(t *testing.T) {
optionalVars := OptionalVariableBindings{VersionedParams: tc.params, Authorizer: tc.authorizer}
ctx := context.TODO()
evalResults, err := f.ForInput(ctx, versionedAttr, CreateAdmissionRequest(versionedAttr.Attributes), optionalVars, celconfig.RuntimeCELCostBudget)
evalResults, _, err := f.ForInput(ctx, versionedAttr, CreateAdmissionRequest(versionedAttr.Attributes), optionalVars, celconfig.RuntimeCELCostBudget)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -697,6 +698,7 @@ func TestRuntimeCELCostBudget(t *testing.T) {
authorizer authorizer.Authorizer
testRuntimeCELCostBudget int64
exceedBudget bool
expectRemainingBudget *int64
}{
{
name: "expression exceed RuntimeCELCostBudget at fist expression",
@ -739,10 +741,49 @@ func TestRuntimeCELCostBudget(t *testing.T) {
Expression: "object.subsets.size() > 2",
},
},
attributes: newValidAttribute(nil, false),
hasParamKind: true,
params: configMapParams,
exceedBudget: false,
attributes: newValidAttribute(nil, false),
hasParamKind: true,
params: configMapParams,
exceedBudget: false,
testRuntimeCELCostBudget: 10,
expectRemainingBudget: pointer.Int64(4), // 10 - 6
},
{
name: "test RuntimeCELCostBudge exactly covers",
validations: []ExpressionAccessor{
&condition{
Expression: "oldObject != null",
},
&condition{
Expression: "object.subsets.size() > 2",
},
},
attributes: newValidAttribute(nil, false),
hasParamKind: true,
params: configMapParams,
exceedBudget: false,
testRuntimeCELCostBudget: 6,
expectRemainingBudget: pointer.Int64(0),
},
{
name: "test RuntimeCELCostBudge exactly covers then constant",
validations: []ExpressionAccessor{
&condition{
Expression: "oldObject != null",
},
&condition{
Expression: "object.subsets.size() > 2",
},
&condition{
Expression: "true", // zero cost
},
},
attributes: newValidAttribute(nil, false),
hasParamKind: true,
params: configMapParams,
exceedBudget: false,
testRuntimeCELCostBudget: 6,
expectRemainingBudget: pointer.Int64(0),
},
}
@ -767,19 +808,25 @@ func TestRuntimeCELCostBudget(t *testing.T) {
}
optionalVars := OptionalVariableBindings{VersionedParams: tc.params, Authorizer: tc.authorizer}
ctx := context.TODO()
evalResults, err := f.ForInput(ctx, versionedAttr, CreateAdmissionRequest(versionedAttr.Attributes), optionalVars, tc.testRuntimeCELCostBudget)
evalResults, remaining, err := f.ForInput(ctx, versionedAttr, CreateAdmissionRequest(versionedAttr.Attributes), optionalVars, tc.testRuntimeCELCostBudget)
if tc.exceedBudget && err == nil {
t.Errorf("Expected RuntimeCELCostBudge to be exceeded but got nil")
}
if tc.exceedBudget && !strings.Contains(err.Error(), "validation failed due to running out of cost budget, no further validation rules will be run") {
t.Errorf("Expected RuntimeCELCostBudge exceeded error but got: %v", err)
}
if err != nil && remaining != -1 {
t.Errorf("expected -1 remaining when error, but got %d", remaining)
}
if err != nil && !tc.exceedBudget {
t.Fatalf("unexpected error: %v", err)
}
if tc.exceedBudget && len(evalResults) != 0 {
t.Fatalf("unexpected result returned: %v", evalResults)
}
if tc.expectRemainingBudget != nil && *tc.expectRemainingBudget != remaining {
t.Errorf("wrong remaining budget, expect %d, but got %d", *tc.expectRemainingBudget, remaining)
}
})
}
}

View File

@ -92,9 +92,10 @@ type OptionalVariableBindings struct {
// by the underlying CEL code (which is indicated by the match criteria of a policy definition).
// versionedParams may be nil.
type Filter interface {
// ForInput converts compiled CEL-typed values into evaluated CEL-typed values
// ForInput converts compiled CEL-typed values into evaluated CEL-typed value.
// runtimeCELCostBudget was added for testing purpose only. Callers should always use const RuntimeCELCostBudget from k8s.io/apiserver/pkg/apis/cel/config.go as input.
ForInput(ctx context.Context, versionedAttr *admission.VersionedAttributes, request *v1.AdmissionRequest, optionalVars OptionalVariableBindings, runtimeCELCostBudget int64) ([]EvaluationResult, error)
// If cost budget is calculated, the filter should return the remaining budget.
ForInput(ctx context.Context, versionedAttr *admission.VersionedAttributes, request *v1.AdmissionRequest, optionalVars OptionalVariableBindings, runtimeCELCostBudget int64) ([]EvaluationResult, int64, error)
// CompilationErrors returns a list of errors from the compilation of the evaluator
CompilationErrors() []error

View File

@ -212,7 +212,7 @@ func (f *fakeCompiler) Compile(
options cel.OptionalVariableDeclarations,
perCallLimit uint64,
) cel.Filter {
if len(expressions) > 0 {
if len(expressions) > 0 && expressions[0] != nil {
key := expressions[0].GetExpression()
if fun, ok := f.CompileFuncs[key]; ok {
return fun(expressions, options)
@ -252,8 +252,8 @@ type fakeFilter struct {
keyId string
}
func (f *fakeFilter) ForInput(ctx context.Context, versionedAttr *admission.VersionedAttributes, request *admissionv1.AdmissionRequest, inputs cel.OptionalVariableBindings, runtimeCELCostBudget int64) ([]cel.EvaluationResult, error) {
return []cel.EvaluationResult{}, nil
func (f *fakeFilter) ForInput(ctx context.Context, versionedAttr *admission.VersionedAttributes, request *admissionv1.AdmissionRequest, inputs cel.OptionalVariableBindings, runtimeCELCostBudget int64) ([]cel.EvaluationResult, int64, error) {
return []cel.EvaluationResult{}, 0, nil
}
func (f *fakeFilter) CompilationErrors() []error {
@ -263,8 +263,8 @@ func (f *fakeFilter) CompilationErrors() []error {
var _ Validator = &fakeValidator{}
type fakeValidator struct {
validationFilter, auditAnnotationFilter *fakeFilter
ValidateFunc func(ctx context.Context, versionedAttr *admission.VersionedAttributes, versionedParams runtime.Object, runtimeCELCostBudget int64) ValidateResult
validationFilter, auditAnnotationFilter, messageFilter *fakeFilter
ValidateFunc func(ctx context.Context, versionedAttr *admission.VersionedAttributes, versionedParams runtime.Object, runtimeCELCostBudget int64) ValidateResult
}
func (f *fakeValidator) RegisterDefinition(definition *v1alpha1.ValidatingAdmissionPolicy, validateFunc func(ctx context.Context, versionedAttr *admission.VersionedAttributes, versionedParams runtime.Object, runtimeCELCostBudget int64) ValidateResult) {
@ -418,10 +418,11 @@ func setupTestCommon(t *testing.T, compiler cel.FilterCompiler, matcher Matcher,
// Override compiler used by controller for tests
controller = handler.evaluator.(*celAdmissionController)
controller.policyController.filterCompiler = compiler
controller.policyController.newValidator = func(validationFilter, auditAnnotationFilter cel.Filter, fail *admissionRegistrationv1.FailurePolicyType, authorizer authorizer.Authorizer) Validator {
controller.policyController.newValidator = func(validationFilter, auditAnnotationFilter, messageFilter cel.Filter, fail *admissionRegistrationv1.FailurePolicyType, authorizer authorizer.Authorizer) Validator {
f := validationFilter.(*fakeFilter)
v := validatorMap[f.keyId]
v.validationFilter = f
v.messageFilter = f
v.auditAnnotationFilter = auditAnnotationFilter.(*fakeFilter)
return v
}

View File

@ -92,7 +92,7 @@ type policyController struct {
authz authorizer.Authorizer
}
type newValidator func(validationFilter cel.Filter, auditAnnotationFilter cel.Filter, failurePolicy *v1.FailurePolicyType, authorizer authorizer.Authorizer) Validator
type newValidator func(validationFilter cel.Filter, auditAnnotationFilter cel.Filter, messageFilter cel.Filter, failurePolicy *v1.FailurePolicyType, authorizer authorizer.Authorizer) Validator
func newPolicyController(
restMapper meta.RESTMapper,
@ -459,9 +459,11 @@ func (c *policyController) latestPolicyData() []policyData {
hasParam = true
}
optionalVars := cel.OptionalVariableDeclarations{HasParams: hasParam, HasAuthorizer: true}
expressionOptionalVars := cel.OptionalVariableDeclarations{HasParams: hasParam, HasAuthorizer: false}
bindingInfo.validator = c.newValidator(
c.filterCompiler.Compile(convertv1alpha1Validations(definitionInfo.lastReconciledValue.Spec.Validations), optionalVars, celconfig.PerCallLimit),
c.filterCompiler.Compile(convertv1alpha1AuditAnnotations(definitionInfo.lastReconciledValue.Spec.AuditAnnotations), optionalVars, celconfig.PerCallLimit),
c.filterCompiler.Compile(convertV1Alpha1MessageExpressions(definitionInfo.lastReconciledValue.Spec.Validations), expressionOptionalVars, celconfig.PerCallLimit),
convertv1alpha1FailurePolicyTypeTov1FailurePolicyType(definitionInfo.lastReconciledValue.Spec.FailurePolicy),
c.authz,
)
@ -514,6 +516,19 @@ func convertv1alpha1Validations(inputValidations []v1alpha1.Validation) []cel.Ex
return celExpressionAccessor
}
func convertV1Alpha1MessageExpressions(inputValidations []v1alpha1.Validation) []cel.ExpressionAccessor {
celExpressionAccessor := make([]cel.ExpressionAccessor, len(inputValidations))
for i, validation := range inputValidations {
if validation.MessageExpression != "" {
condition := MessageExpressionCondition{
MessageExpression: validation.MessageExpression,
}
celExpressionAccessor[i] = &condition
}
}
return celExpressionAccessor
}
func convertv1alpha1AuditAnnotations(inputValidations []v1alpha1.AuditAnnotation) []cel.ExpressionAccessor {
celExpressionAccessor := make([]cel.ExpressionAccessor, len(inputValidations))
for i, validation := range inputValidations {

View File

@ -0,0 +1,36 @@
/*
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 validatingadmissionpolicy
import (
celgo "github.com/google/cel-go/cel"
"k8s.io/apiserver/pkg/admission/plugin/cel"
)
var _ cel.ExpressionAccessor = (*MessageExpressionCondition)(nil)
type MessageExpressionCondition struct {
MessageExpression string
}
func (m *MessageExpressionCondition) GetExpression() string {
return m.MessageExpression
}
func (m *MessageExpressionCondition) ReturnTypes() []*celgo.Type {
return []*celgo.Type{celgo.StringType}
}

View File

@ -30,21 +30,25 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/admission/plugin/cel"
celconfig "k8s.io/apiserver/pkg/apis/cel"
"k8s.io/apiserver/pkg/authorization/authorizer"
apiservercel "k8s.io/apiserver/pkg/cel"
)
// validator implements the Validator interface
type validator struct {
validationFilter cel.Filter
auditAnnotationFilter cel.Filter
messageFilter cel.Filter
failPolicy *v1.FailurePolicyType
authorizer authorizer.Authorizer
}
func NewValidator(validationFilter, auditAnnotationFilter cel.Filter, failPolicy *v1.FailurePolicyType, authorizer authorizer.Authorizer) Validator {
func NewValidator(validationFilter, auditAnnotationFilter, messageFilter cel.Filter, failPolicy *v1.FailurePolicyType, authorizer authorizer.Authorizer) Validator {
return &validator{
validationFilter: validationFilter,
auditAnnotationFilter: auditAnnotationFilter,
messageFilter: messageFilter,
failPolicy: failPolicy,
authorizer: authorizer,
}
@ -75,7 +79,9 @@ func (v *validator) Validate(ctx context.Context, versionedAttr *admission.Versi
}
optionalVars := cel.OptionalVariableBindings{VersionedParams: versionedParams, Authorizer: v.authorizer}
evalResults, err := v.validationFilter.ForInput(ctx, versionedAttr, cel.CreateAdmissionRequest(versionedAttr.Attributes), optionalVars, runtimeCELCostBudget)
expressionOptionalVars := cel.OptionalVariableBindings{VersionedParams: versionedParams}
admissionRequest := cel.CreateAdmissionRequest(versionedAttr.Attributes)
evalResults, remainingBudget, err := v.validationFilter.ForInput(ctx, versionedAttr, admissionRequest, optionalVars, runtimeCELCostBudget)
if err != nil {
return ValidateResult{
Decisions: []PolicyDecision{
@ -88,7 +94,7 @@ func (v *validator) Validate(ctx context.Context, versionedAttr *admission.Versi
}
}
decisions := make([]PolicyDecision, len(evalResults))
messageResults, _, err := v.messageFilter.ForInput(ctx, versionedAttr, admissionRequest, expressionOptionalVars, remainingBudget)
for i, evalResult := range evalResults {
var decision = &decisions[i]
// TODO: move this to generics
@ -101,10 +107,23 @@ func (v *validator) Validate(ctx context.Context, versionedAttr *admission.Versi
continue
}
var messageResult *cel.EvaluationResult
var messageError *apiservercel.Error
if len(messageResults) > i {
messageResult = &messageResults[i]
}
messageError, _ = err.(*apiservercel.Error)
if evalResult.Error != nil {
decision.Action = policyDecisionActionForError(f)
decision.Evaluation = EvalError
decision.Message = evalResult.Error.Error()
} else if messageError != nil &&
(messageError.Type == apiservercel.ErrorTypeInternal ||
(messageError.Type == apiservercel.ErrorTypeInvalid &&
strings.HasPrefix(messageError.Detail, "validation failed due to running out of cost budget"))) {
decision.Action = policyDecisionActionForError(f)
decision.Evaluation = EvalError
decision.Message = fmt.Sprintf("failed messageExpression: %s", err)
} else if evalResult.EvalResult != celtypes.True {
decision.Action = ActionDeny
if validation.Reason == nil {
@ -112,11 +131,39 @@ func (v *validator) Validate(ctx context.Context, versionedAttr *admission.Versi
} else {
decision.Reason = *validation.Reason
}
if len(validation.Message) > 0 {
decision.Message = strings.TrimSpace(validation.Message)
} else {
decision.Message = fmt.Sprintf("failed expression: %v", strings.TrimSpace(validation.Expression))
// decide the failure message
var message string
// attempt to set message with messageExpression result
if messageResult != nil && messageResult.Error == nil && messageResult.EvalResult != nil {
// also fallback if the eval result is non-string (including null) or
// whitespaces.
if message, ok = messageResult.EvalResult.Value().(string); ok {
message = strings.TrimSpace(message)
// deny excessively long message from EvalResult
if len(message) > celconfig.MaxEvaluatedMessageExpressionSizeBytes {
klog.V(2).InfoS("excessively long message denied", "message", message)
message = ""
}
// deny message that contains newlines
if strings.ContainsAny(message, "\n") {
klog.V(2).InfoS("multi-line message denied", "message", message)
message = ""
}
}
}
if messageResult != nil && messageResult.Error != nil {
// log any error with messageExpression
klog.V(2).ErrorS(messageResult.Error, "error while evaluating messageExpression")
}
// fallback to set message to the custom message
if message == "" && len(validation.Message) > 0 {
message = strings.TrimSpace(validation.Message)
}
// fallback to use the expression to compose a message
if message == "" {
message = fmt.Sprintf("failed expression: %v", strings.TrimSpace(validation.Expression))
}
decision.Message = message
} else {
decision.Action = ActionAdmit
decision.Evaluation = EvalAdmit
@ -124,7 +171,7 @@ func (v *validator) Validate(ctx context.Context, versionedAttr *admission.Versi
}
options := cel.OptionalVariableBindings{VersionedParams: versionedParams}
auditAnnotationEvalResults, err := v.auditAnnotationFilter.ForInput(ctx, versionedAttr, cel.CreateAdmissionRequest(versionedAttr.Attributes), options, runtimeCELCostBudget)
auditAnnotationEvalResults, _, err := v.auditAnnotationFilter.ForInput(ctx, versionedAttr, cel.CreateAdmissionRequest(versionedAttr.Attributes), options, runtimeCELCostBudget)
if err != nil {
return ValidateResult{
Decisions: []PolicyDecision{
@ -139,6 +186,9 @@ func (v *validator) Validate(ctx context.Context, versionedAttr *admission.Versi
auditAnnotationResults := make([]PolicyAuditAnnotation, len(auditAnnotationEvalResults))
for i, evalResult := range auditAnnotationEvalResults {
if evalResult.ExpressionAccessor == nil {
continue
}
var auditAnnotationResult = &auditAnnotationResults[i]
// TODO: move this to generics
validation, ok := evalResult.ExpressionAccessor.(*AuditAnnotationCondition)

View File

@ -34,6 +34,7 @@ import (
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/admission/plugin/cel"
celconfig "k8s.io/apiserver/pkg/apis/cel"
apiservercel "k8s.io/apiserver/pkg/cel"
)
var _ cel.Filter = &fakeCelFilter{}
@ -43,11 +44,17 @@ type fakeCelFilter struct {
throwError bool
}
func (f *fakeCelFilter) ForInput(context.Context, *admission.VersionedAttributes, *admissionv1.AdmissionRequest, cel.OptionalVariableBindings, int64) ([]cel.EvaluationResult, error) {
if f.throwError {
return nil, errors.New("test error")
func (f *fakeCelFilter) ForInput(_ context.Context, _ *admission.VersionedAttributes, _ *admissionv1.AdmissionRequest, _ cel.OptionalVariableBindings, costBudget int64) ([]cel.EvaluationResult, int64, error) {
if costBudget <= 0 { // this filter will cost 1, so cost = 0 means fail.
return nil, -1, &apiservercel.Error{
Type: apiservercel.ErrorTypeInvalid,
Detail: fmt.Sprintf("validation failed due to running out of cost budget, no further validation rules will be run"),
}
}
return f.evaluations, nil
if f.throwError {
return nil, -1, errors.New("test error")
}
return f.evaluations, costBudget - 1, nil
}
func (f *fakeCelFilter) CompilationErrors() []error {
@ -65,13 +72,15 @@ func TestValidate(t *testing.T) {
fakeVersionedAttr, _ := admission.NewVersionedAttributes(fakeAttr, schema.GroupVersionKind{}, nil)
cases := []struct {
name string
failPolicy *v1.FailurePolicyType
evaluations []cel.EvaluationResult
auditEvaluations []cel.EvaluationResult
policyDecision []PolicyDecision
auditAnnotations []PolicyAuditAnnotation
throwError bool
name string
failPolicy *v1.FailurePolicyType
evaluations []cel.EvaluationResult
messageEvaluations []cel.EvaluationResult
auditEvaluations []cel.EvaluationResult
policyDecision []PolicyDecision
auditAnnotations []PolicyAuditAnnotation
throwError bool
costBudget int64 // leave zero to use default
}{
{
name: "test pass",
@ -572,6 +581,244 @@ func TestValidate(t *testing.T) {
},
failPolicy: &ignore,
},
{
name: "messageExpression successful, empty message",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.False,
ExpressionAccessor: &ValidationCondition{
Reason: &forbiddenReason,
Expression: "this.expression == unit.test",
},
},
},
messageEvaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.String("evaluated message"),
},
},
policyDecision: []PolicyDecision{
{
Action: ActionDeny,
Message: "evaluated message",
Reason: forbiddenReason,
},
},
},
{
name: "messageExpression for multiple validations",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.False,
ExpressionAccessor: &ValidationCondition{
Reason: &forbiddenReason,
Expression: "this.expression == unit.test",
Message: "I am not overwritten",
},
},
{
EvalResult: celtypes.False,
ExpressionAccessor: &ValidationCondition{
Reason: &forbiddenReason,
Expression: "this.expression == unit.test",
Message: "I am overwritten",
},
},
},
messageEvaluations: []cel.EvaluationResult{
{},
{
EvalResult: celtypes.String("evaluated message"),
},
},
policyDecision: []PolicyDecision{
{
Action: ActionDeny,
Message: "I am not overwritten",
Reason: forbiddenReason,
},
{
Action: ActionDeny,
Message: "evaluated message",
Reason: forbiddenReason,
},
},
},
{
name: "messageExpression successful, overwritten message",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.False,
ExpressionAccessor: &ValidationCondition{
Reason: &forbiddenReason,
Expression: "this.expression == unit.test",
Message: "I am overwritten",
},
},
},
messageEvaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.String("evaluated message"),
},
},
policyDecision: []PolicyDecision{
{
Action: ActionDeny,
Message: "evaluated message",
Reason: forbiddenReason,
},
},
},
{
name: "messageExpression user failure",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.False,
ExpressionAccessor: &ValidationCondition{
Reason: &forbiddenReason,
Expression: "this.expression == unit.test",
Message: "test1",
},
},
},
messageEvaluations: []cel.EvaluationResult{
{
Error: &apiservercel.Error{Type: apiservercel.ErrorTypeInvalid},
},
},
policyDecision: []PolicyDecision{
{
Action: ActionDeny,
Message: "test1", // original message used
Reason: forbiddenReason,
},
},
},
{
name: "messageExpression eval to empty",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.False,
ExpressionAccessor: &ValidationCondition{
Reason: &forbiddenReason,
Expression: "this.expression == unit.test",
Message: "test1",
},
},
},
messageEvaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.String(" "),
},
},
policyDecision: []PolicyDecision{
{
Action: ActionDeny,
Message: "test1",
Reason: forbiddenReason,
},
},
},
{
name: "messageExpression eval to multi-line",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.False,
ExpressionAccessor: &ValidationCondition{
Reason: &forbiddenReason,
Expression: "this.expression == unit.test",
Message: "test1",
},
},
},
messageEvaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.String("hello\nthere"),
},
},
policyDecision: []PolicyDecision{
{
Action: ActionDeny,
Message: "test1",
Reason: forbiddenReason,
},
},
},
{
name: "messageExpression eval result too long",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.False,
ExpressionAccessor: &ValidationCondition{
Reason: &forbiddenReason,
Expression: "this.expression == unit.test",
Message: "test1",
},
},
},
messageEvaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.String(strings.Repeat("x", 5*1024+1)),
},
},
policyDecision: []PolicyDecision{
{
Action: ActionDeny,
Message: "test1",
Reason: forbiddenReason,
},
},
},
{
name: "messageExpression eval to null",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.False,
ExpressionAccessor: &ValidationCondition{
Reason: &forbiddenReason,
Expression: "this.expression == unit.test",
Message: "test1",
},
},
},
messageEvaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.NullValue,
},
},
policyDecision: []PolicyDecision{
{
Action: ActionDeny,
Message: "test1",
Reason: forbiddenReason,
},
},
},
{
name: "messageExpression out of budget after successful eval of expression",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.False,
ExpressionAccessor: &ValidationCondition{
Reason: &forbiddenReason,
Expression: "this.expression == unit.test",
Message: "test1",
},
},
},
messageEvaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.StringType, // does not matter
},
},
policyDecision: []PolicyDecision{
{
Action: ActionDeny,
Message: "running out of cost budget",
},
},
costBudget: 1, // shared between expression and messageExpression, needs 1 + 1 = 2 in total
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@ -581,13 +828,21 @@ func TestValidate(t *testing.T) {
evaluations: tc.evaluations,
throwError: tc.throwError,
},
messageFilter: &fakeCelFilter{
evaluations: tc.messageEvaluations,
throwError: tc.throwError,
},
auditAnnotationFilter: &fakeCelFilter{
evaluations: tc.auditEvaluations,
throwError: tc.throwError,
},
}
ctx := context.TODO()
validateResult := v.Validate(ctx, fakeVersionedAttr, nil, celconfig.RuntimeCELCostBudget)
var budget int64 = celconfig.RuntimeCELCostBudget
if tc.costBudget != 0 {
budget = tc.costBudget
}
validateResult := v.Validate(ctx, fakeVersionedAttr, nil, budget)
require.Equal(t, len(validateResult.Decisions), len(tc.policyDecision))
@ -630,6 +885,7 @@ func TestContextCanceled(t *testing.T) {
v := validator{
failPolicy: &fail,
validationFilter: f,
messageFilter: f,
auditAnnotationFilter: &fakeCelFilter{
evaluations: nil,
throwError: false,