diff --git a/pkg/admission/plugin/cel/compile.go b/pkg/admission/plugin/cel/compile.go index f6b2605ac..ff1eddf70 100644 --- a/pkg/admission/plugin/cel/compile.go +++ b/pkg/admission/plugin/cel/compile.go @@ -17,6 +17,7 @@ limitations under the License. package cel import ( + "fmt" "sync" "github.com/google/cel-go/cel" @@ -26,43 +27,35 @@ import ( ) const ( - ObjectVarName = "object" - OldObjectVarName = "oldObject" - ParamsVarName = "params" - RequestVarName = "request" + ObjectVarName = "object" + OldObjectVarName = "oldObject" + ParamsVarName = "params" + RequestVarName = "request" + AuthorizerVarName = "authorizer" + RequestResourceAuthorizerVarName = "authorizer.requestResource" checkFrequency = 100 ) -type envs struct { - noParams *cel.Env - withParams *cel.Env -} - var ( initEnvsOnce sync.Once - initEnvs *envs + initEnvs envs initEnvsErr error ) -func getEnvs() (*envs, error) { +func getEnvs() (envs, error) { initEnvsOnce.Do(func() { - base, err := buildBaseEnv() + requiredVarsEnv, err := buildRequiredVarsEnv() if err != nil { initEnvsErr = err return } - noParams, err := buildNoParamsEnv(base) + + initEnvs, err = buildWithOptionalVarsEnvs(requiredVarsEnv) if err != nil { initEnvsErr = err return } - withParams, err := buildWithParamsEnv(noParams) - if err != nil { - initEnvsErr = err - return - } - initEnvs = &envs{noParams: noParams, withParams: withParams} }) return initEnvs, initEnvsErr } @@ -81,7 +74,11 @@ func buildBaseEnv() (*cel.Env, error) { return cel.NewEnv(opts...) } -func buildNoParamsEnv(baseEnv *cel.Env) (*cel.Env, error) { +func buildRequiredVarsEnv() (*cel.Env, error) { + baseEnv, err := buildBaseEnv() + if err != nil { + return nil, err + } var propDecls []cel.EnvOption reg := apiservercel.NewRegistry(baseEnv) @@ -109,8 +106,33 @@ func buildNoParamsEnv(baseEnv *cel.Env) (*cel.Env, error) { return env, nil } -func buildWithParamsEnv(noParams *cel.Env) (*cel.Env, error) { - return noParams.Extend(cel.Variable(ParamsVarName, cel.DynType)) +type envs map[OptionalVariableDeclarations]*cel.Env + +func buildEnvWithVars(baseVarsEnv *cel.Env, options OptionalVariableDeclarations) (*cel.Env, error) { + var opts []cel.EnvOption + if options.HasParams { + opts = append(opts, cel.Variable(ParamsVarName, cel.DynType)) + } + if options.HasAuthorizer { + opts = append(opts, cel.Variable(AuthorizerVarName, library.AuthorizerType)) + opts = append(opts, cel.Variable(RequestResourceAuthorizerVarName, library.ResourceCheckType)) + } + return baseVarsEnv.Extend(opts...) +} + +func buildWithOptionalVarsEnvs(requiredVarsEnv *cel.Env) (envs, error) { + envs := make(envs, 4) // since the number of variable combinations is small, pre-build a environment for each + for _, hasParams := range []bool{false, true} { + for _, hasAuthorizer := range []bool{false, true} { + opts := OptionalVariableDeclarations{HasParams: hasParams, HasAuthorizer: hasAuthorizer} + env, err := buildEnvWithVars(requiredVarsEnv, opts) + if err != nil { + return nil, err + } + envs[opts] = env + } + } + return envs, nil } // buildRequestType generates a DeclType for AdmissionRequest. This may be replaced with a utility that @@ -168,7 +190,7 @@ type CompilationResult struct { } // CompileCELExpression returns a compiled CEL expression. -func CompileCELExpression(expressionAccessor ExpressionAccessor, hasParams bool) CompilationResult { +func CompileCELExpression(expressionAccessor ExpressionAccessor, optionalVars OptionalVariableDeclarations) CompilationResult { var env *cel.Env envs, err := getEnvs() if err != nil { @@ -180,10 +202,15 @@ func CompileCELExpression(expressionAccessor ExpressionAccessor, hasParams bool) ExpressionAccessor: expressionAccessor, } } - if hasParams { - env = envs.withParams - } else { - env = envs.noParams + env, ok := envs[optionalVars] + if !ok { + return CompilationResult{ + Error: &apiservercel.Error{ + Type: apiservercel.ErrorTypeInvalid, + Detail: fmt.Sprintf("compiler initialization failed: failed to load environment for %v", optionalVars), + }, + ExpressionAccessor: expressionAccessor, + } } ast, issues := env.Compile(expressionAccessor.GetExpression()) diff --git a/pkg/admission/plugin/cel/compile_test.go b/pkg/admission/plugin/cel/compile_test.go index ea427607c..8d58a378a 100644 --- a/pkg/admission/plugin/cel/compile_test.go +++ b/pkg/admission/plugin/cel/compile_test.go @@ -26,6 +26,7 @@ func TestCompileValidatingPolicyExpression(t *testing.T) { name string expressions []string hasParams bool + hasAuthorizer bool errorExpressions map[string]string }{ { @@ -99,6 +100,19 @@ func TestCompileValidatingPolicyExpression(t *testing.T) { "request.userInfo.foo5 == 'nope'": "undefined field 'foo5'", }, }, + { + name: "with authorizer", + hasAuthorizer: true, + expressions: []string{ + "authorizer.group('') != null", + }, + }, + { + name: "without authorizer", + errorExpressions: map[string]string{ + "authorizer.group('') != null": "undeclared reference to 'authorizer'", + }, + }, } for _, tc := range cases { @@ -106,7 +120,7 @@ func TestCompileValidatingPolicyExpression(t *testing.T) { for _, expr := range tc.expressions { result := CompileCELExpression(&fakeExpressionAccessor{ expr, - }, tc.hasParams) + }, OptionalVariableDeclarations{HasParams: tc.hasParams, HasAuthorizer: true}) if result.Error != nil { t.Errorf("Unexpected error: %v", result.Error) } @@ -114,7 +128,7 @@ func TestCompileValidatingPolicyExpression(t *testing.T) { for expr, expectErr := range tc.errorExpressions { result := CompileCELExpression(&fakeExpressionAccessor{ expr, - }, tc.hasParams) + }, OptionalVariableDeclarations{HasParams: tc.hasParams, HasAuthorizer: tc.hasAuthorizer}) if result.Error == nil { t.Errorf("Expected expression '%s' to contain '%v' but got no error", expr, expectErr) continue diff --git a/pkg/admission/plugin/cel/filter.go b/pkg/admission/plugin/cel/filter.go index 4e152a0bb..fde222959 100644 --- a/pkg/admission/plugin/cel/filter.go +++ b/pkg/admission/plugin/cel/filter.go @@ -31,6 +31,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" + "k8s.io/apiserver/pkg/cel/library" ) // filterCompiler implement the interface FilterCompiler. @@ -42,7 +43,7 @@ func NewFilterCompiler() FilterCompiler { } type evaluationActivation struct { - object, oldObject, params, request interface{} + object, oldObject, params, request, authorizer, requestResourceAuthorizer interface{} } // ResolveName returns a value from the activation by qualified name, or false if the name @@ -54,9 +55,13 @@ func (a *evaluationActivation) ResolveName(name string) (interface{}, bool) { case OldObjectVarName: return a.oldObject, true case ParamsVarName: - return a.params, true + return a.params, true // params may be null case RequestVarName: return a.request, true + case AuthorizerVarName: + return a.authorizer, a.authorizer != nil + case RequestResourceAuthorizerVarName: + return a.requestResourceAuthorizer, a.requestResourceAuthorizer != nil default: return nil, false } @@ -69,13 +74,13 @@ func (a *evaluationActivation) Parent() interpreter.Activation { } // Compile compiles the cel expressions defined in the ExpressionAccessors into a Filter -func (c *filterCompiler) Compile(expressionAccessors []ExpressionAccessor, hasParam bool) Filter { +func (c *filterCompiler) Compile(expressionAccessors []ExpressionAccessor, options OptionalVariableDeclarations) Filter { if len(expressionAccessors) == 0 { return nil } compilationResults := make([]CompilationResult, len(expressionAccessors)) for i, expressionAccessor := range expressionAccessors { - compilationResults[i] = CompileCELExpression(expressionAccessor, hasParam) + compilationResults[i] = CompileCELExpression(expressionAccessor, options) } return NewFilter(compilationResults) } @@ -113,9 +118,9 @@ func objectToResolveVal(r runtime.Object) (interface{}, error) { return v.Object, nil } -// Evaluate evaluates the compiled CEL expressions converting them into CELEvaluations +// ForInput evaluates the compiled CEL expressions converting them into CELEvaluations // errors per evaluation are returned on the Evaluation object -func (f *filter) ForInput(versionedAttr *generic.VersionedAttributes, versionedParams runtime.Object, request *admissionv1.AdmissionRequest) ([]EvaluationResult, error) { +func (f *filter) ForInput(versionedAttr *generic.VersionedAttributes, request *admissionv1.AdmissionRequest, inputs OptionalVariableBindings) ([]EvaluationResult, 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 @@ -128,9 +133,17 @@ func (f *filter) ForInput(versionedAttr *generic.VersionedAttributes, versionedP if err != nil { return nil, err } - paramsVal, err := objectToResolveVal(versionedParams) - if err != nil { - return nil, err + var paramsVal, authorizerVal, requestResourceAuthorizerVal any + if inputs.VersionedParams != nil { + paramsVal, err = objectToResolveVal(inputs.VersionedParams) + if err != nil { + return nil, err + } + } + + if inputs.Authorizer != nil { + authorizerVal = library.NewAuthorizerVal(versionedAttr.GetUserInfo(), inputs.Authorizer) + requestResourceAuthorizerVal = library.NewResourceAuthorizerVal(versionedAttr.GetUserInfo(), inputs.Authorizer, versionedAttr) } requestVal, err := convertObjectToUnstructured(request) @@ -138,10 +151,12 @@ func (f *filter) ForInput(versionedAttr *generic.VersionedAttributes, versionedP return nil, err } va := &evaluationActivation{ - object: objectVal, - oldObject: oldObjectVal, - params: paramsVal, - request: requestVal.Object, + object: objectVal, + oldObject: oldObjectVal, + params: paramsVal, + request: requestVal.Object, + authorizer: authorizerVal, + requestResourceAuthorizer: requestResourceAuthorizerVal, } for i, compilationResult := range f.compilationResults { diff --git a/pkg/admission/plugin/cel/filter_test.go b/pkg/admission/plugin/cel/filter_test.go index c2a4cc696..5fe00ffa7 100644 --- a/pkg/admission/plugin/cel/filter_test.go +++ b/pkg/admission/plugin/cel/filter_test.go @@ -17,12 +17,18 @@ limitations under the License. package cel import ( + "context" "errors" + "fmt" + "reflect" "strings" "testing" celtypes "github.com/google/cel-go/common/types" "github.com/stretchr/testify/require" + + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" apiservercel "k8s.io/apiserver/pkg/cel" corev1 "k8s.io/api/core/v1" @@ -81,7 +87,7 @@ func TestCompile(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var c filterCompiler - e := c.Compile(tc.validation, false) + e := c.Compile(tc.validation, OptionalVariableDeclarations{HasParams: false, HasAuthorizer: false}) if e == nil { t.Fatalf("unexpected nil validator") } @@ -144,6 +150,7 @@ func TestFilter(t *testing.T) { validations []ExpressionAccessor results []EvaluationResult hasParamKind bool + authorizer authorizer.Authorizer }{ { name: "valid syntax for object", @@ -417,12 +424,204 @@ func TestFilter(t *testing.T) { hasParamKind: true, params: runtime.Object(nilUnstructured), }, + { + name: "test authorizer allow resource check", + validations: []ExpressionAccessor{ + &condition{ + Expression: "authorizer.group('').resource('endpoints').check('create').allowed()", + }, + }, + attributes: newValidAttribute(&podObject, false), + results: []EvaluationResult{ + { + EvalResult: celtypes.True, + }, + }, + authorizer: newAuthzAllowMatch(authorizer.AttributesRecord{ + ResourceRequest: true, + Resource: "endpoints", + Verb: "create", + APIVersion: "*", + }), + }, + { + name: "test authorizer allow resource check with all fields", + validations: []ExpressionAccessor{ + &condition{ + Expression: "authorizer.group('apps').resource('deployments').subresource('status').namespace('test').name('backend').check('create').allowed()", + }, + }, + attributes: newValidAttribute(&podObject, false), + results: []EvaluationResult{ + { + EvalResult: celtypes.True, + }, + }, + authorizer: newAuthzAllowMatch(authorizer.AttributesRecord{ + ResourceRequest: true, + APIGroup: "apps", + Resource: "deployments", + Subresource: "status", + Namespace: "test", + Name: "backend", + Verb: "create", + APIVersion: "*", + }), + }, + { + name: "test authorizer not allowed resource check one incorrect field", + validations: []ExpressionAccessor{ + &condition{ + + Expression: "authorizer.group('apps').resource('deployments').subresource('status').namespace('test').name('backend').check('create').allowed()", + }, + }, + attributes: newValidAttribute(&podObject, false), + results: []EvaluationResult{ + { + EvalResult: celtypes.False, + }, + }, + authorizer: newAuthzAllowMatch(authorizer.AttributesRecord{ + ResourceRequest: true, + APIGroup: "apps", + Resource: "deployments-xxxx", + Subresource: "status", + Namespace: "test", + Name: "backend", + Verb: "create", + APIVersion: "*", + }), + }, + { + name: "test authorizer reason", + validations: []ExpressionAccessor{ + &condition{ + Expression: "authorizer.group('').resource('endpoints').check('create').reason() == 'fake reason'", + }, + }, + attributes: newValidAttribute(&podObject, false), + results: []EvaluationResult{ + { + EvalResult: celtypes.True, + }, + }, + authorizer: denyAll, + }, + { + name: "test authorizer allow path check", + validations: []ExpressionAccessor{ + &condition{ + Expression: "authorizer.path('/healthz').check('get').allowed()", + }, + }, + attributes: newValidAttribute(&podObject, false), + results: []EvaluationResult{ + { + EvalResult: celtypes.True, + }, + }, + authorizer: newAuthzAllowMatch(authorizer.AttributesRecord{ + Path: "/healthz", + Verb: "get", + }), + }, + { + name: "test authorizer decision is denied path check", + validations: []ExpressionAccessor{ + &condition{ + Expression: "authorizer.path('/healthz').check('get').allowed() == false", + }, + }, + attributes: newValidAttribute(&podObject, false), + results: []EvaluationResult{ + { + EvalResult: celtypes.True, + }, + }, + authorizer: denyAll, + }, + { + name: "test request resource authorizer allow check", + validations: []ExpressionAccessor{ + &condition{ + Expression: "authorizer.requestResource.check('custom-verb').allowed()", + }, + }, + attributes: endpointCreateAttributes(), + results: []EvaluationResult{ + { + EvalResult: celtypes.True, + }, + }, + authorizer: newAuthzAllowMatch(authorizer.AttributesRecord{ + ResourceRequest: true, + APIGroup: "", + Resource: "endpoints", + Subresource: "", + Namespace: "default", + Name: "endpoints1", + Verb: "custom-verb", + APIVersion: "*", + }), + }, + { + name: "test subresource request resource authorizer allow check", + validations: []ExpressionAccessor{ + &condition{ + Expression: "authorizer.requestResource.check('custom-verb').allowed()", + }, + }, + attributes: endpointStatusUpdateAttributes(), + results: []EvaluationResult{ + { + EvalResult: celtypes.True, + }, + }, + authorizer: newAuthzAllowMatch(authorizer.AttributesRecord{ + ResourceRequest: true, + APIGroup: "", + Resource: "endpoints", + Subresource: "status", + Namespace: "default", + Name: "endpoints1", + Verb: "custom-verb", + APIVersion: "*", + }), + }, + { + name: "test serviceAccount authorizer allow check", + validations: []ExpressionAccessor{ + &condition{ + Expression: "authorizer.serviceAccount('default', 'test-serviceaccount').group('').resource('endpoints').namespace('default').name('endpoints1').check('custom-verb').allowed()", + }, + }, + attributes: endpointCreateAttributes(), + results: []EvaluationResult{ + { + EvalResult: celtypes.True, + }, + }, + authorizer: newAuthzAllowMatch(authorizer.AttributesRecord{ + User: &user.DefaultInfo{ + Name: "system:serviceaccount:default:test-serviceaccount", + Groups: []string{"system:serviceaccounts", "system:serviceaccounts:default"}, + }, + ResourceRequest: true, + APIGroup: "", + Resource: "endpoints", + Namespace: "default", + Name: "endpoints1", + Verb: "custom-verb", + APIVersion: "*", + }), + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { c := filterCompiler{} - f := c.Compile(tc.validations, tc.hasParamKind) + f := c.Compile(tc.validations, OptionalVariableDeclarations{HasParams: tc.hasParamKind, HasAuthorizer: tc.authorizer != nil}) if f == nil { t.Fatalf("unexpected nil validator") } @@ -435,7 +634,8 @@ func TestFilter(t *testing.T) { t.Fatalf("unexpected error on conversion: %v", err) } - evalResults, err := f.ForInput(versionedAttr, tc.params, CreateAdmissionRequest(versionedAttr.Attributes)) + optionalVars := OptionalVariableBindings{VersionedParams: tc.params, Authorizer: tc.authorizer} + evalResults, err := f.ForInput(versionedAttr, CreateAdmissionRequest(versionedAttr.Attributes), optionalVars) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -582,3 +782,74 @@ func TestCompilationErrors(t *testing.T) { }) } } + +var denyAll = fakeAuthorizer{defaultResult: authorizerResult{decision: authorizer.DecisionDeny, reason: "fake reason", err: nil}} + +func newAuthzAllowMatch(match authorizer.AttributesRecord) fakeAuthorizer { + return fakeAuthorizer{ + match: &authorizerMatch{ + match: match, + authorizerResult: authorizerResult{decision: authorizer.DecisionAllow, reason: "", err: nil}, + }, + defaultResult: authorizerResult{decision: authorizer.DecisionDeny, reason: "", err: nil}, + } +} + +type fakeAuthorizer struct { + match *authorizerMatch + defaultResult authorizerResult +} + +type authorizerResult struct { + decision authorizer.Decision + reason string + err error +} + +type authorizerMatch struct { + authorizerResult + match authorizer.AttributesRecord +} + +func (f fakeAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { + if f.match != nil { + other, ok := a.(*authorizer.AttributesRecord) + if !ok { + panic(fmt.Sprintf("unsupported type: %T", a)) + } + if reflect.DeepEqual(f.match.match, *other) { + return f.match.decision, f.match.reason, f.match.err + } + } + return f.defaultResult.decision, f.defaultResult.reason, f.defaultResult.err +} + +func endpointCreateAttributes() admission.Attributes { + name := "endpoints1" + namespace := "default" + var object, oldObject runtime.Object + object = &corev1.Endpoints{ + TypeMeta: metav1.TypeMeta{ + Kind: "Endpoints", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Subsets: []corev1.EndpointSubset{ + { + Addresses: []corev1.EndpointAddress{{IP: "127.0.0.0"}}, + }, + }, + } + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Endpoints"} + gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "endpoints"} + return admission.NewAttributesRecord(object, oldObject, gvk, namespace, name, gvr, "", admission.Create, &metav1.CreateOptions{}, false, nil) +} + +func endpointStatusUpdateAttributes() admission.Attributes { + attrs := endpointCreateAttributes() + return admission.NewAttributesRecord( + attrs.GetObject(), attrs.GetObject(), attrs.GetKind(), attrs.GetNamespace(), attrs.GetName(), + attrs.GetResource(), "status", admission.Update, &metav1.UpdateOptions{}, false, nil) +} diff --git a/pkg/admission/plugin/cel/interface.go b/pkg/admission/plugin/cel/interface.go index d5c4f1acd..65f201416 100644 --- a/pkg/admission/plugin/cel/interface.go +++ b/pkg/admission/plugin/cel/interface.go @@ -24,6 +24,7 @@ import ( v1 "k8s.io/api/admission/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" + "k8s.io/apiserver/pkg/authorization/authorizer" ) var _ ExpressionAccessor = &MatchCondition{} @@ -49,19 +50,41 @@ func (v *MatchCondition) GetExpression() string { return v.Expression } +// OptionalVariableDeclarations declares which optional CEL variables +// are declared for an expression. +type OptionalVariableDeclarations struct { + // HasParams specifies if the "params" variable is declared. + // The "params" variable may still be bound to "null" when declared. + HasParams bool + // HasAuthorizer specifies if the"authorizer" and "authorizer.requestResource" + // variables are declared. When declared, the authorizer variables are + // expected to be non-null. + HasAuthorizer bool +} + // FilterCompiler contains a function to assist with converting types and values to/from CEL-typed values. type FilterCompiler interface { // Compile is used for the cel expression compilation - Compile(expressions []ExpressionAccessor, hasParam bool) Filter + Compile(expressions []ExpressionAccessor, optionalDecls OptionalVariableDeclarations) Filter +} + +// OptionalVariableBindings provides expression bindings for optional CEL variables. +type OptionalVariableBindings struct { + // VersionedParams provides the "params" variable binding. This variable binding may + // be set to nil even when OptionalVariableDeclarations.HashParams is set to true. + VersionedParams runtime.Object + // Authorizer provides the authorizer used for the "authorizer" and + // "authorizer.requestResource" variable bindings. If the expression was compiled with + // OptionalVariableDeclarations.HasAuthorizer set to true this must be non-nil. + Authorizer authorizer.Authorizer } // Filter contains a function to evaluate compiled CEL-typed values // It expects the inbound object to already have been converted to the version expected // 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(versionedAttr *generic.VersionedAttributes, versionedParams runtime.Object, request *v1.AdmissionRequest) ([]EvaluationResult, error) + ForInput(versionedAttr *generic.VersionedAttributes, request *v1.AdmissionRequest, optionalVars OptionalVariableBindings) ([]EvaluationResult, error) // CompilationErrors returns a list of errors from the compilation of the evaluator CompilationErrors() []error diff --git a/pkg/admission/plugin/validatingadmissionpolicy/admission.go b/pkg/admission/plugin/validatingadmissionpolicy/admission.go index acc307630..c0ca270d8 100644 --- a/pkg/admission/plugin/validatingadmissionpolicy/admission.go +++ b/pkg/admission/plugin/validatingadmissionpolicy/admission.go @@ -23,6 +23,7 @@ import ( "io" "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/features" "k8s.io/client-go/dynamic" "k8s.io/component-base/featuregate" @@ -71,6 +72,7 @@ type celAdmissionPlugin struct { restMapper meta.RESTMapper dynamicClient dynamic.Interface stopCh <-chan struct{} + authorizer authorizer.Authorizer } var _ initializer.WantsExternalKubeInformerFactory = &celAdmissionPlugin{} @@ -78,6 +80,7 @@ var _ initializer.WantsExternalKubeClientSet = &celAdmissionPlugin{} var _ initializer.WantsRESTMapper = &celAdmissionPlugin{} var _ initializer.WantsDynamicClient = &celAdmissionPlugin{} var _ initializer.WantsDrainedNotification = &celAdmissionPlugin{} +var _ initializer.WantsAuthorizer = &celAdmissionPlugin{} var _ admission.InitializationValidator = &celAdmissionPlugin{} var _ admission.ValidationInterface = &celAdmissionPlugin{} @@ -108,6 +111,10 @@ func (c *celAdmissionPlugin) SetDrainedNotification(stopCh <-chan struct{}) { c.stopCh = stopCh } +func (c *celAdmissionPlugin) SetAuthorizer(authorizer authorizer.Authorizer) { + c.authorizer = authorizer +} + func (c *celAdmissionPlugin) InspectFeatureGates(featureGates featuregate.FeatureGate) { if featureGates.Enabled(features.ValidatingAdmissionPolicy) { c.enabled = true @@ -138,7 +145,10 @@ func (c *celAdmissionPlugin) ValidateInitialization() error { if c.stopCh == nil { return errors.New("missing stop channel") } - c.evaluator = NewAdmissionController(c.informerFactory, c.client, c.restMapper, c.dynamicClient) + if c.authorizer == nil { + return errors.New("missing authorizer") + } + c.evaluator = NewAdmissionController(c.informerFactory, c.client, c.restMapper, c.dynamicClient, c.authorizer) if err := c.evaluator.ValidateInitialization(); err != nil { return err } diff --git a/pkg/admission/plugin/validatingadmissionpolicy/admission_test.go b/pkg/admission/plugin/validatingadmissionpolicy/admission_test.go index 09d8e5df9..637fcc693 100644 --- a/pkg/admission/plugin/validatingadmissionpolicy/admission_test.go +++ b/pkg/admission/plugin/validatingadmissionpolicy/admission_test.go @@ -26,6 +26,8 @@ import ( "github.com/stretchr/testify/require" + "k8s.io/klog/v2" + admissionv1 "k8s.io/api/admission/v1" admissionRegistrationv1 "k8s.io/api/admissionregistration/v1" "k8s.io/api/admissionregistration/v1alpha1" @@ -42,6 +44,7 @@ import ( "k8s.io/apiserver/pkg/admission/plugin/cel" "k8s.io/apiserver/pkg/admission/plugin/validatingadmissionpolicy/internal/generic" whgeneric "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" + "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/features" dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/informers" @@ -49,7 +52,6 @@ import ( clienttesting "k8s.io/client-go/testing" "k8s.io/client-go/tools/cache" "k8s.io/component-base/featuregate" - "k8s.io/klog/v2" ) var ( @@ -158,7 +160,7 @@ var ( // Interface which has fake compile functionality for use in tests // So that we can test the controller without pulling in any CEL functionality type fakeCompiler struct { - CompileFuncs map[string]func([]cel.ExpressionAccessor, bool) cel.Filter + CompileFuncs map[string]func([]cel.ExpressionAccessor, cel.OptionalVariableDeclarations) cel.Filter } var _ cel.FilterCompiler = &fakeCompiler{} @@ -169,22 +171,22 @@ func (f *fakeCompiler) HasSynced() bool { func (f *fakeCompiler) Compile( expressions []cel.ExpressionAccessor, - hasParam bool, + options cel.OptionalVariableDeclarations, ) cel.Filter { key := expressions[0].GetExpression() if fun, ok := f.CompileFuncs[key]; ok { - return fun(expressions, hasParam) + return fun(expressions, options) } return nil } -func (f *fakeCompiler) RegisterDefinition(definition *v1alpha1.ValidatingAdmissionPolicy, compileFunc func([]cel.ExpressionAccessor, bool) cel.Filter) { +func (f *fakeCompiler) RegisterDefinition(definition *v1alpha1.ValidatingAdmissionPolicy, compileFunc func([]cel.ExpressionAccessor, cel.OptionalVariableDeclarations) cel.Filter) { //Key must be something that we can decipher from the inputs to Validate so using expression which will be passed to validate on the filter key := definition.Spec.Validations[0].Expression if compileFunc != nil { if f.CompileFuncs == nil { - f.CompileFuncs = make(map[string]func([]cel.ExpressionAccessor, bool) cel.Filter) + f.CompileFuncs = make(map[string]func([]cel.ExpressionAccessor, cel.OptionalVariableDeclarations) cel.Filter) } f.CompileFuncs[key] = compileFunc } @@ -206,7 +208,7 @@ type fakeFilter struct { keyId string } -func (f *fakeFilter) ForInput(versionedAttr *whgeneric.VersionedAttributes, versionedParams runtime.Object, request *admissionv1.AdmissionRequest) ([]cel.EvaluationResult, error) { +func (f *fakeFilter) ForInput(versionedAttr *whgeneric.VersionedAttributes, request *admissionv1.AdmissionRequest, inputs cel.OptionalVariableBindings) ([]cel.EvaluationResult, error) { return []cel.EvaluationResult{}, nil } @@ -333,6 +335,7 @@ func setupTestCommon(t *testing.T, compiler cel.FilterCompiler, matcher Matcher, testContext, testContextCancel := context.WithCancel(context.Background()) t.Cleanup(testContextCancel) + fakeAuthorizer := fakeAuthorizer{} dynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) fakeClient := fake.NewSimpleClientset() @@ -355,7 +358,7 @@ func setupTestCommon(t *testing.T, compiler cel.FilterCompiler, matcher Matcher, handler := plug.(*celAdmissionPlugin) handler.enabled = true - genericInitializer := initializer.New(fakeClient, dynamicClient, fakeInformerFactory, nil, featureGate, testContext.Done()) + genericInitializer := initializer.New(fakeClient, dynamicClient, fakeInformerFactory, fakeAuthorizer, featureGate, testContext.Done()) genericInitializer.Initialize(handler) handler.SetRESTMapper(fakeRestMapper) err = admission.ValidateInitialization(handler) @@ -365,7 +368,7 @@ 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(filter cel.Filter, fail *admissionRegistrationv1.FailurePolicyType) Validator { + controller.policyController.newValidator = func(filter cel.Filter, fail *admissionRegistrationv1.FailurePolicyType, authorizer authorizer.Authorizer) Validator { f := filter.(*fakeFilter) v := validatorMap[f.keyId] v.fakeFilter = f @@ -702,7 +705,7 @@ func TestBasicPolicyDefinitionFailure(t *testing.T) { DefaultMatch: true, } - compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, bool) cel.Filter { + compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, cel.OptionalVariableDeclarations) cel.Filter { datalock.Lock() numCompiles += 1 datalock.Unlock() @@ -762,7 +765,7 @@ func TestDefinitionDoesntMatch(t *testing.T) { passedParams := []*unstructured.Unstructured{} numCompiles := 0 - compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, bool) cel.Filter { + compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, cel.OptionalVariableDeclarations) cel.Filter { datalock.Lock() numCompiles += 1 datalock.Unlock() @@ -873,7 +876,7 @@ func TestReconfigureBinding(t *testing.T) { }, } - compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, bool) cel.Filter { + compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, cel.OptionalVariableDeclarations) cel.Filter { datalock.Lock() numCompiles += 1 datalock.Unlock() @@ -980,7 +983,7 @@ func TestRemoveDefinition(t *testing.T) { datalock := sync.Mutex{} numCompiles := 0 - compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, bool) cel.Filter { + compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, cel.OptionalVariableDeclarations) cel.Filter { datalock.Lock() numCompiles += 1 datalock.Unlock() @@ -1047,7 +1050,7 @@ func TestRemoveBinding(t *testing.T) { datalock := sync.Mutex{} numCompiles := 0 - compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, bool) cel.Filter { + compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, cel.OptionalVariableDeclarations) cel.Filter { datalock.Lock() numCompiles += 1 datalock.Unlock() @@ -1155,7 +1158,7 @@ func TestInvalidParamSourceInstanceName(t *testing.T) { passedParams := []*unstructured.Unstructured{} numCompiles := 0 - compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, bool) cel.Filter { + compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, cel.OptionalVariableDeclarations) cel.Filter { datalock.Lock() numCompiles += 1 datalock.Unlock() @@ -1221,7 +1224,7 @@ func TestEmptyParamSource(t *testing.T) { noParamSourcePolicy := *denyPolicy noParamSourcePolicy.Spec.ParamKind = nil - compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, bool) cel.Filter { + compiler.RegisterDefinition(denyPolicy, func([]cel.ExpressionAccessor, cel.OptionalVariableDeclarations) cel.Filter { datalock.Lock() numCompiles += 1 datalock.Unlock() @@ -1323,7 +1326,7 @@ func TestMultiplePoliciesSharedParamType(t *testing.T) { compiles2 := atomic.Int64{} evaluations2 := atomic.Int64{} - compiler.RegisterDefinition(&policy1, func([]cel.ExpressionAccessor, bool) cel.Filter { + compiler.RegisterDefinition(&policy1, func([]cel.ExpressionAccessor, cel.OptionalVariableDeclarations) cel.Filter { compiles1.Add(1) return &fakeFilter{ @@ -1340,7 +1343,7 @@ func TestMultiplePoliciesSharedParamType(t *testing.T) { } }) - compiler.RegisterDefinition(&policy2, func([]cel.ExpressionAccessor, bool) cel.Filter { + compiler.RegisterDefinition(&policy2, func([]cel.ExpressionAccessor, cel.OptionalVariableDeclarations) cel.Filter { compiles2.Add(1) return &fakeFilter{ @@ -1448,7 +1451,7 @@ func TestNativeTypeParam(t *testing.T) { Kind: "ConfigMap", } - compiler.RegisterDefinition(&nativeTypeParamPolicy, func([]cel.ExpressionAccessor, bool) cel.Filter { + compiler.RegisterDefinition(&nativeTypeParamPolicy, func([]cel.ExpressionAccessor, cel.OptionalVariableDeclarations) cel.Filter { compiles.Add(1) return &fakeFilter{ @@ -1511,3 +1514,9 @@ func TestNativeTypeParam(t *testing.T) { require.EqualValues(t, 1, compiles.Load()) require.EqualValues(t, 1, evaluations.Load()) } + +type fakeAuthorizer struct{} + +func (f fakeAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { + return authorizer.DecisionAllow, "", nil +} diff --git a/pkg/admission/plugin/validatingadmissionpolicy/controller.go b/pkg/admission/plugin/validatingadmissionpolicy/controller.go index cafb6e389..800e823ab 100644 --- a/pkg/admission/plugin/validatingadmissionpolicy/controller.go +++ b/pkg/admission/plugin/validatingadmissionpolicy/controller.go @@ -38,6 +38,7 @@ import ( "k8s.io/apiserver/pkg/admission/plugin/validatingadmissionpolicy/internal/generic" "k8s.io/apiserver/pkg/admission/plugin/validatingadmissionpolicy/matching" whgeneric "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" + "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/client-go/dynamic" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" @@ -119,6 +120,7 @@ func NewAdmissionController( client kubernetes.Interface, restMapper meta.RESTMapper, dynamicClient dynamic.Interface, + authz authorizer.Authorizer, ) CELPolicyEvaluator { return &celAdmissionController{ definitions: atomic.Value{}, @@ -132,6 +134,7 @@ func NewAdmissionController( informerFactory.Admissionregistration().V1alpha1().ValidatingAdmissionPolicies().Informer()), generic.NewInformer[*v1alpha1.ValidatingAdmissionPolicyBinding]( informerFactory.Admissionregistration().V1alpha1().ValidatingAdmissionPolicyBindings().Informer()), + authz, ), } } diff --git a/pkg/admission/plugin/validatingadmissionpolicy/controller_reconcile.go b/pkg/admission/plugin/validatingadmissionpolicy/controller_reconcile.go index 4622491e9..ecf2a715f 100644 --- a/pkg/admission/plugin/validatingadmissionpolicy/controller_reconcile.go +++ b/pkg/admission/plugin/validatingadmissionpolicy/controller_reconcile.go @@ -32,6 +32,7 @@ import ( celmetrics "k8s.io/apiserver/pkg/admission/cel" "k8s.io/apiserver/pkg/admission/plugin/cel" "k8s.io/apiserver/pkg/admission/plugin/validatingadmissionpolicy/internal/generic" + "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" "k8s.io/client-go/informers" @@ -85,9 +86,11 @@ type policyController struct { definitionsToBindings map[namespacedName]sets.Set[namespacedName] client kubernetes.Interface + + authz authorizer.Authorizer } -type newValidator func(cel.Filter, *v1.FailurePolicyType) Validator +type newValidator func(cel.Filter, *v1.FailurePolicyType, authorizer.Authorizer) Validator func newPolicyController( restMapper meta.RESTMapper, @@ -97,6 +100,7 @@ func newPolicyController( matcher Matcher, policiesInformer generic.Informer[*v1alpha1.ValidatingAdmissionPolicy], bindingsInformer generic.Informer[*v1alpha1.ValidatingAdmissionPolicyBinding], + authz authorizer.Authorizer, ) *policyController { res := &policyController{} *res = policyController{ @@ -126,6 +130,7 @@ func newPolicyController( restMapper: restMapper, dynamicClient: dynamicClient, client: client, + authz: authz, } return res } @@ -439,9 +444,11 @@ func (c *policyController) latestPolicyData() []policyData { if definitionInfo.lastReconciledValue.Spec.ParamKind != nil { hasParam = true } + optionalVars := cel.OptionalVariableDeclarations{HasParams: hasParam, HasAuthorizer: true} bindingInfo.validator = c.newValidator( - c.filterCompiler.Compile(convertv1alpha1Validations(definitionInfo.lastReconciledValue.Spec.Validations), hasParam), + c.filterCompiler.Compile(convertv1alpha1Validations(definitionInfo.lastReconciledValue.Spec.Validations), optionalVars), convertv1alpha1FailurePolicyTypeTov1FailurePolicyType(definitionInfo.lastReconciledValue.Spec.FailurePolicy), + c.authz, ) } bindingInfos = append(bindingInfos, *bindingInfo) diff --git a/pkg/admission/plugin/validatingadmissionpolicy/initializer.go b/pkg/admission/plugin/validatingadmissionpolicy/initializer.go index 563bb69de..15b757985 100644 --- a/pkg/admission/plugin/validatingadmissionpolicy/initializer.go +++ b/pkg/admission/plugin/validatingadmissionpolicy/initializer.go @@ -18,6 +18,7 @@ package validatingadmissionpolicy import ( "context" + "k8s.io/apiserver/pkg/admission" ) diff --git a/pkg/admission/plugin/validatingadmissionpolicy/validator.go b/pkg/admission/plugin/validatingadmissionpolicy/validator.go index 6e81518a1..385d1618e 100644 --- a/pkg/admission/plugin/validatingadmissionpolicy/validator.go +++ b/pkg/admission/plugin/validatingadmissionpolicy/validator.go @@ -18,28 +18,31 @@ package validatingadmissionpolicy import ( "fmt" - "k8s.io/klog/v2" "strings" celtypes "github.com/google/cel-go/common/types" + "k8s.io/klog/v2" v1 "k8s.io/api/admissionregistration/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/admission/plugin/cel" "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" + "k8s.io/apiserver/pkg/authorization/authorizer" ) // validator implements the Validator interface type validator struct { filter cel.Filter failPolicy *v1.FailurePolicyType + authorizer authorizer.Authorizer } -func NewValidator(filter cel.Filter, failPolicy *v1.FailurePolicyType) Validator { +func NewValidator(filter cel.Filter, failPolicy *v1.FailurePolicyType, authorizer authorizer.Authorizer) Validator { return &validator{ filter: filter, failPolicy: failPolicy, + authorizer: authorizer, } } @@ -59,7 +62,8 @@ func (v *validator) Validate(versionedAttr *generic.VersionedAttributes, version f = *v.failPolicy } - evalResults, err := v.filter.ForInput(versionedAttr, versionedParams, cel.CreateAdmissionRequest(versionedAttr.Attributes)) + optionalVars := cel.OptionalVariableBindings{VersionedParams: versionedParams, Authorizer: v.authorizer} + evalResults, err := v.filter.ForInput(versionedAttr, cel.CreateAdmissionRequest(versionedAttr.Attributes), optionalVars) if err != nil { return []PolicyDecision{ { diff --git a/pkg/admission/plugin/validatingadmissionpolicy/validator_test.go b/pkg/admission/plugin/validatingadmissionpolicy/validator_test.go index c10e33fd5..06d703295 100644 --- a/pkg/admission/plugin/validatingadmissionpolicy/validator_test.go +++ b/pkg/admission/plugin/validatingadmissionpolicy/validator_test.go @@ -28,7 +28,6 @@ import ( admissionv1 "k8s.io/api/admission/v1" v1 "k8s.io/api/admissionregistration/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/admission/plugin/cel" @@ -42,7 +41,7 @@ type fakeCelFilter struct { throwError bool } -func (f *fakeCelFilter) ForInput(versionedAttr *generic.VersionedAttributes, versionedParams runtime.Object, request *admissionv1.AdmissionRequest) ([]cel.EvaluationResult, error) { +func (f *fakeCelFilter) ForInput(*generic.VersionedAttributes, *admissionv1.AdmissionRequest, cel.OptionalVariableBindings) ([]cel.EvaluationResult, error) { if f.throwError { return nil, errors.New("test error") } @@ -465,6 +464,7 @@ func TestValidate(t *testing.T) { throwError: tc.throwError, }, } + policyResults := v.Validate(fakeVersionedAttr, nil) require.Equal(t, len(policyResults), len(tc.policyDecision)) diff --git a/pkg/cel/library/authz.go b/pkg/cel/library/authz.go new file mode 100644 index 000000000..8d5a39d64 --- /dev/null +++ b/pkg/cel/library/authz.go @@ -0,0 +1,580 @@ +/* +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 library + +import ( + "context" + "fmt" + "reflect" + "strings" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + + apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authentication/serviceaccount" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" +) + +// Authz provides a CEL function library extension for performing authorization checks. +// Note that authorization checks are only supported for CEL expression fields in the API +// where an 'authorizer' variable is provided to the CEL expression. See the +// documentation of API fields where CEL expressions are used to learn if the 'authorizer' +// variable is provided. +// +// path +// +// Returns a PathCheck configured to check authorization for a non-resource request +// path (e.g. /healthz). If path is an empty string, an error is returned. +// Note that the leading '/' is not required. +// +// .path() +// +// Examples: +// +// authorizer.path('/healthz') // returns a PathCheck for the '/healthz' API path +// authorizer.path('') // results in "path must not be empty" error +// authorizer.path(' ') // results in "path must not be empty" error +// +// group +// +// Returns a GroupCheck configured to check authorization for the API resources for +// a particular API group. +// Note that authorization checks are only supported for CEL expression fields in the API +// where an 'authorizer' variable is provided to the CEL expression. Check the +// documentation of API fields where CEL expressions are used to learn if the 'authorizer' +// variable is provided. +// +// .group() +// +// Examples: +// +// authorizer.group('apps') // returns a GroupCheck for the 'apps' API group +// authorizer.group('') // returns a GroupCheck for the core API group +// authorizer.group('example.com') // returns a GroupCheck for the custom resources in the 'example.com' API group +// +// serviceAccount +// +// Returns an Authorizer configured to check authorization for the provided service account namespace and name. +// If the name is not a valid DNS subdomain string (as defined by RFC 1123), an error is returned. +// If the namespace is not a valid DNS label (as defined by RFC 1123), an error is returned. +// +// .serviceAccount(, ) +// +// Examples: +// +// authorizer.serviceAccount('default', 'myserviceaccount') // returns an Authorizer for the service account with namespace 'default' and name 'myserviceaccount' +// authorizer.serviceAccount('not@a#valid!namespace', 'validname') // returns an error +// authorizer.serviceAccount('valid.example.com', 'invalid@*name') // returns an error +// +// resource +// +// Returns a ResourceCheck configured to check authorization for a particular API resource. +// Note that the provided resource string should be a lower case plural name of a Kubernetes API resource. +// +// .resource() +// +// Examples: +// +// authorizer.group('apps').resource('deployments') // returns a ResourceCheck for the 'deployments' resources in the 'apps' group. +// authorizer.group('').resource('pods') // returns a ResourceCheck for the 'pods' resources in the core group. +// authorizer.group('apps').resource('') // results in "resource must not be empty" error +// authorizer.group('apps').resource(' ') // results in "resource must not be empty" error +// +// subresource +// +// Returns a ResourceCheck configured to check authorization for a particular subresource of an API resource. +// If subresource is set to "", the subresource field of this ResourceCheck is considered unset. +// +// .subresource() +// +// Examples: +// +// authorizer.group('').resource('pods').subresource('status') // returns a ResourceCheck the 'status' subresource of 'pods' +// authorizer.group('apps').resource('deployments').subresource('scale') // returns a ResourceCheck the 'scale' subresource of 'deployments' +// authorizer.group('example.com').resource('widgets').subresource('scale') // returns a ResourceCheck for the 'scale' subresource of the 'widgets' custom resource +// authorizer.group('example.com').resource('widgets').subresource('') // returns a ResourceCheck for the 'widgets' resource. +// +// namespace +// +// Returns a ResourceCheck configured to check authorization for a particular namespace. +// For cluster scoped resources, namespace() does not need to be called; namespace defaults +// to "", which is the correct namespace value to use to check cluster scoped resources. +// If namespace is set to "", the ResourceCheck will check authorization for the cluster scope. +// +// .namespace() +// +// Examples: +// +// authorizer.group('apps').resource('deployments').namespace('test') // returns a ResourceCheck for 'deployments' in the 'test' namespace +// authorizer.group('').resource('pods').namespace('default') // returns a ResourceCheck for 'pods' in the 'default' namespace +// authorizer.group('').resource('widgets').namespace('') // returns a ResourceCheck for 'widgets' in the cluster scope +// +// name +// +// Returns a ResourceCheck configured to check authorization for a particular resource name. +// If name is set to "", the name field of this ResourceCheck is considered unset. +// +// .name() +// +// Examples: +// +// authorizer.group('apps').resource('deployments').namespace('test').name('backend') // returns a ResourceCheck for the 'backend' 'deployments' resource in the 'test' namespace +// authorizer.group('apps').resource('deployments').namespace('test').name('') // returns a ResourceCheck for the 'deployments' resource in the 'test' namespace +// +// check +// +// For PathCheck, checks if the principal (user or service account) that sent the request is authorized for the HTTP request verb of the path. +// For ResourceCheck, checks if the principal (user or service account) that sent the request is authorized for the API verb and the configured authorization checks of the ResourceCheck. +// The check operation can be expensive, particularly in clusters using the webhook authorization mode. +// +// .check() +// .check() +// +// Examples: +// +// authorizer.group('').resource('pods').namespace('default').check('create') // Checks if the principal (user or service account) is authorized create pods in the 'default' namespace. +// authorizer.path('/healthz').check('get') // Checks if the principal (user or service account) is authorized to make HTTP GET requests to the /healthz API path. +// +// allowed +// +// Returns true if the authorizer's decision for the check is "allow". Note that if the authorizer's decision is +// "no opinion", that both the 'allowed' and 'denied' functions will return false. +// +// .allowed() +// +// Examples: +// +// authorizer.group('').resource('pods').namespace('default').check('create').allowed() // Returns true if the principal (user or service account) is allowed create pods in the 'default' namespace. +// authorizer.path('/healthz').check('get').allowed() // Returns true if the principal (user or service account) is allowed to make HTTP GET requests to the /healthz API path. +// +// reason +// +// Returns a string reason for the authorization decision +// +// .reason() +// +// Examples: +// +// authorizer.path('/healthz').check('GET').reason() +func Authz() cel.EnvOption { + return cel.Lib(authzLib) +} + +var authzLib = &authz{} + +type authz struct{} + +var authzLibraryDecls = map[string][]cel.FunctionOpt{ + "path": { + cel.MemberOverload("authorizer_path", []*cel.Type{AuthorizerType, cel.StringType}, PathCheckType, + cel.BinaryBinding(authorizerPath))}, + "group": { + cel.MemberOverload("authorizer_group", []*cel.Type{AuthorizerType, cel.StringType}, GroupCheckType, + cel.BinaryBinding(authorizerGroup))}, + "serviceAccount": { + cel.MemberOverload("authorizer_serviceaccount", []*cel.Type{AuthorizerType, cel.StringType, cel.StringType}, AuthorizerType, + cel.FunctionBinding(authorizerServiceAccount))}, + "resource": { + cel.MemberOverload("groupcheck_resource", []*cel.Type{GroupCheckType, cel.StringType}, ResourceCheckType, + cel.BinaryBinding(groupCheckResource))}, + "subresource": { + cel.MemberOverload("resourcecheck_subresource", []*cel.Type{ResourceCheckType, cel.StringType}, ResourceCheckType, + cel.BinaryBinding(resourceCheckSubresource))}, + "namespace": { + cel.MemberOverload("resourcecheck_namespace", []*cel.Type{ResourceCheckType, cel.StringType}, ResourceCheckType, + cel.BinaryBinding(resourceCheckNamespace))}, + "name": { + cel.MemberOverload("resourcecheck_name", []*cel.Type{ResourceCheckType, cel.StringType}, ResourceCheckType, + cel.BinaryBinding(resourceCheckName))}, + "check": { + cel.MemberOverload("pathcheck_check", []*cel.Type{PathCheckType, cel.StringType}, DecisionType, + cel.BinaryBinding(pathCheckCheck)), + cel.MemberOverload("resourcecheck_check", []*cel.Type{ResourceCheckType, cel.StringType}, DecisionType, + cel.BinaryBinding(resourceCheckCheck))}, + "allowed": { + cel.MemberOverload("decision_allowed", []*cel.Type{DecisionType}, cel.BoolType, + cel.UnaryBinding(decisionAllowed))}, + "reason": { + cel.MemberOverload("decision_reason", []*cel.Type{DecisionType}, cel.StringType, + cel.UnaryBinding(decisionReason))}, +} + +func (*authz) CompileOptions() []cel.EnvOption { + options := make([]cel.EnvOption, 0, len(authzLibraryDecls)) + for name, overloads := range authzLibraryDecls { + options = append(options, cel.Function(name, overloads...)) + } + return options +} + +func (*authz) ProgramOptions() []cel.ProgramOption { + return []cel.ProgramOption{} +} + +func authorizerPath(arg1, arg2 ref.Val) ref.Val { + authz, ok := arg1.(authorizerVal) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + path, ok := arg2.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + if len(strings.TrimSpace(path)) == 0 { + return types.NewErr("path must not be empty") + } + + return authz.pathCheck(path) +} + +func authorizerGroup(arg1, arg2 ref.Val) ref.Val { + authz, ok := arg1.(authorizerVal) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + group, ok := arg2.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + return authz.groupCheck(group) +} + +func authorizerServiceAccount(args ...ref.Val) ref.Val { + argn := len(args) + if argn != 3 { + return types.NoSuchOverloadErr() + } + + authz, ok := args[0].(authorizerVal) + if !ok { + return types.MaybeNoSuchOverloadErr(args[0]) + } + + namespace, ok := args[1].Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(args[1]) + } + + name, ok := args[2].Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(args[2]) + } + + if errors := apimachineryvalidation.ValidateServiceAccountName(name, false); len(errors) > 0 { + return types.NewErr("Invalid service account name") + } + if errors := apimachineryvalidation.ValidateNamespaceName(namespace, false); len(errors) > 0 { + return types.NewErr("Invalid service account namespace") + } + return authz.serviceAccount(namespace, name) +} + +func groupCheckResource(arg1, arg2 ref.Val) ref.Val { + groupCheck, ok := arg1.(groupCheckVal) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + resource, ok := arg2.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + if len(strings.TrimSpace(resource)) == 0 { + return types.NewErr("resource must not be empty") + } + return groupCheck.resourceCheck(resource) +} + +func resourceCheckSubresource(arg1, arg2 ref.Val) ref.Val { + resourceCheck, ok := arg1.(resourceCheckVal) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + subresource, ok := arg2.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + result := resourceCheck + result.subresource = subresource + return result +} + +func resourceCheckNamespace(arg1, arg2 ref.Val) ref.Val { + resourceCheck, ok := arg1.(resourceCheckVal) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + namespace, ok := arg2.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + result := resourceCheck + result.namespace = namespace + return result +} + +func resourceCheckName(arg1, arg2 ref.Val) ref.Val { + resourceCheck, ok := arg1.(resourceCheckVal) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + name, ok := arg2.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + result := resourceCheck + result.name = name + return result +} + +func pathCheckCheck(arg1, arg2 ref.Val) ref.Val { + pathCheck, ok := arg1.(pathCheckVal) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + httpRequestVerb, ok := arg2.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + return pathCheck.Authorize(context.TODO(), httpRequestVerb) +} + +func resourceCheckCheck(arg1, arg2 ref.Val) ref.Val { + resourceCheck, ok := arg1.(resourceCheckVal) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + apiVerb, ok := arg2.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + return resourceCheck.Authorize(context.TODO(), apiVerb) +} + +func decisionAllowed(arg ref.Val) ref.Val { + decision, ok := arg.(decisionVal) + if !ok { + return types.MaybeNoSuchOverloadErr(arg) + } + + return types.Bool(decision.authDecision == authorizer.DecisionAllow) +} + +func decisionReason(arg ref.Val) ref.Val { + decision, ok := arg.(decisionVal) + if !ok { + return types.MaybeNoSuchOverloadErr(arg) + } + + return types.String(decision.reason) +} + +var ( + AuthorizerType = cel.ObjectType("kubernetes.authorization.Authorizer") + PathCheckType = cel.ObjectType("kubernetes.authorization.PathCheck") + GroupCheckType = cel.ObjectType("kubernetes.authorization.GroupCheck") + ResourceCheckType = cel.ObjectType("kubernetes.authorization.ResourceCheck") + DecisionType = cel.ObjectType("kubernetes.authorization.Decision") +) + +// Resource represents an API resource +type Resource interface { + // GetName returns the name of the object as presented in the request. On a CREATE operation, the client + // may omit name and rely on the server to generate the name. If that is the case, this method will return + // the empty string + GetName() string + // GetNamespace is the namespace associated with the request (if any) + GetNamespace() string + // GetResource is the name of the resource being requested. This is not the kind. For example: pods + GetResource() schema.GroupVersionResource + // GetSubresource is the name of the subresource being requested. This is a different resource, scoped to the parent resource, but it may have a different kind. + // For instance, /pods has the resource "pods" and the kind "Pod", while /pods/foo/status has the resource "pods", the sub resource "status", and the kind "Pod" + // (because status operates on pods). The binding resource for a pod though may be /pods/foo/binding, which has resource "pods", subresource "binding", and kind "Binding". + GetSubresource() string +} + +func NewAuthorizerVal(userInfo user.Info, authorizer authorizer.Authorizer) ref.Val { + return authorizerVal{receiverOnlyObjectVal: receiverOnlyVal(AuthorizerType), userInfo: userInfo, authAuthorizer: authorizer} +} + +func NewResourceAuthorizerVal(userInfo user.Info, authorizer authorizer.Authorizer, requestResource Resource) ref.Val { + a := authorizerVal{receiverOnlyObjectVal: receiverOnlyVal(AuthorizerType), userInfo: userInfo, authAuthorizer: authorizer} + resource := requestResource.GetResource() + g := a.groupCheck(resource.Group) + r := g.resourceCheck(resource.Resource) + r.subresource = requestResource.GetSubresource() + r.namespace = requestResource.GetNamespace() + r.name = requestResource.GetName() + return r +} + +type authorizerVal struct { + receiverOnlyObjectVal + userInfo user.Info + authAuthorizer authorizer.Authorizer +} + +func (a authorizerVal) pathCheck(path string) pathCheckVal { + return pathCheckVal{receiverOnlyObjectVal: receiverOnlyVal(PathCheckType), authorizer: a, path: path} +} + +func (a authorizerVal) groupCheck(group string) groupCheckVal { + return groupCheckVal{receiverOnlyObjectVal: receiverOnlyVal(GroupCheckType), authorizer: a, group: group} +} + +func (a authorizerVal) serviceAccount(namespace, name string) authorizerVal { + sa := &serviceaccount.ServiceAccountInfo{Name: name, Namespace: namespace} + return authorizerVal{ + receiverOnlyObjectVal: receiverOnlyVal(AuthorizerType), + userInfo: sa.UserInfo(), + authAuthorizer: a.authAuthorizer, + } +} + +type pathCheckVal struct { + receiverOnlyObjectVal + authorizer authorizerVal + path string +} + +func (a pathCheckVal) Authorize(ctx context.Context, verb string) ref.Val { + attr := &authorizer.AttributesRecord{ + Path: a.path, + Verb: verb, + User: a.authorizer.userInfo, + } + + decision, reason, err := a.authorizer.authAuthorizer.Authorize(ctx, attr) + if err != nil { + return types.NewErr("error in authorization check: %v", err) + } + return newDecision(decision, reason) +} + +type groupCheckVal struct { + receiverOnlyObjectVal + authorizer authorizerVal + group string +} + +func (g groupCheckVal) resourceCheck(resource string) resourceCheckVal { + return resourceCheckVal{receiverOnlyObjectVal: receiverOnlyVal(ResourceCheckType), groupCheck: g, resource: resource} +} + +type resourceCheckVal struct { + receiverOnlyObjectVal + groupCheck groupCheckVal + resource string + subresource string + namespace string + name string +} + +func (a resourceCheckVal) Authorize(ctx context.Context, verb string) ref.Val { + attr := &authorizer.AttributesRecord{ + ResourceRequest: true, + APIGroup: a.groupCheck.group, + APIVersion: "*", + Resource: a.resource, + Subresource: a.subresource, + Namespace: a.namespace, + Name: a.name, + Verb: verb, + User: a.groupCheck.authorizer.userInfo, + } + decision, reason, err := a.groupCheck.authorizer.authAuthorizer.Authorize(ctx, attr) + if err != nil { + return types.NewErr("error in authorization check: %v", err) + } + return newDecision(decision, reason) +} + +func newDecision(authDecision authorizer.Decision, reason string) decisionVal { + return decisionVal{receiverOnlyObjectVal: receiverOnlyVal(DecisionType), authDecision: authDecision, reason: reason} +} + +type decisionVal struct { + receiverOnlyObjectVal + authDecision authorizer.Decision + reason string +} + +// receiverOnlyObjectVal provides an implementation of ref.Val for +// any object type that has receiver functions but does not expose any fields to +// CEL. +type receiverOnlyObjectVal struct { + typeValue *types.TypeValue +} + +// receiverOnlyVal returns a receiverOnlyObjectVal for the given type. +func receiverOnlyVal(objectType *cel.Type) receiverOnlyObjectVal { + return receiverOnlyObjectVal{typeValue: types.NewTypeValue(objectType.String())} +} + +// ConvertToNative implements ref.Val.ConvertToNative. +func (a receiverOnlyObjectVal) ConvertToNative(typeDesc reflect.Type) (any, error) { + return nil, fmt.Errorf("type conversion error from '%s' to '%v'", a.typeValue.String(), typeDesc) +} + +// ConvertToType implements ref.Val.ConvertToType. +func (a receiverOnlyObjectVal) ConvertToType(typeVal ref.Type) ref.Val { + switch typeVal { + case a.typeValue: + return a + case types.TypeType: + return a.typeValue + } + return types.NewErr("type conversion error from '%s' to '%s'", a.typeValue, typeVal) +} + +// Equal implements ref.Val.Equal. +func (a receiverOnlyObjectVal) Equal(other ref.Val) ref.Val { + o, ok := other.(receiverOnlyObjectVal) + if !ok { + return types.MaybeNoSuchOverloadErr(other) + } + return types.Bool(a == o) +} + +// Type implements ref.Val.Type. +func (a receiverOnlyObjectVal) Type() ref.Type { + return a.typeValue +} + +// Value implements ref.Val.Value. +func (a receiverOnlyObjectVal) Value() any { + return types.NoSuchOverloadErr() +} diff --git a/pkg/cel/library/cost.go b/pkg/cel/library/cost.go index 39098e3f6..6cc629032 100644 --- a/pkg/cel/library/cost.go +++ b/pkg/cel/library/cost.go @@ -36,6 +36,15 @@ type CostEstimator struct { func (l *CostEstimator) CallCost(function, overloadId string, args []ref.Val, result ref.Val) *uint64 { switch function { + case "check": + // An authorization check has a fixed cost + // This cost is set to allow for only two authorization checks per expression + cost := uint64(350000) + return &cost + case "serviceAccount", "path", "group", "resource", "subresource", "namespace", "name", "allowed", "denied", "reason": + // All authorization builder and accessor functions have a nominal cost + cost := uint64(1) + return &cost case "isSorted", "sum", "max", "min", "indexOf", "lastIndexOf": var cost uint64 if len(args) > 0 { @@ -78,6 +87,13 @@ func (l *CostEstimator) EstimateCallCost(function, overloadId string, target *ch // WARNING: Any changes to this code impact API compatibility! The estimated cost is used to determine which CEL rules may be written to a // CRD and any change (cost increases and cost decreases) are breaking. switch function { + case "check": + // An authorization check has a fixed cost + // This cost is set to allow for only two authorization checks per expression + return &checker.CallEstimate{CostEstimate: checker.CostEstimate{Min: 350000, Max: 350000}} + case "serviceAccount", "path", "group", "resource", "subresource", "namespace", "name", "allowed", "denied", "reason": + // All authorization builder and accessor functions have a nominal cost + return &checker.CallEstimate{CostEstimate: checker.CostEstimate{Min: 1, Max: 1}} case "isSorted", "sum", "max", "min", "indexOf", "lastIndexOf": if target != nil { // Charge 1 cost for comparing each element in the list @@ -94,7 +110,6 @@ func (l *CostEstimator) EstimateCallCost(function, overloadId string, target *ch } else { // the target is a string, which is supported by indexOf and lastIndexOf return &checker.CallEstimate{CostEstimate: l.sizeEstimate(*target).MultiplyByCostFactor(common.StringTraversalCostFactor)} } - } case "url": if len(args) == 1 { diff --git a/pkg/cel/library/cost_test.go b/pkg/cel/library/cost_test.go index 0b1e0020c..c0f4f9558 100644 --- a/pkg/cel/library/cost_test.go +++ b/pkg/cel/library/cost_test.go @@ -17,6 +17,7 @@ limitations under the License. package library import ( + "context" "fmt" "testing" @@ -24,6 +25,8 @@ import ( "github.com/google/cel-go/checker" "github.com/google/cel-go/ext" expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1" + + "k8s.io/apiserver/pkg/authorization/authorizer" ) const ( @@ -311,12 +314,62 @@ func TestStringLibrary(t *testing.T) { } } +func TestAuthzLibrary(t *testing.T) { + cases := []struct { + name string + expr string + expectEstimatedCost checker.CostEstimate + expectRuntimeCost uint64 + }{ + { + name: "path", + expr: "authorizer.path('/healthz')", + expectEstimatedCost: checker.CostEstimate{Min: 2, Max: 2}, + expectRuntimeCost: 2, + }, + { + name: "resource", + expr: "authorizer.group('apps').resource('deployments').subresource('status').namespace('test').name('backend')", + expectEstimatedCost: checker.CostEstimate{Min: 6, Max: 6}, + expectRuntimeCost: 6, + }, + { + name: "path check allowed", + expr: "authorizer.path('/healthz').check('get').allowed()", + expectEstimatedCost: checker.CostEstimate{Min: 350003, Max: 350003}, + expectRuntimeCost: 350003, + }, + { + name: "resource check allowed", + expr: "authorizer.group('apps').resource('deployments').subresource('status').namespace('test').name('backend').check('create').allowed()", + expectEstimatedCost: checker.CostEstimate{Min: 350007, Max: 350007}, + expectRuntimeCost: 350007, + }, + { + name: "resource check reason", + expr: "authorizer.group('apps').resource('deployments').subresource('status').namespace('test').name('backend').check('create').allowed()", + expectEstimatedCost: checker.CostEstimate{Min: 350007, Max: 350007}, + expectRuntimeCost: 350007, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testCost(t, tc.expr, tc.expectEstimatedCost, tc.expectRuntimeCost) + }) + } +} + func testCost(t *testing.T, expr string, expectEsimatedCost checker.CostEstimate, expectRuntimeCost uint64) { est := &CostEstimator{SizeEstimator: &testCostEstimator{}} env, err := cel.NewEnv(append(k8sExtensionLibs, ext.Strings())...) if err != nil { t.Fatalf("%v", err) } + env, err = env.Extend(cel.Variable("authorizer", AuthorizerType)) + if err != nil { + t.Fatalf("%v", err) + } compiled, issues := env.Compile(expr) if len(issues.Errors()) > 0 { t.Fatalf("%v", issues.Errors()) @@ -332,7 +385,7 @@ func testCost(t *testing.T, expr string, expectEsimatedCost checker.CostEstimate if err != nil { t.Fatalf("%v", err) } - _, details, err := prog.Eval(map[string]interface{}{}) + _, details, err := prog.Eval(map[string]interface{}{"authorizer": NewAuthorizerVal(nil, alwaysAllowAuthorizer{})}) if err != nil { t.Fatalf("%v", err) } @@ -361,3 +414,9 @@ func (t *testCostEstimator) EstimateSize(element checker.AstNode) *checker.SizeE func (t *testCostEstimator) EstimateCallCost(function, overloadId string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate { return nil } + +type alwaysAllowAuthorizer struct{} + +func (f alwaysAllowAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { + return authorizer.DecisionAllow, "", nil +} diff --git a/pkg/cel/library/libraries.go b/pkg/cel/library/libraries.go index 18f6d7a7c..e2e8fc29b 100644 --- a/pkg/cel/library/libraries.go +++ b/pkg/cel/library/libraries.go @@ -29,6 +29,7 @@ var k8sExtensionLibs = []cel.EnvOption{ URLs(), Regex(), Lists(), + Authz(), } var ExtensionLibRegexOptimizations = []*interpreter.RegexOptimization{FindRegexOptimization, FindAllRegexOptimization} diff --git a/pkg/cel/library/library_compatibility_test.go b/pkg/cel/library/library_compatibility_test.go index 65473ff09..b0c9743d9 100644 --- a/pkg/cel/library/library_compatibility_test.go +++ b/pkg/cel/library/library_compatibility_test.go @@ -29,6 +29,7 @@ func TestLibraryCompatibility(t *testing.T) { urlsLib: urlLibraryDecls, listsLib: listsLibraryDecls, regexLib: regexLibraryDecls, + authzLib: authzLibraryDecls, } if len(k8sExtensionLibs) != len(decls) { t.Errorf("Expected the same number of libraries in the ExtensionLibs as are tested for compatibility") @@ -46,6 +47,8 @@ func TestLibraryCompatibility(t *testing.T) { // Kubernetes 1.24: "isSorted", "sum", "max", "min", "indexOf", "lastIndexOf", "find", "findAll", "url", "getScheme", "getHost", "getHostname", "getPort", "getEscapedPath", "getQuery", "isURL", + // Kubernetes <1.27>: + "path", "group", "serviceAccount", "resource", "subresource", "namespace", "name", "check", "allowed", "denied", "reason", // Kubernetes <1.??>: } for _, fn := range knownFunctions {