From 8b74e73e3825e725d05376de717ad96506a52eec Mon Sep 17 00:00:00 2001 From: Alexander Zielenski <351783+alexzielenski@users.noreply.github.com> Date: Wed, 12 Oct 2022 18:03:44 -0700 Subject: [PATCH] add cel admission controller tests 84% coverage --- .../admission/plugin/cel/admission_test.go | 1035 +++++++++++++++++ .../pkg/admission/plugin/cel/fake.go | 258 ++++ 2 files changed, 1293 insertions(+) create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/cel/admission_test.go create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/cel/fake.go diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/cel/admission_test.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/cel/admission_test.go new file mode 100644 index 00000000000..f46661e0153 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/cel/admission_test.go @@ -0,0 +1,1035 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cel + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apimachinery/pkg/watch" + + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/admission/plugin/cel/internal/generic" + dynamicfake "k8s.io/client-go/dynamic/fake" + clienttesting "k8s.io/client-go/testing" + "k8s.io/client-go/tools/cache" +) + +var ( + scheme *runtime.Scheme = runtime.NewScheme() + codecs serializer.CodecFactory = serializer.NewCodecFactory(scheme) + paramsGVK schema.GroupVersionKind = schema.GroupVersionKind{ + Group: "example.com", + Version: "v1", + Kind: "ParamsConfig", + } + fakeRestMapper *meta.DefaultRESTMapper = meta.NewDefaultRESTMapper([]schema.GroupVersion{ + { + Group: "", + Version: "v1", + }, + }) + + definitionGVK schema.GroupVersionKind = (&FakePolicyDefinition{}).GroupVersionKind() + bindingGVK schema.GroupVersionKind = (&FakePolicyBinding{}).GroupVersionKind() + + definitionsGVR schema.GroupVersionResource = definitionGVK.GroupVersion().WithResource("policydefinitions") + bindingsGVR schema.GroupVersionResource = bindingGVK.GroupVersion().WithResource("policybindings") +) + +func init() { + fakeRestMapper.Add(definitionGVK, meta.RESTScopeRoot) + fakeRestMapper.Add(bindingGVK, meta.RESTScopeNamespace) + fakeRestMapper.Add(paramsGVK, meta.RESTScopeNamespace) + + scheme.AddKnownTypeWithName(definitionGVK, &FakePolicyDefinition{}) + scheme.AddKnownTypeWithName(bindingGVK, &FakePolicyBinding{}) + + scheme.AddKnownTypeWithName((&FakePolicyDefinitionList{}).GroupVersionKind(), &FakePolicyDefinitionList{}) + scheme.AddKnownTypeWithName((&FakePolicyBindingList{}).GroupVersionKind(), &FakePolicyBindingList{}) + + scheme.AddKnownTypeWithName(paramsGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(schema.GroupVersionKind{ + Group: paramsGVK.Group, + Version: paramsGVK.Version, + Kind: paramsGVK.Kind + "List", + }, &unstructured.UnstructuredList{}) +} + +// Starts CEL admission controller and sets up a plugin configured with it as well +// as object trackers for manipulating the objects available to the system +// +// ParamTracker only knows the gvk `paramGVK`. If in the future we need to +// support multiple types of params this function needs to be augmented +// +// PolicyTracker expects FakePolicyDefinition and FakePolicyBinding types +func setupTest(t *testing.T) (plugin admission.ValidationInterface, paramTracker, policyTracker clienttesting.ObjectTracker, controller *celAdmissionController) { + testContext, testContextCancel := context.WithCancel(context.Background()) + t.Cleanup(testContextCancel) + + dynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) + tracker := clienttesting.NewObjectTracker(scheme, codecs.UniversalDecoder()) + + // Set up fake informers that return instances of mock Policy definitoins + // and mock policy bindings + fakeDefinitionsInformer := cache.NewSharedIndexInformer(&cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { + return tracker.List(definitionsGVR, definitionGVK, "") + }, + WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { + return tracker.Watch(definitionsGVR, "") + }, + }, &FakePolicyDefinition{}, 30*time.Second, nil) + + fakeBindingsInformer := cache.NewSharedIndexInformer(&cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { + return tracker.List(bindingsGVR, bindingGVK, "") + }, + WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { + return tracker.Watch(bindingsGVR, "") + }, + }, &FakePolicyBinding{}, 30*time.Second, nil) + + go fakeDefinitionsInformer.Run(testContext.Done()) + go fakeBindingsInformer.Run(testContext.Done()) + + admissionController := NewAdmissionController( + fakeDefinitionsInformer, + fakeBindingsInformer, + nil, // objectConverter is unused by the `FakePolicyDefinition` compile func + fakeRestMapper, + dynamicClient, + ).(*celAdmissionController) + + handler, err := NewPlugin() + require.NoError(t, err) + + pluginInitializer := NewPluginInitializer(admissionController) + pluginInitializer.Initialize(handler) + err = admission.ValidateInitialization(handler) + require.NoError(t, err) + + go admissionController.Run(testContext.Done()) + return handler, dynamicClient.Tracker(), tracker, admissionController +} + +// Gets the last reconciled value in the controller of an object with the same +// gvk and name as the given object +func (c *celAdmissionController) getCurrentObject(obj runtime.Object) (runtime.Object, error) { + accessor, err := meta.Accessor(obj) + if err != nil { + return nil, err + } + + c.mutex.RLock() + defer c.mutex.RUnlock() + + switch obj.(type) { + case *unstructured.Unstructured: + paramSource := obj.GetObjectKind().GroupVersionKind() + var paramInformer generic.Informer[*unstructured.Unstructured] + if paramInfo, ok := c.paramsCRDControllers[paramSource]; ok { + paramInformer = paramInfo.controller.Informer() + } else { + return nil, fmt.Errorf("paramSource kind `%v` not known", paramSource.String()) + } + + // Param type. Just check informer for its GVK + item, err := paramInformer.Get(accessor.GetName()) + if err != nil { + if k8serrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + + return item, nil + case PolicyBinding: + namespacedName := accessor.GetNamespace() + "/" + accessor.GetName() + info, ok := c.bindingInfos[namespacedName] + if !ok { + return nil, nil + } + + return info.lastReconciledValue, nil + case PolicyDefinition: + namespacedName := accessor.GetNamespace() + "/" + accessor.GetName() + info, ok := c.definitionInfo[namespacedName] + if !ok { + return nil, nil + } + + return info.lastReconciledValue, nil + default: + panic(fmt.Errorf("unhandled object type: %T", obj)) + } +} + +// Waits for the given objects to have been the latest reconciled values of +// their gvk/name in the controller +func waitForReconcile(ctx context.Context, controller *celAdmissionController, objects ...runtime.Object) error { + return wait.PollWithContext(ctx, 200*time.Millisecond, 3*time.Hour, func(ctx context.Context) (done bool, err error) { + for _, obj := range objects { + currentValue, err := controller.getCurrentObject(obj) + if err != nil { + return false, err + } + + objMeta, err := meta.Accessor(obj) + if err != nil { + return false, err + } + valueMeta, err := meta.Accessor(currentValue) + if err != nil { + return false, err + } + + if len(objMeta.GetResourceVersion()) == 0 { + return false, fmt.Errorf("%s named %s has no resource version. please ensure your test objects have an RV", + obj.GetObjectKind().GroupVersionKind().String(), objMeta.GetName()) + } else if len(valueMeta.GetResourceVersion()) == 0 { + return false, fmt.Errorf("%s named %s has no resource version. please ensure your test objects have an RV", + currentValue.GetObjectKind().GroupVersionKind().String(), valueMeta.GetName()) + } else if objMeta.GetResourceVersion() != valueMeta.GetResourceVersion() { + return false, nil + } + } + + return true, nil + }) +} + +// Waits for the admissoin controller to have no knowledge of the objects +// with the given GVKs and namespace/names +func waitForReconcileDeletion(ctx context.Context, controller *celAdmissionController, objects ...runtime.Object) error { + return wait.PollWithContext(ctx, 200*time.Millisecond, 3*time.Hour, func(ctx context.Context) (done bool, err error) { + for _, obj := range objects { + currentValue, err := controller.getCurrentObject(obj) + if err != nil { + return false, err + } + + if currentValue != nil { + return false, nil + } + } + + return true, nil + }) +} + +func attributeRecord( + old, new runtime.Object, + operation admission.Operation, +) admission.Attributes { + accessor, err := meta.Accessor(new) + if err != nil { + panic(err) + } + + gvk := new.GetObjectKind().GroupVersionKind() + mapping, err := fakeRestMapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + panic(err) + } + + return admission.NewAttributesRecord( + new, + old, + gvk, + accessor.GetNamespace(), + accessor.GetName(), + mapping.Resource, + "", + operation, + nil, + false, + nil, + ) +} + +func ptrTo[T any](obj T) *T { + return &obj +} + +//////////////////////////////////////////////////////////////////////////////// +// Functionality Tests +//////////////////////////////////////////////////////////////////////////////// + +func TestBasicPolicyDefinitionFailure(t *testing.T) { + testContext, testContextCancel := context.WithCancel(context.Background()) + defer testContextCancel() + + datalock := sync.Mutex{} + passedParams := []*unstructured.Unstructured{} + numCompiles := 0 + + fakeParams := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": paramsGVK.GroupVersion().String(), + "kind": paramsGVK.Kind, + "metadata": map[string]interface{}{ + "name": "replicas-test.example.com", + "resourceVersion": "1", + }, + "maxReplicas": int64(3), + }, + } + + // Push some fake + denyPolicy := &FakePolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denypolicy.example.com", + ResourceVersion: "1", + }, + CompileFunc: ptrTo(func(converter ObjectConverter) (EvaluatorFunc, error) { + datalock.Lock() + numCompiles += 1 + datalock.Unlock() + + return func(a admission.Attributes, params *unstructured.Unstructured) []PolicyDecision { + datalock.Lock() + passedParams = append(passedParams, params) + datalock.Unlock() + + // Policy always denies + return []PolicyDecision{ + { + Kind: Deny, + Message: "Denied", + }, + } + }, nil + }), + ParamSource: ¶msGVK, + FailurePolicy: Fail, + } + + denyBinding := &FakePolicyBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denybinding.example.com", + Namespace: "", + ResourceVersion: "1", + }, + Params: "replicas-test.example.com", + Policy: "denypolicy.example.com", + } + + handler, paramTracker, tracker, controller := setupTest(t) + + require.NoError(t, paramTracker.Add(fakeParams)) + require.NoError(t, tracker.Add(denyPolicy)) + require.NoError(t, tracker.Add(denyBinding)) + + // Wait for controller to reconcile given objects + require.NoError(t, + waitForReconcile( + testContext, controller, + fakeParams, denyBinding, denyPolicy)) + + err := handler.Validate( + testContext, + // Object is irrelevant/unchecked for this test. Just test that + // the evaluator is executed, and returns a denial + attributeRecord(nil, denyBinding, admission.Create), + &admission.RuntimeObjectInterfaces{}, + ) + + require.ErrorContains(t, err, `{"kind":"Deny","message":"Denied"}`) + + require.Equal(t, []*unstructured.Unstructured{fakeParams}, passedParams) +} + +// Shows that if a definition does not match the input, it will not be used. +// But with a different input it will be used. +func TestDefinitionDoesntMatch(t *testing.T) { + handler, paramTracker, tracker, controller := setupTest(t) + testContext, testContextCancel := context.WithCancel(context.Background()) + defer testContextCancel() + + datalock := sync.Mutex{} + passedParams := []*unstructured.Unstructured{} + numCompiles := 0 + + denyPolicy := &FakePolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denypolicy.example.com", + ResourceVersion: "1", + }, + MatchFunc: ptrTo(func(a admission.Attributes) bool { + // Match names with even-numbered length + obj := a.GetObject() + + accessor, err := meta.Accessor(obj) + if err != nil { + t.Fatal(err) + return false + } + + return len(accessor.GetName())%2 == 0 + }), + CompileFunc: ptrTo(func(converter ObjectConverter) (EvaluatorFunc, error) { + datalock.Lock() + numCompiles += 1 + datalock.Unlock() + + return func(a admission.Attributes, params *unstructured.Unstructured) []PolicyDecision { + datalock.Lock() + passedParams = append(passedParams, params) + datalock.Unlock() + + // Policy always denies + return []PolicyDecision{ + { + Kind: Deny, + Message: "Denied", + }, + } + }, nil + }), + ParamSource: ¶msGVK, + FailurePolicy: Fail, + } + + fakeParams := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": paramsGVK.GroupVersion().String(), + "kind": paramsGVK.Kind, + "metadata": map[string]interface{}{ + "name": "replicas-test.example.com", + "resourceVersion": "1", + }, + "maxReplicas": int64(3), + }, + } + + denyBinding := &FakePolicyBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denybinding.example.com", + Namespace: "", + ResourceVersion: "1", + }, + Params: "replicas-test.example.com", + Policy: "denypolicy.example.com", + } + + require.NoError(t, paramTracker.Add(fakeParams)) + require.NoError(t, tracker.Add(denyPolicy)) + require.NoError(t, tracker.Add(denyBinding)) + + // Wait for controller to reconcile given objects + require.NoError(t, + waitForReconcile( + testContext, controller, + fakeParams, denyBinding, denyPolicy)) + + // Validate a non-matching input. + // Should pass validation with no error. + + nonMatchingParams := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": paramsGVK.GroupVersion().String(), + "kind": paramsGVK.Kind, + "metadata": map[string]interface{}{ + "name": "oddlength", + "resourceVersion": "1", + }, + }, + } + require.NoError(t, + handler.Validate(testContext, + attributeRecord( + nil, nonMatchingParams, + admission.Create), &admission.RuntimeObjectInterfaces{})) + require.Zero(t, numCompiles) + require.Empty(t, passedParams) + + // Validate a matching input. + // Should match and be denied. + matchingParams := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": paramsGVK.GroupVersion().String(), + "kind": paramsGVK.Kind, + "metadata": map[string]interface{}{ + "name": "evenlength", + "resourceVersion": "1", + }, + }, + } + require.ErrorContains(t, + handler.Validate(testContext, + attributeRecord( + nil, matchingParams, + admission.Create), &admission.RuntimeObjectInterfaces{}), + `{"kind":"Deny","message":"Denied"}`) + require.Equal(t, numCompiles, 1) + require.Equal(t, passedParams, []*unstructured.Unstructured{fakeParams}) +} + +func TestReconfigureBinding(t *testing.T) { + testContext, testContextCancel := context.WithCancel(context.Background()) + defer testContextCancel() + + datalock := sync.Mutex{} + passedParams := []*unstructured.Unstructured{} + numCompiles := 0 + + fakeParams := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": paramsGVK.GroupVersion().String(), + "kind": paramsGVK.Kind, + "metadata": map[string]interface{}{ + "name": "replicas-test.example.com", + "resourceVersion": "1", + }, + "maxReplicas": int64(3), + }, + } + + fakeParams2 := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": paramsGVK.GroupVersion().String(), + "kind": paramsGVK.Kind, + "metadata": map[string]interface{}{ + "name": "replicas-test2.example.com", + "resourceVersion": "2", + }, + "maxReplicas": int64(35), + }, + } + + denyPolicy := &FakePolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denypolicy.example.com", + ResourceVersion: "1", + }, + CompileFunc: ptrTo(func(converter ObjectConverter) (EvaluatorFunc, error) { + datalock.Lock() + numCompiles += 1 + datalock.Unlock() + + return func(a admission.Attributes, params *unstructured.Unstructured) []PolicyDecision { + datalock.Lock() + passedParams = append(passedParams, params) + datalock.Unlock() + + // Policy always denies + return []PolicyDecision{ + { + Kind: Deny, + Message: "Denied", + }, + } + }, nil + }), + ParamSource: ¶msGVK, + FailurePolicy: Fail, + } + + denyBinding := &FakePolicyBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denybinding.example.com", + ResourceVersion: "1", + }, + Params: "replicas-test.example.com", + Policy: "denypolicy.example.com", + } + + denyBinding2 := &FakePolicyBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denybinding.example.com", + ResourceVersion: "2", + }, + Params: "replicas-test2.example.com", + Policy: "denypolicy.example.com", + } + + handler, paramTracker, tracker, controller := setupTest(t) + + require.NoError(t, paramTracker.Add(fakeParams)) + require.NoError(t, tracker.Add(denyPolicy)) + require.NoError(t, tracker.Add(denyBinding)) + + // Wait for controller to reconcile given objects + require.NoError(t, + waitForReconcile( + testContext, controller, + fakeParams, denyBinding, denyPolicy)) + + ar := attributeRecord(nil, denyBinding, admission.Create) + + err := handler.Validate( + testContext, + attributeRecord(nil, denyBinding, admission.Create), + &admission.RuntimeObjectInterfaces{}, + ) + + // Expect validation to fail for first time due to binding unconditionally + // failing + require.ErrorContains(t, err, `{"kind":"Deny","message":"Denied"}`, "expect policy validation error") + + // Expect `Compile` only called once + require.Equal(t, 1, numCompiles, "expect `Compile` to be called only once") + + // Show Evaluator was called + require.Len(t, passedParams, 1, "expect evaluator is called due to proper configuration") + + // Update the tracker to point at different params + require.NoError(t, tracker.Update(bindingsGVR, denyBinding2, "")) + + // Wait for update to propagate + // Wait for controller to reconcile given objects + require.NoError(t, waitForReconcile(testContext, controller, denyBinding2)) + + err = handler.Validate( + testContext, + ar, + &admission.RuntimeObjectInterfaces{}, + ) + + require.ErrorContains(t, err, `{"decision":{"kind":"Deny","message":"configuration error: replicas-test2.example.com not found"}`) + require.Equal(t, 1, numCompiles, "expect compile is not called when there is configuration error") + require.Len(t, passedParams, 1, "expect evaluator was not called when there is configuration error") + + // Add the missing params + require.NoError(t, paramTracker.Add(fakeParams2)) + + // Wait for update to propagate + require.NoError(t, waitForReconcile(testContext, controller, fakeParams2)) + + // Expect validation to now fail again. + err = handler.Validate( + testContext, + ar, + &admission.RuntimeObjectInterfaces{}, + ) + + // Expect validation to fail the third time due to validation failure + require.ErrorContains(t, err, `{"kind":"Deny","message":"Denied"}`, "expected a true policy failure, not a configuration error") + require.Equal(t, []*unstructured.Unstructured{fakeParams, fakeParams2}, passedParams, "expected call to `Validate` to cause call to evaluator") + require.Equal(t, 2, numCompiles, "expect changing binding causes a recompile") +} + +// Shows that a policy which is in effect will stop being in effect when removed +func TestRemoveDefinition(t *testing.T) { + testContext, testContextCancel := context.WithCancel(context.Background()) + defer testContextCancel() + + datalock := sync.Mutex{} + passedParams := []*unstructured.Unstructured{} + numCompiles := 0 + + fakeParams := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": paramsGVK.GroupVersion().String(), + "kind": paramsGVK.Kind, + "metadata": map[string]interface{}{ + "name": "replicas-test.example.com", + "resourceVersion": "1", + }, + "maxReplicas": int64(3), + }, + } + + denyPolicy := &FakePolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denypolicy.example.com", + ResourceVersion: "1", + }, + CompileFunc: ptrTo(func(converter ObjectConverter) (EvaluatorFunc, error) { + datalock.Lock() + numCompiles += 1 + datalock.Unlock() + + return func(a admission.Attributes, params *unstructured.Unstructured) []PolicyDecision { + datalock.Lock() + passedParams = append(passedParams, params) + datalock.Unlock() + + // Policy always denies + return []PolicyDecision{ + { + Kind: Deny, + Message: "Denied", + }, + } + }, nil + }), + ParamSource: ¶msGVK, + FailurePolicy: Fail, + } + denyBinding := &FakePolicyBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denybinding.example.com", + Namespace: "", + ResourceVersion: "1", + }, + Params: "replicas-test.example.com", + Policy: "denypolicy.example.com", + } + + handler, paramTracker, tracker, controller := setupTest(t) + + require.NoError(t, paramTracker.Add(fakeParams)) + require.NoError(t, tracker.Add(denyPolicy)) + require.NoError(t, tracker.Add(denyBinding)) + + // Wait for controller to reconcile given objects + require.NoError(t, + waitForReconcile( + testContext, controller, + fakeParams, denyBinding, denyPolicy)) + + record := attributeRecord(nil, denyBinding, admission.Create) + require.ErrorContains(t, + handler.Validate( + testContext, + record, + &admission.RuntimeObjectInterfaces{}, + ), + `{"kind":"Deny","message":"Denied"}`) + + require.Equal(t, []*unstructured.Unstructured{fakeParams}, passedParams) + require.NoError(t, tracker.Delete(definitionsGVR, denyPolicy.Namespace, denyPolicy.Name)) + require.NoError(t, waitForReconcileDeletion(testContext, controller, denyPolicy)) + + require.NoError(t, handler.Validate( + testContext, + // Object is irrelevant/unchecked for this test. Just test that + // the evaluator is executed, and returns a denial + record, + &admission.RuntimeObjectInterfaces{}, + )) + +} + +// Shows that a binding which is in effect will stop being in effect when removed +func TestRemoveBinding(t *testing.T) { + testContext, testContextCancel := context.WithCancel(context.Background()) + defer testContextCancel() + + datalock := sync.Mutex{} + passedParams := []*unstructured.Unstructured{} + numCompiles := 0 + + fakeParams := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": paramsGVK.GroupVersion().String(), + "kind": paramsGVK.Kind, + "metadata": map[string]interface{}{ + "name": "replicas-test.example.com", + "resourceVersion": "1", + }, + "maxReplicas": int64(3), + }, + } + + // Push some fake + denyPolicy := &FakePolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denypolicy.example.com", + ResourceVersion: "1", + }, + CompileFunc: ptrTo(func(converter ObjectConverter) (EvaluatorFunc, error) { + datalock.Lock() + numCompiles += 1 + datalock.Unlock() + + return func(a admission.Attributes, params *unstructured.Unstructured) []PolicyDecision { + datalock.Lock() + passedParams = append(passedParams, params) + datalock.Unlock() + + // Policy always denies + return []PolicyDecision{ + { + Kind: Deny, + Message: "Denied", + }, + } + }, nil + }), + ParamSource: ¶msGVK, + FailurePolicy: Fail, + } + + denyBinding := &FakePolicyBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denybinding.example.com", + Namespace: "", + ResourceVersion: "1", + }, + Params: "replicas-test.example.com", + Policy: "denypolicy.example.com", + } + + handler, paramTracker, tracker, controller := setupTest(t) + + require.NoError(t, paramTracker.Add(fakeParams)) + require.NoError(t, tracker.Add(denyPolicy)) + require.NoError(t, tracker.Add(denyBinding)) + + // Wait for controller to reconcile given objects + require.NoError(t, + waitForReconcile( + testContext, controller, + fakeParams, denyBinding, denyPolicy)) + + record := attributeRecord(nil, denyBinding, admission.Create) + + require.ErrorContains(t, + handler.Validate( + testContext, + record, + &admission.RuntimeObjectInterfaces{}, + ), + `{"kind":"Deny","message":"Denied"}`) + + require.Equal(t, []*unstructured.Unstructured{fakeParams}, passedParams) + require.NoError(t, tracker.Delete(bindingsGVR, denyBinding.Namespace, denyBinding.Name)) + require.NoError(t, waitForReconcileDeletion(testContext, controller, denyBinding)) + + require.ErrorContains(t, handler.Validate( + testContext, + // Object is irrelevant/unchecked for this test. Just test that + // the evaluator is executed, and returns a denial + record, + &admission.RuntimeObjectInterfaces{}, + ), `{"decision":{"kind":"Deny","message":"configuration error: no bindings found"}`) +} + +// Shows that an error is surfaced if a paramSource specified in a binding does +// not actually exist +func TestInvalidParamSourceGVK(t *testing.T) { + testContext, testContextCancel := context.WithCancel(context.Background()) + defer testContextCancel() + handler, _, tracker, controller := setupTest(t) + passedParams := make(chan *unstructured.Unstructured) + + denyPolicy := &FakePolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denypolicy.example.com", + Namespace: "", + ResourceVersion: "1", + }, + CompileFunc: ptrTo(func(converter ObjectConverter) (EvaluatorFunc, error) { + return func(a admission.Attributes, params *unstructured.Unstructured) []PolicyDecision { + // Policy always denies + return []PolicyDecision{ + { + Kind: Deny, + Message: "Denied", + }, + } + }, nil + }), + ParamSource: ptrTo(paramsGVK.GroupVersion().WithKind("BadParamKind")), + FailurePolicy: Fail, + } + + denyBinding := &FakePolicyBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denybinding.example.com", + Namespace: "", + ResourceVersion: "1", + }, + Params: "replicas-test.example.com", + Policy: "denypolicy.example.com", + } + + require.NoError(t, tracker.Add(denyPolicy)) + require.NoError(t, tracker.Add(denyBinding)) + + // Wait for controller to reconcile given objects + require.NoError(t, + waitForReconcile( + testContext, controller, + denyBinding, denyPolicy)) + + err := handler.Validate( + testContext, + attributeRecord(nil, denyBinding, admission.Create), + &admission.RuntimeObjectInterfaces{}, + ) + + // expect the specific error to be that the param was not found, not that CRD + // is not existing + require.ErrorContains(t, err, + `{"decision":{"kind":"Deny","message":"configuration error: failed to find resource for param source: 'example.com/v1, Kind=BadParamKind'"}`) + + close(passedParams) + require.Len(t, passedParams, 0) +} + +// Shows that an error is surfaced if a param specified in a binding does not +// actually exist +func TestInvalidParamSourceInstanceName(t *testing.T) { + testContext, testContextCancel := context.WithCancel(context.Background()) + defer testContextCancel() + handler, _, tracker, controller := setupTest(t) + + datalock := sync.Mutex{} + passedParams := []*unstructured.Unstructured{} + numCompiles := 0 + + denyPolicy := &FakePolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denypolicy.example.com", + ResourceVersion: "1", + }, + CompileFunc: ptrTo(func(converter ObjectConverter) (EvaluatorFunc, error) { + datalock.Lock() + numCompiles += 1 + datalock.Unlock() + + return func(a admission.Attributes, params *unstructured.Unstructured) []PolicyDecision { + datalock.Lock() + passedParams = append(passedParams, params) + datalock.Unlock() + + // Policy always denies + return []PolicyDecision{ + { + Kind: Deny, + Message: "Denied", + }, + } + }, nil + }), + ParamSource: ¶msGVK, + FailurePolicy: Fail, + } + + denyBinding := &FakePolicyBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denybinding.example.com", + Namespace: "", + ResourceVersion: "1", + }, + Params: "replicas-test.example.com", + Policy: "denypolicy.example.com", + } + + require.NoError(t, tracker.Add(denyPolicy)) + require.NoError(t, tracker.Add(denyBinding)) + + // Wait for controller to reconcile given objects + require.NoError(t, + waitForReconcile( + testContext, controller, + denyBinding, denyPolicy)) + + err := handler.Validate( + testContext, + attributeRecord(nil, denyBinding, admission.Create), + &admission.RuntimeObjectInterfaces{}, + ) + + // expect the specific error to be that the param was not found, not that CRD + // is not existing + require.ErrorContains(t, err, + `{"decision":{"kind":"Deny","message":"configuration error: replicas-test.example.com not found"}`) + require.Len(t, passedParams, 0) +} + +// Shows that a definition with no param source works just fine, and has +// nil params passed to its evaluator. +// +// Also shows that if binding has specified params in this instance then they +// are silently ignored. +func TestEmptyParamSource(t *testing.T) { + testContext, testContextCancel := context.WithCancel(context.Background()) + defer testContextCancel() + + datalock := sync.Mutex{} + passedParams := []*unstructured.Unstructured{} + numCompiles := 0 + + // Push some fake + denyPolicy := &FakePolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denypolicy.example.com", + ResourceVersion: "1", + }, + CompileFunc: ptrTo(func(converter ObjectConverter) (EvaluatorFunc, error) { + datalock.Lock() + numCompiles += 1 + datalock.Unlock() + + return func(a admission.Attributes, params *unstructured.Unstructured) []PolicyDecision { + datalock.Lock() + passedParams = append(passedParams, params) + datalock.Unlock() + + // Policy always denies + return []PolicyDecision{ + { + Kind: Deny, + Message: "Denied", + }, + } + }, nil + }), + ParamSource: nil, + FailurePolicy: Fail, + } + + denyBinding := &FakePolicyBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denybinding.example.com", + Namespace: "", + ResourceVersion: "1", + }, + Params: "replicas-test.example.com", + Policy: "denypolicy.example.com", + } + + handler, _, tracker, controller := setupTest(t) + + require.NoError(t, tracker.Add(denyPolicy)) + require.NoError(t, tracker.Add(denyBinding)) + + // Wait for controller to reconcile given objects + require.NoError(t, + waitForReconcile( + testContext, controller, + denyBinding, denyPolicy)) + + err := handler.Validate( + testContext, + // Object is irrelevant/unchecked for this test. Just test that + // the evaluator is executed, and returns a denial + attributeRecord(nil, denyBinding, admission.Create), + &admission.RuntimeObjectInterfaces{}, + ) + + require.ErrorContains(t, err, `{"kind":"Deny","message":"Denied"}`) + require.Equal(t, 1, numCompiles) + require.Equal(t, []*unstructured.Unstructured{nil}, passedParams) +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/cel/fake.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/cel/fake.go new file mode 100644 index 00000000000..9163f0e9e8a --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/cel/fake.go @@ -0,0 +1,258 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cel + +import ( + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" +) + +//////////////////////////////////////////////////////////////////////////////// +// Fake Policy Definitions +//////////////////////////////////////////////////////////////////////////////// + +type FakePolicyDefinition struct { + metav1.TypeMeta + metav1.ObjectMeta + + // Function called when `Matches` is called + // If nil, a default function that always returns true is used + // Specified as a function pointer so that this type is still comparable + MatchFunc *func(admission.Attributes) bool `json:"-"` + + // Func invoked for implementation of `Compile` + // Specified as a function pointer so that this type is still comparable + CompileFunc *func(converter ObjectConverter) (EvaluatorFunc, error) `json:"-"` + + // GVK to return when ParamSource() is called + ParamSource *schema.GroupVersionKind `json:"paramSource"` + + FailurePolicy FailurePolicy `json:"failurePolicy"` +} + +var _ PolicyDefinition = &FakePolicyDefinition{} + +func (f *FakePolicyDefinition) SetGroupVersionKind(kind schema.GroupVersionKind) { + f.TypeMeta.APIVersion = kind.GroupVersion().String() + f.TypeMeta.Kind = kind.Kind +} + +func (f *FakePolicyDefinition) GroupVersionKind() schema.GroupVersionKind { + parsedGV, err := schema.ParseGroupVersion(f.TypeMeta.APIVersion) + if err != nil || f.TypeMeta.Kind == "" || parsedGV.Empty() { + return schema.GroupVersionKind{ + Group: "admission.k8s.io", + Version: "v1alpha1", + Kind: "PolicyDefinition", + } + } + return schema.GroupVersionKind{ + Group: parsedGV.Group, + Version: parsedGV.Version, + Kind: f.TypeMeta.Kind, + } +} + +func (f *FakePolicyDefinition) GetObjectKind() schema.ObjectKind { + return f +} + +func (f *FakePolicyDefinition) DeepCopyObject() runtime.Object { + copied := *f + f.ObjectMeta.DeepCopyInto(&copied.ObjectMeta) + return &copied +} + +func (f *FakePolicyDefinition) GetName() string { + return f.ObjectMeta.Name +} + +func (f *FakePolicyDefinition) GetNamespace() string { + return f.ObjectMeta.Namespace +} + +func (f *FakePolicyDefinition) Matches(a admission.Attributes) bool { + if f.MatchFunc == nil || *f.MatchFunc == nil { + return true + } + return (*f.MatchFunc)(a) +} + +func (f *FakePolicyDefinition) Compile( + converter ObjectConverter, + mapper meta.RESTMapper, +) (EvaluatorFunc, error) { + if f.CompileFunc == nil || *f.CompileFunc == nil { + panic("must provide a CompileFunc to policy definition") + } + return (*f.CompileFunc)(converter) +} + +func (f *FakePolicyDefinition) GetParamSource() *schema.GroupVersionKind { + return f.ParamSource +} + +func (f *FakePolicyDefinition) GetFailurePolicy() FailurePolicy { + return f.FailurePolicy +} + +//////////////////////////////////////////////////////////////////////////////// +// Fake Policy Binding +//////////////////////////////////////////////////////////////////////////////// + +type FakePolicyBinding struct { + metav1.TypeMeta + metav1.ObjectMeta + + // Specified as a function pointer so that this type is still comparable + MatchFunc *func(admission.Attributes) bool `json:"-"` + Params string `json:"params"` + Policy string `json:"policy"` +} + +var _ PolicyBinding = &FakePolicyBinding{} + +func (f *FakePolicyBinding) SetGroupVersionKind(kind schema.GroupVersionKind) { + f.TypeMeta.APIVersion = kind.GroupVersion().String() + f.TypeMeta.Kind = kind.Kind +} + +func (f *FakePolicyBinding) GroupVersionKind() schema.GroupVersionKind { + parsedGV, err := schema.ParseGroupVersion(f.TypeMeta.APIVersion) + if err != nil || f.TypeMeta.Kind == "" || parsedGV.Empty() { + return schema.GroupVersionKind{ + Group: "admission.k8s.io", + Version: "v1alpha1", + Kind: "PolicyBinding", + } + } + return schema.GroupVersionKind{ + Group: parsedGV.Group, + Version: parsedGV.Version, + Kind: f.TypeMeta.Kind, + } +} + +func (f *FakePolicyBinding) GetObjectKind() schema.ObjectKind { + return f +} + +func (f *FakePolicyBinding) DeepCopyObject() runtime.Object { + copied := *f + f.ObjectMeta.DeepCopyInto(&copied.ObjectMeta) + return &copied +} + +func (f *FakePolicyBinding) Matches(a admission.Attributes) bool { + if f.MatchFunc == nil || *f.MatchFunc == nil { + return true + } + return (*f.MatchFunc)(a) +} + +func (f *FakePolicyBinding) GetTargetDefinition() (namespace, name string) { + return f.Namespace, f.Policy +} + +func (f *FakePolicyBinding) GetTargetParams() (namespace, name string) { + return f.Namespace, f.Params +} + +/// List Types + +type FakePolicyDefinitionList struct { + metav1.TypeMeta + metav1.ListMeta + + Items []FakePolicyDefinition +} + +func (f *FakePolicyDefinitionList) SetGroupVersionKind(kind schema.GroupVersionKind) { + f.TypeMeta.APIVersion = kind.GroupVersion().String() + f.TypeMeta.Kind = kind.Kind +} + +func (f *FakePolicyDefinitionList) GroupVersionKind() schema.GroupVersionKind { + parsedGV, err := schema.ParseGroupVersion(f.TypeMeta.APIVersion) + if err != nil || f.TypeMeta.Kind == "" || parsedGV.Empty() { + return schema.GroupVersionKind{ + Group: "admission.k8s.io", + Version: "v1alpha1", + Kind: "PolicyDefinitionList", + } + } + return schema.GroupVersionKind{ + Group: parsedGV.Group, + Version: parsedGV.Version, + Kind: f.TypeMeta.Kind, + } +} + +func (f *FakePolicyDefinitionList) GetObjectKind() schema.ObjectKind { + return f +} + +func (f *FakePolicyDefinitionList) DeepCopyObject() runtime.Object { + copied := *f + f.ListMeta.DeepCopyInto(&copied.ListMeta) + copied.Items = make([]FakePolicyDefinition, len(f.Items)) + copy(copied.Items, f.Items) + return &copied +} + +type FakePolicyBindingList struct { + metav1.TypeMeta + metav1.ListMeta + + Items []FakePolicyBinding +} + +func (f *FakePolicyBindingList) SetGroupVersionKind(kind schema.GroupVersionKind) { + f.TypeMeta.APIVersion = kind.GroupVersion().String() + f.TypeMeta.Kind = kind.Kind +} + +func (f *FakePolicyBindingList) GroupVersionKind() schema.GroupVersionKind { + parsedGV, err := schema.ParseGroupVersion(f.TypeMeta.APIVersion) + if err != nil || f.TypeMeta.Kind == "" || parsedGV.Empty() { + return schema.GroupVersionKind{ + Group: "admission.k8s.io", + Version: "v1alpha1", + Kind: "PolicyBindingList", + } + } + return schema.GroupVersionKind{ + Group: parsedGV.Group, + Version: parsedGV.Version, + Kind: f.TypeMeta.Kind, + } +} + +func (f *FakePolicyBindingList) GetObjectKind() schema.ObjectKind { + return f +} + +func (f *FakePolicyBindingList) DeepCopyObject() runtime.Object { + copied := *f + f.ListMeta.DeepCopyInto(&copied.ListMeta) + copied.Items = make([]FakePolicyBinding, len(f.Items)) + copy(copied.Items, f.Items) + return &copied +}